diff --git a/packages/adapters/opencode-local/src/index.test.ts b/packages/adapters/opencode-local/src/index.test.ts new file mode 100644 index 00000000..3a23d109 --- /dev/null +++ b/packages/adapters/opencode-local/src/index.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { buildOpenCodeModelProfiles, DEFAULT_OPENCODE_CHEAP_MODEL } from "./index.js"; + +describe("buildOpenCodeModelProfiles cheap lane", () => { + it("defaults to the upstream Codex mini model with variant low", () => { + const [cheap] = buildOpenCodeModelProfiles({}); + expect(cheap.key).toBe("cheap"); + expect(cheap.adapterConfig).toEqual({ model: DEFAULT_OPENCODE_CHEAP_MODEL, variant: "low" }); + }); + + it("uses PAPERCLIP_OPENCODE_CHEAP_MODEL when set (no variant, gateway models may not support it)", () => { + const [cheap] = buildOpenCodeModelProfiles({ PAPERCLIP_OPENCODE_CHEAP_MODEL: "anthropic/gw/m" }); + expect(cheap.adapterConfig).toEqual({ model: "anthropic/gw/m" }); + }); + + it("falls back to PAPERCLIP_OPENCODE_SMALL_MODEL so one setting covers both budget lanes", () => { + const [cheap] = buildOpenCodeModelProfiles({ PAPERCLIP_OPENCODE_SMALL_MODEL: "anthropic/gw/small" }); + expect(cheap.adapterConfig).toEqual({ model: "anthropic/gw/small" }); + }); + + it("prefers CHEAP_MODEL over SMALL_MODEL when both are set", () => { + const [cheap] = buildOpenCodeModelProfiles({ + PAPERCLIP_OPENCODE_CHEAP_MODEL: "anthropic/gw/cheap", + PAPERCLIP_OPENCODE_SMALL_MODEL: "anthropic/gw/small", + }); + expect(cheap.adapterConfig).toEqual({ model: "anthropic/gw/cheap" }); + }); +}); diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 6f36e378..77fe859c 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -61,18 +61,40 @@ export const models: Array<{ id: string; label: string }> = [ { id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" }, ]; -export const modelProfiles: AdapterModelProfileDefinition[] = [ - { - key: "cheap", - label: "Cheap", - description: "Use OpenCode's known Codex mini model as the budget lane.", - adapterConfig: { - model: "openai/gpt-5.1-codex-mini", - variant: "low", +export const DEFAULT_OPENCODE_CHEAP_MODEL = "openai/gpt-5.1-codex-mini"; + +// The "cheap" budget profile (used for recovery retries and other low-cost lanes). +// Defaults to OpenCode's known Codex mini model, but is overridable so a deployment +// routing through a gateway that does not serve that model (e.g. an EU LLM gateway) +// can point the budget lane at a gateway-served model instead -- otherwise recovery +// retries fail with "model not found". PAPERCLIP_OPENCODE_CHEAP_MODEL takes priority; +// PAPERCLIP_OPENCODE_SMALL_MODEL (the auxiliary/title model) is reused as a sensible +// fallback so a single setting covers both budget lanes. The default keeps the +// upstream behaviour (with the Codex `variant: "low"`). +// +// This module is shared client/server code (the UI imports it for +// DEFAULT_OPENCODE_LOCAL_MODEL etc.), so it must not touch the global `process` +// unguarded: in the browser (Vite dev middleware serves it untransformed) +// a bare `process.env` throws ReferenceError at module load and takes the whole +// app down. Guard with `typeof process` and fall back to an empty env. +export function buildOpenCodeModelProfiles( + env: NodeJS.ProcessEnv = typeof process === "undefined" ? {} : process.env, +): AdapterModelProfileDefinition[] { + const override = (env.PAPERCLIP_OPENCODE_CHEAP_MODEL ?? env.PAPERCLIP_OPENCODE_SMALL_MODEL)?.trim(); + return [ + { + key: "cheap", + label: "Cheap", + description: "Budget lane model for recovery retries and other low-cost tasks.", + adapterConfig: override + ? { model: override } + : { model: DEFAULT_OPENCODE_CHEAP_MODEL, variant: "low" }, + source: "adapter_default", }, - source: "adapter_default", - }, -]; + ]; +} + +export const modelProfiles: AdapterModelProfileDefinition[] = buildOpenCodeModelProfiles(); export const agentConfigurationDoc = `# opencode_local agent configuration diff --git a/packages/adapters/opencode-local/src/server/execute.test.ts b/packages/adapters/opencode-local/src/server/execute.test.ts new file mode 100644 index 00000000..3b7bcadd --- /dev/null +++ b/packages/adapters/opencode-local/src/server/execute.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { ensureRemoteOpenCodeModelConfiguredAndAvailable } from "./execute.js"; + +describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => { + afterEach(() => { + delete process.env.OPENCODE_ALLOW_ALL_MODELS; + }); + + // The remote/sandbox execution path must honour OPENCODE_ALLOW_ALL_MODELS just + // like the local path: gateway-routed models (e.g. anthropic// + // via Bifrost) never appear in `opencode models`, so the availability probe + // must be skipped. The early return happens before the executionTarget is ever + // touched, so a bogus target proves the probe was not run. + const bogusTarget = {} as never; + + it("skips the remote availability probe when OPENCODE_ALLOW_ALL_MODELS is set in the run env", async () => { + await expect( + ensureRemoteOpenCodeModelConfiguredAndAvailable({ + runId: "run-1", + executionTarget: bogusTarget, + command: "opencode", + model: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", + cwd: "/tmp", + env: { OPENCODE_ALLOW_ALL_MODELS: "true" }, + timeoutSec: 30, + graceSec: 5, + }), + ).resolves.toBeUndefined(); + }); + + it("honours OPENCODE_ALLOW_ALL_MODELS from the process env", async () => { + process.env.OPENCODE_ALLOW_ALL_MODELS = "1"; + await expect( + ensureRemoteOpenCodeModelConfiguredAndAvailable({ + runId: "run-2", + executionTarget: bogusTarget, + command: "opencode", + model: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", + cwd: "/tmp", + env: {}, + timeoutSec: 30, + graceSec: 5, + }), + ).resolves.toBeUndefined(); + }); + + it("still enforces provider/model format even when the bypass flag is set", async () => { + await expect( + ensureRemoteOpenCodeModelConfiguredAndAvailable({ + runId: "run-3", + executionTarget: bogusTarget, + command: "opencode", + model: "", + cwd: "/tmp", + env: { OPENCODE_ALLOW_ALL_MODELS: "true" }, + timeoutSec: 30, + graceSec: 5, + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 72702dbd..b7ae92bc 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -47,6 +47,7 @@ import { import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { ensureOpenCodeModelConfiguredAndAvailable, + isTruthyEnvFlag, parseOpenCodeModelsOutput, requireOpenCodeModelId, } from "./models.js"; @@ -79,7 +80,7 @@ function resolveOpenCodeBiller(env: Record, provider: string | n const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20; const REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC = 120; -async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { +export async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { runId: string; executionTarget: NonNullable; command: string; @@ -90,6 +91,17 @@ async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { graceSec: number; }) { const model = requireOpenCodeModelId(input.model); + + // When the caller opts into OPENCODE_ALLOW_ALL_MODELS, OpenCode accepts any + // provider/model at run time (e.g. gateway-routed models that never appear in + // `opencode models` output). Honour that on the REMOTE path too by skipping the + // remote availability probe; we still enforce the provider/model format above. + // Mirrors the local ensureOpenCodeModelConfiguredAndAvailable bypass. Prefer the + // explicit run env, then the process env. + if (isTruthyEnvFlag(input.env.OPENCODE_ALLOW_ALL_MODELS ?? process.env.OPENCODE_ALLOW_ALL_MODELS)) { + return; + } + const defaultProbeTimeoutSec = input.executionTarget.kind === "remote" && input.executionTarget.transport === "sandbox" ? REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC @@ -548,8 +560,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const args = ["run", "--format", "json"]; + if (printLogs) args.push("--print-logs"); if (resumeSessionId) args.push("--session", resumeSessionId); if (model) args.push("--model", model); if (variant) args.push("--variant", variant); diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts index 0f0bea0c..4a3f775c 100644 --- a/packages/adapters/opencode-local/src/server/models.test.ts +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -9,6 +9,7 @@ import { describe("openCode models", () => { afterEach(() => { delete process.env.PAPERCLIP_OPENCODE_COMMAND; + delete process.env.OPENCODE_ALLOW_ALL_MODELS; resetOpenCodeModelsCacheForTests(); }); @@ -44,4 +45,33 @@ describe("openCode models", () => { }), ).rejects.toThrow("Failed to start command"); }); + + it("skips the availability check when OPENCODE_ALLOW_ALL_MODELS is set in the run env", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", + env: { OPENCODE_ALLOW_ALL_MODELS: "true" }, + }), + ).resolves.toEqual([ + { id: "anthropic/tensorix/deepseek/deepseek-chat-v3.1", label: "anthropic/tensorix/deepseek/deepseek-chat-v3.1" }, + ]); + }); + + it("honours OPENCODE_ALLOW_ALL_MODELS from the process env", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + process.env.OPENCODE_ALLOW_ALL_MODELS = "1"; + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ model: "anthropic/gateway/some-model" }), + ).resolves.toEqual([{ id: "anthropic/gateway/some-model", label: "anthropic/gateway/some-model" }]); + }); + + it("still enforces provider/model format when OPENCODE_ALLOW_ALL_MODELS is set", async () => { + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "not-a-valid-id", + env: { OPENCODE_ALLOW_ALL_MODELS: "true" }, + }), + ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); + }); }); diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index 38d64b20..5838e070 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -175,6 +175,12 @@ export async function discoverOpenCodeModelsCached(input: { return models; } +export function isTruthyEnvFlag(value: string | undefined): boolean { + if (value === undefined) return false; + const v = value.trim().toLowerCase(); + return v === "true" || v === "1" || v === "yes"; +} + export async function ensureOpenCodeModelConfiguredAndAvailable(input: { model?: unknown; command?: unknown; @@ -183,6 +189,16 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: { }): Promise { const model = requireOpenCodeModelId(input.model); + // When the caller opts into OPENCODE_ALLOW_ALL_MODELS, OpenCode accepts any + // provider/model at run time (e.g. gateway-routed models that never appear in + // `opencode models` output). Honour that by skipping the availability probe; + // we still enforce the provider/model format above and do not second-guess + // the configured model. Prefer the explicit run env, then the process env. + const env = normalizeEnv(input.env); + if (isTruthyEnvFlag(env.OPENCODE_ALLOW_ALL_MODELS ?? process.env.OPENCODE_ALLOW_ALL_MODELS)) { + return [{ id: model, label: model }]; + } + const models = await discoverOpenCodeModelsCached({ command: input.command, cwd: input.cwd, diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts index c5c396ac..19f61c06 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.test.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -65,6 +65,190 @@ describe("prepareOpenCodeRuntimeConfig", () => { await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow(); }); + it("merges custom providers from PAPERCLIP_OPENCODE_PROVIDERS into the config", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const providers = { + bifrost: { + npm: "@ai-sdk/openai-compatible", + name: "Bifrost EU", + options: { + baseURL: "http://gateway.example.svc.cluster.local:8080/v1", + apiKey: "{env:ANTHROPIC_API_KEY}", + }, + models: { "example/model-a": { name: "Model A" } }, + }, + }; + + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { + XDG_CONFIG_HOME: configHome, + PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers), + }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as Record; + expect(runtimeConfig).toMatchObject({ + permission: { read: "allow", external_directory: "allow" }, + provider: providers, + }); + expect(prepared.notes.some((n) => n.includes("bifrost"))).toBe(true); + await prepared.cleanup(); + }); + + it("reads PAPERCLIP_OPENCODE_PROVIDERS from process.env when absent from the run env", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const providers = { bifrost: { npm: "@ai-sdk/openai-compatible", models: { "example/model-a": {} } } }; + process.env.PAPERCLIP_OPENCODE_PROVIDERS = JSON.stringify(providers); + try { + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as Record; + expect(runtimeConfig).toMatchObject({ provider: providers }); + await prepared.cleanup(); + } finally { + delete process.env.PAPERCLIP_OPENCODE_PROVIDERS; + } + }); + + it("expands {env:VAR} placeholders in custom providers using the run/process env (bakes the literal vk)", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const providers = { + bifrost: { + npm: "@ai-sdk/openai-compatible", + options: { baseURL: "http://bifrost/v1", apiKey: "{env:ANTHROPIC_API_KEY}" }, + models: { "example/model-a": {} }, + }, + }; + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers), ANTHROPIC_API_KEY: "sk-bf-REALVK" }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as { provider: { bifrost: { options: { apiKey: string } } } }; + // The {env:...} placeholder must be replaced with the literal value, so OpenCode + // does not depend on its sandboxed process env carrying the key. + expect(runtimeConfig.provider.bifrost.options.apiKey).toBe("sk-bf-REALVK"); + await prepared.cleanup(); + }); + + it("leaves an unresolvable {env:VAR} placeholder intact", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const providers = { bifrost: { options: { apiKey: "{env:DEFINITELY_UNSET_VAR_XYZ}" }, models: { "x/y": {} } } }; + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers) }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as { provider: { bifrost: { options: { apiKey: string } } } }; + expect(runtimeConfig.provider.bifrost.options.apiKey).toBe("{env:DEFINITELY_UNSET_VAR_XYZ}"); + await prepared.cleanup(); + }); + + it("pins small_model from PAPERCLIP_OPENCODE_SMALL_MODEL", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_SMALL_MODEL: "example/model-a" }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as { small_model?: string }; + expect(runtimeConfig.small_model).toBe("example/model-a"); + await prepared.cleanup(); + }); + + it("ignores malformed PAPERCLIP_OPENCODE_PROVIDERS without writing a provider block and surfaces a note", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "not json" }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as Record; + expect(runtimeConfig.provider).toBeUndefined(); + expect(prepared.notes).toContain( + "PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON; custom providers ignored.", + ); + await prepared.cleanup(); + }); + + it("surfaces a note when PAPERCLIP_OPENCODE_PROVIDERS is valid JSON but not an object", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "[1,2,3]" }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as Record; + expect(runtimeConfig.provider).toBeUndefined(); + expect(prepared.notes).toContain( + "PAPERCLIP_OPENCODE_PROVIDERS is set but is not a JSON object; custom providers ignored.", + ); + await prepared.cleanup(); + }); + + it("surfaces skipped provider entries with non-object values and keeps the usable ones", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { + XDG_CONFIG_HOME: configHome, + PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify({ + bifrost: "http://gateway.example/v1", + usable: { options: { baseURL: "http://gateway.example/v1" } }, + }), + }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as { provider?: Record }; + expect(runtimeConfig.provider?.usable).toBeDefined(); + expect(runtimeConfig.provider?.bifrost).toBeUndefined(); + expect(prepared.notes).toContain( + "PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: bifrost.", + ); + await prepared.cleanup(); + }); + + it("surfaces skipped provider entries when no usable entries remain", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { + XDG_CONFIG_HOME: configHome, + PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify({ bifrost: "http://gateway.example/v1" }), + }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as Record; + expect(runtimeConfig.provider).toBeUndefined(); + expect(prepared.notes).toContain( + "PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: bifrost.", + ); + await prepared.cleanup(); + }); + it("respects explicit opt-out", async () => { const configHome = await makeConfigHome(); const prepared = await prepareOpenCodeRuntimeConfig({ diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts index 6f9a338b..146b371f 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -21,6 +21,69 @@ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +// Recursively replace {env:VAR} placeholders with the resolved value. Used to bake +// gateway provider secrets (e.g. the LLM-gateway virtual key) into opencode.json +// SERVER-SIDE, where the value is reliably present. OpenCode's own {env:...} +// resolution happens inside the (possibly sandboxed) run process, whose env +// plumbing is not guaranteed to carry the key to OpenCode's spawned server -- so +// we resolve it here. Unresolvable placeholders are left intact for OpenCode to try. +function expandEnvPlaceholders(value: T, resolve: (name: string) => string | undefined): T { + if (typeof value === "string") { + return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => { + const resolved = resolve(name); + return resolved !== undefined && resolved.length > 0 ? resolved : match; + }) as unknown as T; + } + if (Array.isArray(value)) { + return value.map((entry) => expandEnvPlaceholders(entry, resolve)) as unknown as T; + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = expandEnvPlaceholders(entry, resolve); + } + return out as unknown as T; + } + return value; +} + +function parseProviderConfig( + raw: unknown, + resolveEnv: (name: string) => string | undefined, + notes: string[], +): Record | null { + if (typeof raw !== "string" || raw.trim().length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Surface the misconfiguration instead of silently dropping the provider + // block; an unparseable value would otherwise be undiagnosable. + notes.push("PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON; custom providers ignored."); + return null; + } + if (!isPlainObject(parsed)) { + notes.push( + "PAPERCLIP_OPENCODE_PROVIDERS is set but is not a JSON object; custom providers ignored.", + ); + return null; + } + // Only keep provider entries that are themselves objects; surface the ones + // we drop so a malformed entry is just as diagnosable as malformed JSON. + const providers: Record = {}; + const skipped: string[] = []; + for (const [key, value] of Object.entries(parsed)) { + if (isPlainObject(value)) providers[key] = expandEnvPlaceholders(value, resolveEnv); + else skipped.push(key); + } + if (skipped.length > 0) { + notes.push( + `PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: ${skipped.join(", ")}.`, + ); + } + return Object.keys(providers).length > 0 ? providers : null; +} + async function readJsonObject(filepath: string): Promise> { try { const raw = await fs.readFile(filepath, "utf8"); @@ -81,13 +144,55 @@ export async function prepareOpenCodeRuntimeConfig(input: { const existingPermission = isPlainObject(existingConfig.permission) ? existingConfig.permission : {}; - const nextConfig = { + const notes = [ + "Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.", + ]; + + // Merge gateway/custom provider definitions supplied via PAPERCLIP_OPENCODE_PROVIDERS + // (a JSON object in OpenCode's `provider` shape). OpenCode resolves a `--model + // provider/model` only when that model exists in a provider's `models` map, and + // OPENCODE_ALLOW_ALL_MODELS does NOT bypass its internal getModel(). So routing a + // gateway model (e.g. an EU LLM gateway exposing OpenAI-compatible /v1) requires a + // custom provider with an explicit models map. We accept it as config (not + // hard-coded) so the gateway URL, key env, and model list stay declarative. + const resolveEnv = (name: string): string | undefined => input.env[name] ?? process.env[name]; + const gatewayProviders = parseProviderConfig( + input.env.PAPERCLIP_OPENCODE_PROVIDERS ?? process.env.PAPERCLIP_OPENCODE_PROVIDERS, + resolveEnv, + notes, + ); + const existingProvider = isPlainObject(existingConfig.provider) ? existingConfig.provider : {}; + const nextProvider = gatewayProviders + ? { ...existingProvider, ...gatewayProviders } + : existingProvider; + if (gatewayProviders) { + notes.push( + `Injected ${Object.keys(gatewayProviders).length} custom OpenCode provider(s) from PAPERCLIP_OPENCODE_PROVIDERS: ${Object.keys(gatewayProviders).join(", ")}.`, + ); + } + + const nextConfig: Record = { ...existingConfig, permission: { ...existingPermission, external_directory: "allow", }, }; + if (Object.keys(nextProvider).length > 0) { + nextConfig.provider = nextProvider; + } + + // Pin OpenCode's auxiliary "small" model (used for session-title generation and + // other helper tasks) via PAPERCLIP_OPENCODE_SMALL_MODEL. OpenCode otherwise + // defaults the small model to a built-in provider default (e.g. a claude-* model + // for the anthropic provider); when that provider is repointed at a gateway that + // does not serve that exact model, the title-gen call fails and aborts the run. + // Setting small_model to a gateway-served model keeps every call on supported models. + const smallModel = (input.env.PAPERCLIP_OPENCODE_SMALL_MODEL ?? process.env.PAPERCLIP_OPENCODE_SMALL_MODEL)?.trim(); + if (smallModel) { + nextConfig.small_model = smallModel; + notes.push(`Pinned OpenCode small_model to ${smallModel}.`); + } await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8"); return { @@ -95,9 +200,7 @@ export async function prepareOpenCodeRuntimeConfig(input: { ...input.env, XDG_CONFIG_HOME: runtimeConfigHome, }, - notes: [ - "Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.", - ], + notes, cleanup: async () => { await fs.rm(runtimeConfigHome, { recursive: true, force: true }); },