diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 98de0babf5..c23ebd5ef1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -960,7 +960,7 @@ export namespace Provider { return state().then((state) => state.providers) } - function resolveModelBaseURL(model: Model, options: Record): string { + export function resolveModelBaseURL(model: Model, options: Record): string { const template = model.api?.url ?? "" if (!template) return "" const matches = [...template.matchAll(/{{([^}]+)}}/g)] diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 8a2009646e..ba34157d45 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -381,6 +381,160 @@ test("parseModel handles model IDs with slashes", () => { expect(result.modelID).toBe("anthropic/claude-3-opus") }) +test("resolveModelBaseURL returns empty when url missing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = { api: { url: "" } } as Provider.Model + expect(Provider.resolveModelBaseURL(model, {})).toBe("") + }, + }) +}) + +test("resolveModelBaseURL returns template when no placeholders", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = { api: { url: "https://api.example.com/v1" } } as Provider.Model + expect(Provider.resolveModelBaseURL(model, {})).toBe("https://api.example.com/v1") + }, + }) +}) + +test("resolveModelBaseURL replaces placeholders from env", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("CUSTOM_HOST", "env.example.com") + }, + fn: async () => { + const model = { api: { url: "https://{{CUSTOM_HOST}}/v1" } } as Provider.Model + expect(Provider.resolveModelBaseURL(model, {})).toBe("https://env.example.com/v1") + Env.remove("CUSTOM_HOST") + }, + }) +}) + +test("resolveModelBaseURL falls back to options when env missing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = { api: { url: "https://{{HOST}}/v1" } } as Provider.Model + expect(Provider.resolveModelBaseURL(model, { HOST: "options.example.com" })).toBe( + "https://options.example.com/v1", + ) + }, + }) +}) + +test("resolveModelBaseURL chooses first non-empty key", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = { api: { url: "https://{{PRIMARY|SECONDARY}}/v1" } } as Provider.Model + const options = { + PRIMARY: "", + SECONDARY: "fallback.example.com", + } + expect(Provider.resolveModelBaseURL(model, options)).toBe("https://fallback.example.com/v1") + }, + }) +}) + +test("resolveModelBaseURL keeps unresolved placeholders", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = { api: { url: "https://{{MISSING}}/v1" } } as Provider.Model + expect(Provider.resolveModelBaseURL(model, {})).toBe("https://{{MISSING}}/v1") + }, + }) +}) + +test("resolveModelBaseURL replaces multiple placeholders", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = { api: { url: "https://{{HOST}}/{{VERSION}}" } } as Provider.Model + const options = { + HOST: "multi.example.com", + VERSION: "v1", + } + expect(Provider.resolveModelBaseURL(model, options)).toBe("https://multi.example.com/v1") + }, + }) +}) + test("defaultModel returns first available model when no config set", async () => { await using tmp = await tmpdir({ init: async (dir) => {