diff --git a/src/agents/agent-paths.ts b/src/agents/agent-paths.ts new file mode 100644 index 000000000..a66e1c223 --- /dev/null +++ b/src/agents/agent-paths.ts @@ -0,0 +1,21 @@ +import path from "node:path"; + +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; + +const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent"); + +export function resolveClawdisAgentDir(): string { + const override = + process.env.CLAWDIS_AGENT_DIR?.trim() || + process.env.PI_CODING_AGENT_DIR?.trim() || + DEFAULT_AGENT_DIR; + return resolveUserPath(override); +} + +export function ensureClawdisAgentEnv(): string { + const dir = resolveClawdisAgentDir(); + if (!process.env.CLAWDIS_AGENT_DIR) process.env.CLAWDIS_AGENT_DIR = dir; + if (!process.env.PI_CODING_AGENT_DIR) process.env.PI_CODING_AGENT_DIR = dir; + return dir; +} + diff --git a/src/agents/context.ts b/src/agents/context.ts index eb442820f..6535d20ce 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -1,13 +1,19 @@ // Lazy-load pi-coding-agent model metadata so we can infer context windows when // the agent reports a model id. This includes custom models.json entries. +import { loadConfig } from "../config/config.js"; +import { resolveClawdisAgentDir } from "./agent-paths.js"; +import { ensureClawdisModelsJson } from "./models-config.js"; + type ModelEntry = { id: string; contextWindow?: number }; const MODEL_CACHE = new Map(); const loadPromise = (async () => { try { const { discoverModels } = await import("@mariozechner/pi-coding-agent"); - const models = discoverModels() as ModelEntry[]; + const cfg = loadConfig(); + await ensureClawdisModelsJson(cfg); + const models = discoverModels(resolveClawdisAgentDir()) as ModelEntry[]; for (const m of models) { if (!m?.id) continue; if (typeof m.contextWindow === "number" && m.contextWindow > 0) { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts new file mode 100644 index 000000000..0cffb6a0c --- /dev/null +++ b/src/agents/models-config.ts @@ -0,0 +1,64 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { loadConfig, type ClawdisConfig } from "../config/config.js"; +import { ensureClawdisAgentEnv, resolveClawdisAgentDir } from "./agent-paths.js"; + +type ModelsConfig = NonNullable; + +const DEFAULT_MODE: NonNullable = "merge"; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +async function readJson(pathname: string): Promise { + try { + const raw = await fs.readFile(pathname, "utf8"); + return JSON.parse(raw) as unknown; + } catch { + return null; + } +} + +export async function ensureClawdisModelsJson( + config?: ClawdisConfig, +): Promise<{ agentDir: string; wrote: boolean }> { + const cfg = config ?? loadConfig(); + const providers = cfg.models?.providers; + if (!providers || Object.keys(providers).length === 0) { + return { agentDir: resolveClawdisAgentDir(), wrote: false }; + } + + const mode = cfg.models?.mode ?? DEFAULT_MODE; + const agentDir = ensureClawdisAgentEnv(); + const targetPath = path.join(agentDir, "models.json"); + + let mergedProviders = providers; + let existingRaw = ""; + if (mode === "merge") { + const existing = await readJson(targetPath); + if (isRecord(existing) && isRecord(existing.providers)) { + const existingProviders = existing.providers as Record< + string, + NonNullable[string] + >; + mergedProviders = { ...existingProviders, ...providers }; + } + } + + const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; + try { + existingRaw = await fs.readFile(targetPath, "utf8"); + } catch { + existingRaw = ""; + } + + if (existingRaw === next) { + return { agentDir, wrote: false }; + } + + await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); + await fs.writeFile(targetPath, next, { mode: 0o600 }); + return { agentDir, wrote: true }; +} diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index b1c29132b..01c3ad9dc 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -26,6 +26,8 @@ import type { ClawdisConfig } from "../config/config.js"; import { splitMediaFromOutput } from "../media/parse.js"; import { enqueueCommand } from "../process/command-queue.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveClawdisAgentDir } from "./agent-paths.js"; +import { ensureClawdisModelsJson } from "./models-config.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { buildBootstrapContextFiles, @@ -84,7 +86,6 @@ const ACTIVE_EMBEDDED_RUNS = new Map(); const OAUTH_FILENAME = "oauth.json"; const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); -const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent"); let oauthStorageConfigured = false; let cachedDefaultApiKey: ReturnType | null = null; @@ -94,14 +95,6 @@ function resolveClawdisOAuthPath(): string { return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME); } -function resolveAgentDir(): string { - const override = - process.env.CLAWDIS_AGENT_DIR?.trim() || - process.env.PI_CODING_AGENT_DIR?.trim() || - DEFAULT_AGENT_DIR; - return resolveUserPath(override); -} - function loadOAuthStorageAt(pathname: string): OAuthStorage | null { if (!fsSync.existsSync(pathname)) return null; try { @@ -284,10 +277,8 @@ export async function runEmbeddedPiAgent(params: { const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; - const agentDir = resolveAgentDir(); - if (!process.env.PI_CODING_AGENT_DIR) { - process.env.PI_CODING_AGENT_DIR = agentDir; - } + await ensureClawdisModelsJson(params.config); + const agentDir = resolveClawdisAgentDir(); const { model, error } = resolveModel(provider, modelId, agentDir); if (!model) { throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); diff --git a/src/config/config.ts b/src/config/config.ts index 9f0bb55ea..71cc566b6 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -173,6 +173,51 @@ export type SkillsInstallConfig = { nodeManager?: "npm" | "pnpm" | "yarn"; }; +export type ModelApi = + | "openai-completions" + | "openai-responses" + | "anthropic-messages" + | "google-generative-ai"; + +export type ModelCompatConfig = { + supportsStore?: boolean; + supportsDeveloperRole?: boolean; + supportsReasoningEffort?: boolean; + maxTokensField?: "max_completion_tokens" | "max_tokens"; +}; + +export type ModelDefinitionConfig = { + id: string; + name: string; + api?: ModelApi; + reasoning: boolean; + input: Array<"text" | "image">; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + headers?: Record; + compat?: ModelCompatConfig; +}; + +export type ModelProviderConfig = { + baseUrl: string; + apiKey: string; + api?: ModelApi; + headers?: Record; + authHeader?: boolean; + models: ModelDefinitionConfig[]; +}; + +export type ModelsConfig = { + mode?: "merge" | "replace"; + providers?: Record; +}; + export type ClawdisConfig = { identity?: { name?: string; @@ -183,6 +228,7 @@ export type ClawdisConfig = { browser?: BrowserConfig; skillsLoad?: SkillsLoadConfig; skillsInstall?: SkillsInstallConfig; + models?: ModelsConfig; inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) /** Agent working directory (preferred). Used as the default cwd for agent runs. */ @@ -233,6 +279,58 @@ export const CONFIG_PATH_CLAWDIS = path.join( "clawdis.json", ); +const ModelApiSchema = z.union([ + z.literal("openai-completions"), + z.literal("openai-responses"), + z.literal("anthropic-messages"), + z.literal("google-generative-ai"), +]); + +const ModelCompatSchema = z + .object({ + supportsStore: z.boolean().optional(), + supportsDeveloperRole: z.boolean().optional(), + supportsReasoningEffort: z.boolean().optional(), + maxTokensField: z + .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) + .optional(), + }) + .optional(); + +const ModelDefinitionSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + api: ModelApiSchema.optional(), + reasoning: z.boolean(), + input: z.array(z.union([z.literal("text"), z.literal("image")])), + cost: z.object({ + input: z.number(), + output: z.number(), + cacheRead: z.number(), + cacheWrite: z.number(), + }), + contextWindow: z.number().positive(), + maxTokens: z.number().positive(), + headers: z.record(z.string()).optional(), + compat: ModelCompatSchema, +}); + +const ModelProviderSchema = z.object({ + baseUrl: z.string().min(1), + apiKey: z.string().min(1), + api: ModelApiSchema.optional(), + headers: z.record(z.string()).optional(), + authHeader: z.boolean().optional(), + models: z.array(ModelDefinitionSchema), +}); + +const ModelsConfigSchema = z + .object({ + mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), + providers: z.record(ModelProviderSchema).optional(), + }) + .optional(); + const ClawdisSchema = z.object({ identity: z .object({ @@ -280,6 +378,7 @@ const ClawdisSchema = z.object({ attachOnly: z.boolean().optional(), }) .optional(), + models: ModelsConfigSchema, inbound: z .object({ allowFrom: z.array(z.string()).optional(), diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 0be0b90fe..9990ab5a8 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -11,6 +11,8 @@ import chalk from "chalk"; import { type WebSocket, WebSocketServer } from "ws"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; +import { resolveClawdisAgentDir } from "../agents/agent-paths.js"; +import { ensureClawdisModelsJson } from "../agents/models-config.js"; import { installSkill } from "../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; @@ -220,7 +222,7 @@ async function loadGatewayModelCatalog(): Promise { modelCatalogPromise = (async () => { const piSdk = (await import("@mariozechner/pi-coding-agent")) as { - discoverModels: () => Array<{ + discoverModels: (agentDir?: string) => Array<{ id: string; name?: string; provider: string; @@ -235,7 +237,9 @@ async function loadGatewayModelCatalog(): Promise { contextWindow?: number; }> = []; try { - entries = piSdk.discoverModels(); + const cfg = loadConfig(); + await ensureClawdisModelsJson(cfg); + entries = piSdk.discoverModels(resolveClawdisAgentDir()); } catch { entries = []; }