All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
111 lines
3.1 KiB
TypeScript
111 lines
3.1 KiB
TypeScript
import {
|
|
BadGatewayException,
|
|
Injectable,
|
|
Logger,
|
|
ServiceUnavailableException,
|
|
} from "@nestjs/common";
|
|
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import type { ChatMessage } from "./chat-proxy.dto";
|
|
|
|
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
|
|
|
|
interface ContainerConnection {
|
|
url: string;
|
|
token: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ChatProxyService {
|
|
private readonly logger = new Logger(ChatProxyService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly containerLifecycle: ContainerLifecycleService
|
|
) {}
|
|
|
|
// Get the user's OpenClaw container URL and mark it active.
|
|
async getContainerUrl(userId: string): Promise<string> {
|
|
const { url } = await this.getContainerConnection(userId);
|
|
return url;
|
|
}
|
|
|
|
// Proxy chat request to OpenClaw.
|
|
async proxyChat(
|
|
userId: string,
|
|
messages: ChatMessage[],
|
|
signal?: AbortSignal
|
|
): Promise<Response> {
|
|
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
|
|
const model = await this.getPreferredModel(userId);
|
|
const requestInit: RequestInit = {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${gatewayToken}`,
|
|
},
|
|
body: JSON.stringify({
|
|
messages,
|
|
model,
|
|
stream: true,
|
|
}),
|
|
};
|
|
|
|
if (signal) {
|
|
requestInit.signal = signal;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${containerUrl}/v1/chat/completions`, requestInit);
|
|
|
|
if (!response.ok) {
|
|
const detail = await this.readResponseText(response);
|
|
const status = `${String(response.status)} ${response.statusText}`.trim();
|
|
this.logger.warn(
|
|
detail ? `OpenClaw returned ${status}: ${detail}` : `OpenClaw returned ${status}`
|
|
);
|
|
throw new BadGatewayException(`OpenClaw returned ${status}`);
|
|
}
|
|
|
|
return response;
|
|
} catch (error: unknown) {
|
|
if (error instanceof BadGatewayException) {
|
|
throw error;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.logger.warn(`Failed to proxy chat request: ${message}`);
|
|
throw new ServiceUnavailableException("Failed to proxy chat to OpenClaw");
|
|
}
|
|
}
|
|
|
|
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
|
|
const connection = await this.containerLifecycle.ensureRunning(userId);
|
|
await this.containerLifecycle.touch(userId);
|
|
return connection;
|
|
}
|
|
|
|
private async getPreferredModel(userId: string): Promise<string> {
|
|
const config = await this.prisma.userAgentConfig.findUnique({
|
|
where: { userId },
|
|
select: { primaryModel: true },
|
|
});
|
|
|
|
const primaryModel = config?.primaryModel?.trim();
|
|
if (!primaryModel) {
|
|
return DEFAULT_OPENCLAW_MODEL;
|
|
}
|
|
|
|
return primaryModel;
|
|
}
|
|
|
|
private async readResponseText(response: Response): Promise<string | null> {
|
|
try {
|
|
const text = (await response.text()).trim();
|
|
return text.length > 0 ? text : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|