diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index bcb115edf41..4f4d9604971 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -498,6 +498,92 @@ export namespace Provider { }, } }, + unbound: async (input) => { + const config = await Config.get() + const providerConfig = config.provider?.["unbound"] + + // Get API key from env, auth storage, or config (consistent with cloudflare-ai-gateway pattern) + const apiKey = await (async () => { + const envKey = input.env.map((key) => Env.get(key)).find(Boolean) + if (envKey) return envKey + const auth = await Auth.get(input.id) + if (auth?.type === "api") return auth.key + return providerConfig?.options?.apiKey + })() + + if (!apiKey) return { autoload: false } + + const baseURL = providerConfig?.options?.baseURL ?? "https://api.getunbound.ai/v1" + + // Fetch available models from Unbound gateway + try { + const response = await fetch(`${baseURL}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }) + + if (response.ok) { + const data = await response.json() + const models = data.data ?? data.models ?? [] + + if (models.length > 0) { + delete input.models["default"] + for (const model of models) { + const modelId = model.id ?? model.name + const params = model.parameters ?? model + const pricing = model.pricing ?? {} + const supportsImages = params.supports_images ?? params.supportsImages ?? false + + input.models[modelId] = { + id: modelId, + providerID: "unbound", + name: model.name ?? modelId, + api: { id: modelId, url: baseURL, npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: { supportsPromptCaching: params.supports_prompt_caching ?? params.supportsPromptCaching ?? false }, + cost: { + input: parseFloat(pricing.input_token_price ?? pricing.inputTokenPrice) || 0, + output: parseFloat(pricing.output_token_price ?? pricing.outputTokenPrice) || 0, + cache: { + read: parseFloat(pricing.cache_read_price ?? pricing.cacheReadPrice) || 0, + write: parseFloat(pricing.cache_write_price ?? pricing.cacheWritePrice) || 0, + }, + }, + limit: { + context: params.context_window ?? params.contextWindow ?? 128000, + output: params.max_tokens ?? params.maxTokens ?? 4096, + }, + capabilities: { + temperature: true, + reasoning: false, + attachment: supportsImages, + toolcall: true, + input: { text: true, audio: false, image: supportsImages, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: new Date().toISOString().split("T")[0], + variants: {}, + } + } + } + } else { + log.warn("Failed to fetch Unbound models", { status: response.status, statusText: response.statusText }) + } + } catch (e) { + log.warn("Failed to fetch Unbound models, using default", { error: e }) + } + + return { + autoload: true, + options: { + headers: { + "X-Unbound-Metadata": JSON.stringify({ labels: [{ key: "app", value: "opencode" }] }), + }, + }, + } + }, } export const Model = z @@ -704,6 +790,41 @@ export namespace Provider { } } + // Add Unbound provider (AI Gateway) - models are fetched dynamically via custom loader + if (!database["unbound"]) { + database["unbound"] = { + id: "unbound", + name: "Unbound", + source: "custom", + env: ["UNBOUND_API_KEY"], + options: {}, + models: { + default: { + id: "default", + providerID: "unbound", + name: "Default Model", + api: { id: "default", url: "https://api.getunbound.ai/v1", npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 4096 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2024-01-01", + variants: {}, + }, + }, + } + } + function mergeProvider(providerID: string, provider: Partial) { const existing = providers[providerID] if (existing) { diff --git a/packages/opencode/test/provider/unbound.test.ts b/packages/opencode/test/provider/unbound.test.ts new file mode 100644 index 00000000000..7f0ac7659e0 --- /dev/null +++ b/packages/opencode/test/provider/unbound.test.ts @@ -0,0 +1,411 @@ +import { test, expect, mock, beforeEach, afterEach } from "bun:test" +import path from "path" + +// === Mocks === +// Mock BunProc to prevent real package installations during tests +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string) => pkg, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +// Mock plugins +const mockPlugin = () => ({}) +mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) +mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) + +// Import after mocks are set up +const { tmpdir } = await import("../fixture/fixture") +const { Instance } = await import("../../src/project/instance") +const { Provider } = await import("../../src/provider/provider") +const { Env } = await import("../../src/env") + +// Mock fetch for Unbound API +const originalFetch = globalThis.fetch +const mockModelsResponse = { + data: [ + { + id: "openai/gpt-4o", + name: "GPT-4o", + parameters: { + context_window: 128000, + max_tokens: 16384, + supports_images: true, + supports_prompt_caching: true, + }, + pricing: { + input_token_price: "2.50", + output_token_price: "10.00", + cache_read_price: "1.25", + cache_write_price: "2.50", + }, + }, + { + id: "anthropic/claude-3-5-sonnet", + name: "Claude 3.5 Sonnet", + parameters: { + context_window: 200000, + max_tokens: 8192, + supports_images: true, + supports_prompt_caching: true, + }, + pricing: { + input_token_price: "3.00", + output_token_price: "15.00", + cache_read_price: "0.30", + cache_write_price: "3.75", + }, + }, + ], +} + +beforeEach(() => { + // Reset fetch mock before each test + globalThis.fetch = originalFetch +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function mockUnboundFetch(response: any = mockModelsResponse, ok: boolean = true) { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString() + if (url.includes("/models")) { + return { + ok, + status: ok ? 200 : 401, + statusText: ok ? "OK" : "Unauthorized", + json: async () => response, + } as Response + } + return originalFetch(input, init) + } +} + +test("Unbound: provider loaded from UNBOUND_API_KEY env variable", async () => { + mockUnboundFetch() + + 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("UNBOUND_API_KEY", "test-unbound-api-key") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["unbound"]).toBeDefined() + expect(providers["unbound"].source).toBe("custom") + }, + }) +}) + +test("Unbound: provider loaded from config apiKey option", async () => { + mockUnboundFetch() + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + unbound: { + options: { + apiKey: "config-unbound-api-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["unbound"]).toBeDefined() + }, + }) +}) + +test("Unbound: custom baseURL from config", async () => { + mockUnboundFetch() + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + unbound: { + options: { + apiKey: "test-api-key", + baseURL: "https://custom.unbound.example.com/v1", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["unbound"]).toBeDefined() + // Models should have the custom baseURL + const model = Object.values(providers["unbound"].models)[0] + expect(model?.api.url).toBe("https://custom.unbound.example.com/v1") + }, + }) +}) + +test("Unbound: models fetched from /models endpoint", async () => { + mockUnboundFetch() + + 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("UNBOUND_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["unbound"]).toBeDefined() + + // Check that models from mock response are present + const models = providers["unbound"].models + expect(models["openai/gpt-4o"]).toBeDefined() + expect(models["anthropic/claude-3-5-sonnet"]).toBeDefined() + + // Check model properties + const gpt4o = models["openai/gpt-4o"] + expect(gpt4o.name).toBe("GPT-4o") + expect(gpt4o.limit.context).toBe(128000) + expect(gpt4o.limit.output).toBe(16384) + expect(gpt4o.capabilities.attachment).toBe(true) + expect(gpt4o.options.supportsPromptCaching).toBe(true) + }, + }) +}) + +test("Unbound: model pricing parsed correctly", async () => { + mockUnboundFetch() + + 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("UNBOUND_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + const gpt4o = providers["unbound"].models["openai/gpt-4o"] + + expect(gpt4o.cost.input).toBe(2.5) + expect(gpt4o.cost.output).toBe(10) + expect(gpt4o.cost.cache.read).toBe(1.25) + expect(gpt4o.cost.cache.write).toBe(2.5) + }, + }) +}) + +test("Unbound: X-Unbound-Metadata header set correctly", async () => { + mockUnboundFetch() + + 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("UNBOUND_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + const unboundProvider = providers["unbound"] + + expect(unboundProvider.options?.headers?.["X-Unbound-Metadata"]).toBeDefined() + const metadata = JSON.parse(unboundProvider.options?.headers?.["X-Unbound-Metadata"] as string) + expect(metadata.labels).toContainEqual({ key: "app", value: "opencode" }) + }, + }) +}) + +test("Unbound: not loaded without API 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, + init: async () => { + // Explicitly clear any env variables + Env.set("UNBOUND_API_KEY", "") + }, + fn: async () => { + const providers = await Provider.list() + // Without API key, unbound should not be in the active providers list + // (it won't have autoload: true from the custom loader) + expect(providers["unbound"]).toBeUndefined() + }, + }) +}) + +test("Unbound: falls back to default model on API failure", async () => { + // Mock a failed API response + mockUnboundFetch({}, false) + + 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("UNBOUND_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["unbound"]).toBeDefined() + // Should still have the default model when API fails + expect(providers["unbound"].models["default"]).toBeDefined() + }, + }) +}) + +test("Unbound: env variable takes precedence over config apiKey", async () => { + let capturedAuthHeader: string | null = null + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString() + if (url.includes("/models")) { + capturedAuthHeader = (init?.headers as Record)?.["Authorization"] ?? null + return { + ok: true, + status: 200, + statusText: "OK", + json: async () => mockModelsResponse, + } as Response + } + return originalFetch(input, init) + } + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + unbound: { + options: { + apiKey: "config-api-key", + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("UNBOUND_API_KEY", "env-api-key") + }, + fn: async () => { + await Provider.list() + // Env variable should take precedence + expect(capturedAuthHeader).toBe("Bearer env-api-key") + }, + }) +}) + +test("Unbound: disabled_providers excludes unbound", async () => { + mockUnboundFetch() + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + disabled_providers: ["unbound"], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("UNBOUND_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["unbound"]).toBeUndefined() + }, + }) +}) diff --git a/packages/ui/src/assets/icons/provider/unbound.svg b/packages/ui/src/assets/icons/provider/unbound.svg new file mode 100644 index 00000000000..859334fdb6f --- /dev/null +++ b/packages/ui/src/assets/icons/provider/unbound.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/components/provider-icons/types.ts b/packages/ui/src/components/provider-icons/types.ts index 89fbc0625f5..a29f5de93a8 100644 --- a/packages/ui/src/components/provider-icons/types.ts +++ b/packages/ui/src/components/provider-icons/types.ts @@ -8,6 +8,7 @@ export const iconNames = [ "zai-coding-plan", "xiaomi", "xai", + "unbound", "wandb", "vultr", "vercel",