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>
286 lines
7.1 KiB
TypeScript
286 lines
7.1 KiB
TypeScript
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
import type { LlmProvider } from "@prisma/client";
|
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { CryptoService } from "../crypto/crypto.service";
|
|
|
|
const DEFAULT_GATEWAY_PORT = 18789;
|
|
const DEFAULT_PRIMARY_MODEL = "openai/gpt-4o-mini";
|
|
|
|
type ContainerType = "user" | "system";
|
|
|
|
export interface ContainerTokenValidation {
|
|
type: ContainerType;
|
|
id: string;
|
|
}
|
|
|
|
type OpenClawModelMap = Record<string, Record<string, never>>;
|
|
|
|
interface OpenClawProviderConfig {
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
models: OpenClawModelMap;
|
|
}
|
|
|
|
interface OpenClawConfig {
|
|
gateway: {
|
|
mode: "local";
|
|
port: number;
|
|
bind: "lan";
|
|
auth: { mode: "token" };
|
|
http: {
|
|
endpoints: {
|
|
chatCompletions: { enabled: true };
|
|
};
|
|
};
|
|
};
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: string;
|
|
};
|
|
};
|
|
};
|
|
models: {
|
|
providers: Record<string, OpenClawProviderConfig>;
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class AgentConfigService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly crypto: CryptoService
|
|
) {}
|
|
|
|
// Generate complete openclaw.json for a user container
|
|
async generateUserConfig(userId: string): Promise<OpenClawConfig> {
|
|
const [userAgentConfig, providers, userContainer] = await Promise.all([
|
|
this.prisma.userAgentConfig.findUnique({
|
|
where: { userId },
|
|
}),
|
|
this.prisma.llmProvider.findMany({
|
|
where: {
|
|
userId,
|
|
isActive: true,
|
|
},
|
|
orderBy: {
|
|
createdAt: "asc",
|
|
},
|
|
}),
|
|
this.prisma.userContainer.findUnique({
|
|
where: { userId },
|
|
}),
|
|
]);
|
|
|
|
if (!userContainer) {
|
|
throw new NotFoundException(`User container not found for user ${userId}`);
|
|
}
|
|
|
|
const primaryModel =
|
|
userAgentConfig?.primaryModel ??
|
|
this.resolvePrimaryModelFromProviders(providers) ??
|
|
DEFAULT_PRIMARY_MODEL;
|
|
|
|
return this.buildOpenClawConfig(primaryModel, userContainer.gatewayPort, providers);
|
|
}
|
|
|
|
// Generate config for a system container
|
|
async generateSystemConfig(containerId: string): Promise<OpenClawConfig> {
|
|
const systemContainer = await this.prisma.systemContainer.findUnique({
|
|
where: { id: containerId },
|
|
});
|
|
|
|
if (!systemContainer) {
|
|
throw new NotFoundException(`System container ${containerId} not found`);
|
|
}
|
|
|
|
return this.buildOpenClawConfig(
|
|
systemContainer.primaryModel || DEFAULT_PRIMARY_MODEL,
|
|
systemContainer.gatewayPort,
|
|
[]
|
|
);
|
|
}
|
|
|
|
async generateConfigForContainer(
|
|
type: ContainerType,
|
|
containerId: string
|
|
): Promise<OpenClawConfig> {
|
|
if (type === "system") {
|
|
return this.generateSystemConfig(containerId);
|
|
}
|
|
|
|
const userContainer = await this.prisma.userContainer.findUnique({
|
|
where: { id: containerId },
|
|
select: { userId: true },
|
|
});
|
|
|
|
if (!userContainer) {
|
|
throw new NotFoundException(`User container ${containerId} not found`);
|
|
}
|
|
|
|
return this.generateUserConfig(userContainer.userId);
|
|
}
|
|
|
|
// Validate a container's bearer token
|
|
async validateContainerToken(token: string): Promise<ContainerTokenValidation | null> {
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
const [userContainers, systemContainers] = await Promise.all([
|
|
this.prisma.userContainer.findMany({
|
|
select: {
|
|
id: true,
|
|
gatewayToken: true,
|
|
},
|
|
}),
|
|
this.prisma.systemContainer.findMany({
|
|
select: {
|
|
id: true,
|
|
gatewayToken: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
let match: ContainerTokenValidation | null = null;
|
|
|
|
for (const container of userContainers) {
|
|
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
|
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
|
|
match = { type: "user", id: container.id };
|
|
}
|
|
}
|
|
|
|
for (const container of systemContainers) {
|
|
const storedToken = this.decryptContainerToken(container.gatewayToken);
|
|
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
|
|
match = { type: "system", id: container.id };
|
|
}
|
|
}
|
|
|
|
return match;
|
|
}
|
|
|
|
private buildOpenClawConfig(
|
|
primaryModel: string,
|
|
gatewayPort: number | null,
|
|
providers: LlmProvider[]
|
|
): OpenClawConfig {
|
|
return {
|
|
gateway: {
|
|
mode: "local",
|
|
port: gatewayPort ?? DEFAULT_GATEWAY_PORT,
|
|
bind: "lan",
|
|
auth: { mode: "token" },
|
|
http: {
|
|
endpoints: {
|
|
chatCompletions: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: primaryModel,
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: this.buildProviderConfig(providers),
|
|
},
|
|
};
|
|
}
|
|
|
|
private buildProviderConfig(providers: LlmProvider[]): Record<string, OpenClawProviderConfig> {
|
|
const providerConfig: Record<string, OpenClawProviderConfig> = {};
|
|
|
|
for (const provider of providers) {
|
|
const config: OpenClawProviderConfig = {
|
|
models: this.extractModels(provider.models),
|
|
};
|
|
|
|
const apiKey = this.decryptIfNeeded(provider.apiKey);
|
|
if (apiKey) {
|
|
config.apiKey = apiKey;
|
|
}
|
|
|
|
if (provider.baseUrl) {
|
|
config.baseUrl = provider.baseUrl;
|
|
}
|
|
|
|
providerConfig[provider.name] = config;
|
|
}
|
|
|
|
return providerConfig;
|
|
}
|
|
|
|
private extractModels(models: unknown): OpenClawModelMap {
|
|
const modelMap: OpenClawModelMap = {};
|
|
|
|
if (!Array.isArray(models)) {
|
|
return modelMap;
|
|
}
|
|
|
|
for (const modelEntry of models) {
|
|
if (typeof modelEntry === "string") {
|
|
modelMap[modelEntry] = {};
|
|
continue;
|
|
}
|
|
|
|
if (this.hasModelId(modelEntry)) {
|
|
modelMap[modelEntry.id] = {};
|
|
}
|
|
}
|
|
|
|
return modelMap;
|
|
}
|
|
|
|
private resolvePrimaryModelFromProviders(providers: LlmProvider[]): string | null {
|
|
for (const provider of providers) {
|
|
const modelIds = Object.keys(this.extractModels(provider.models));
|
|
const firstModelId = modelIds[0];
|
|
|
|
if (firstModelId) {
|
|
return `${provider.name}/${firstModelId}`;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private decryptIfNeeded(value: string | null | undefined): string | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
|
|
if (this.crypto.isEncrypted(value)) {
|
|
return this.crypto.decrypt(value);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
private decryptContainerToken(value: string): string | null {
|
|
try {
|
|
return this.decryptIfNeeded(value) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private tokensEqual(left: string, right: string): boolean {
|
|
const leftDigest = createHash("sha256").update(left, "utf8").digest();
|
|
const rightDigest = createHash("sha256").update(right, "utf8").digest();
|
|
return timingSafeEqual(leftDigest, rightDigest);
|
|
}
|
|
|
|
private hasModelId(modelEntry: unknown): modelEntry is { id: string } {
|
|
if (typeof modelEntry !== "object" || modelEntry === null || !("id" in modelEntry)) {
|
|
return false;
|
|
}
|
|
|
|
return typeof (modelEntry as { id?: unknown }).id === "string";
|
|
}
|
|
}
|