From e38194f767b4460aa09cbb760266f67b895b0e3d Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 15 Mar 2026 19:21:53 +0100 Subject: [PATCH 1/2] Dynamic model discovery from OpenAI-compatible providers --- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/provider/provider.ts | 145 ++++- .../provider.dynamic-discovery.test.ts | 558 ++++++++++++++++++ 3 files changed, 701 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/provider/provider.dynamic-discovery.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 27ba4e18671..1df76356668 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -997,6 +997,7 @@ export namespace Config { }), ) .optional(), + dynamicModelList: z.boolean().optional().describe("Enable automatic model discovery from OpenAI-compatible /models endpoint"), options: z .object({ apiKey: z.string().optional(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 349073197d7..f0b0a656fd5 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -428,9 +428,9 @@ export namespace Provider { const location = String( provider.options?.location ?? - Env.get("GOOGLE_VERTEX_LOCATION") ?? - Env.get("GOOGLE_CLOUD_LOCATION") ?? - Env.get("VERTEX_LOCATION") ?? + Env.get("GOOGLE_VERTEX_LOCATION") ?? + Env.get("GOOGLE_CLOUD_LOCATION") ?? + Env.get("VERTEX_LOCATION") ?? "us-central1", ) @@ -670,6 +670,141 @@ export namespace Provider { }, } + /** + * Populate the provider models dynamically using provider config + * Returns models or emty object if fails + */ + async function populateDynamicModels(providerID: string, provider: any): Promise> { + // Get base URL from config or thrown an exception + const baseURL = provider.options?.baseURL + if (!baseURL) { + log.error("Missing baseURL for dynamic model discovery", { providerID }) + throw new InitError({ providerID: providerID }) + } + + // Get auth credentials + const key = provider.options?.apiKey + const auth = key ? {"type": "api", "key": key} : await Auth.get(providerID) + + // Discover models + const discoveredModels = await discoverModelsFromEndpoint( + providerID, + baseURL, + auth?.type === "api" ? auth : null, + ) + return discoveredModels + } + + /** + * Discover models from OpenAI-compatible /models endpoint + * Returns discovered models or empty object if discovery fails + */ + async function discoverModelsFromEndpoint( + providerID: string, + baseURL: string, + auth: Auth.ApiAuth | null, + ): Promise> { + const models: Record = {} + + try { + const headers: Record = {} + if (auth?.type === "api" && auth.key) { + headers.Authorization = `Bearer ${auth.key}` + } + + const response = await fetch(`${baseURL}/models`, { + headers, + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + log.warn("Failed to discover models", { + providerID, + baseURL, + status: response.status, + }) + return models + } + + const json = await response.json() + + // Handle OpenAI format: { data: [{ id, ... }] } + const data = json.data + if (!Array.isArray(data)) { + log.warn("Unexpected /models response format", { providerID, format: typeof data }) + return models + } + + for (const modelData of data) { + const modelID = modelData.id + if (!modelID || typeof modelID !== "string") continue + + // Extract context length from various possible fields + const contextLength = + modelData.max_context_length ?? + modelData.context_length ?? + modelData.contextWindow ?? + modelData.max_tokens ?? + 131072 + + const context = Math.max(contextLength, 8192) // Floor at 8k for stability + const output = Math.min(Math.floor(context / 4), 16384) + + // Check for small context warning + if (context < 32768) { + log.warn("Model has small context limit", { + providerID, + modelID, + context, + }) + } + + models[modelID] = { + id: modelID, + providerID, + name: modelData.name ?? modelID, + family: modelData.family ?? "", + api: { + id: modelID, + url: baseURL, + npm: "@ai-sdk/openai-compatible", + }, + 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, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context, output }, + headers: {}, + options: {}, + release_date: modelData.created ?? "", + status: modelData.status?.value ?? "active", + } + } + + if (Object.keys(models).length > 0) { + log.info("Discovered models", { + providerID, + count: Object.keys(models).length, + models: Object.keys(models), + }) + } + } catch (error) { + log.warn("Failed to discover models", { + providerID, + url: baseURL, + error: error, + }) + } + + return models + } + export const Model = z .object({ id: ModelID.zod, @@ -1070,6 +1205,10 @@ export namespace Provider { if (provider.env) partial.env = provider.env if (provider.name) partial.name = provider.name if (provider.options) partial.options = provider.options + const hasExplicitModels = Object.keys(provider.models ?? {}).length > 0 + if (!hasExplicitModels && provider.dynamicModelList) { + partial.models = await populateDynamicModels(providerID, provider) + } mergeProvider(providerID, partial) } diff --git a/packages/opencode/test/provider/provider.dynamic-discovery.test.ts b/packages/opencode/test/provider/provider.dynamic-discovery.test.ts new file mode 100644 index 00000000000..cb4531d624c --- /dev/null +++ b/packages/opencode/test/provider/provider.dynamic-discovery.test.ts @@ -0,0 +1,558 @@ +import { test, expect, beforeEach, afterEach, mock } from "bun:test" +import path from "path" + +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" + +// Mock fetch for dynamic model discovery tests +let originalFetch: typeof global.fetch +let mockFetch: mock.Mock + +beforeEach(() => { + originalFetch = global.fetch + mockFetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }), + ) + global.fetch = mockFetch as any +}) + +afterEach(() => { + global.fetch = originalFetch + mockFetch.mockReset() +}) + +test("dynamic model discovery - successful", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock successful model discovery + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { id: "model-1", name: "Test Model 1", max_context_length: 128000 }, + { id: "model-2", name: "Test Model 2", max_context_length: 32768 }, + ], + }), + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + expect(provider).toBeDefined() + expect(provider.models["model-1"]).toBeDefined() + expect(provider.models["model-2"]).toBeDefined() + expect(provider.models["model-1"].limit.context).toBe(128000) + expect(provider.models["model-2"].limit.context).toBe(32768) + }, + }) +}) + +test("dynamic model discovery - discoverModelsFromEndpoint with small context floor", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock response with small context length + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "small-model", max_context_length: 4096 }], + }), + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + expect(provider).toBeDefined() + expect(provider.models["small-model"]).toBeDefined() + // Should apply 8k floor + expect(provider.models["small-model"].limit.context).toBe(8192) + }, + }) +}) + +test("dynamic model discovery - discoverModelsFromEndpoint missing max_context_length uses default", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock response without context length + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "model-no-context" }], + }), + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + expect(provider).toBeDefined() + expect(provider.models["model-no-context"]).toBeDefined() + // Should use default 8k + expect(provider.models["model-no-context"].limit.context).toBe(131072) + }, + }) +}) + +test("dynamic model discovery - discoverModelsFromEndpoint handles non-200 response", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock non-200 response + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 500, + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + // Should not load the provider when discovery fails and no explicit models + expect(provider).toBeUndefined() + }, + }) +}) + +test("dynamic model discovery - discoverModelsFromEndpoint handles invalid response format", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock invalid response format (no data array) + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ invalid: "format" }), + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + // Should not load the provider when discovery fails and no explicit models + expect(provider).toBeUndefined() + }, + }) +}) + +test("dynamic model discovery - endpoint request without auth when no apiKey", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock fetch to verify no auth headers + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.headers).not.toHaveProperty("Authorization") + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "model-1" }], + }), + }) + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + expect(provider).toBeDefined() + expect(provider.models["model-1"]).toBeDefined() + }, + }) +}) + +test("dynamic model discovery - endpoint request includes auth headers", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock fetch to capture headers + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.headers).toHaveProperty("Authorization") + expect(init?.headers.Authorization).toBe("Bearer test-api-key") + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "model-1" }], + }), + }) + }) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + // Mock Auth.get to return API key + const Auth = await import("../../src/auth") + Auth.Auth.get = async () => ({ type: "api", key: "test-api-key" }) + }, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + expect(provider).toBeDefined() + expect(provider.models["model-1"]).toBeDefined() + }, + }) +}) + +test("dynamic model discovery - local config API key overrides the global one", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + apiKey: "local-api-key" + }, + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + // Mock fetch to capture headers + mockFetch.mockImplementationOnce((url, init) => { + expect(init?.headers).toHaveProperty("Authorization") + expect(init?.headers.Authorization).toBe("Bearer local-api-key") + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "model-1" }], + }), + }) + }) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + // Mock Auth.get to return API key + const Auth = await import("../../src/auth") + Auth.Auth.get = async () => ({ type: "api", key: "global-api-key" }) + }, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + expect(provider).toBeDefined() + expect(provider.models["model-1"]).toBeDefined() + }, + }) +}) + +test("dynamic model discovery - provider doesn't load when dynamicModelList: false and no model list", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: false, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + // Should not load the provider when dynamicModelList is false + expect(providers["test-openai-compatible"]).toBeUndefined() + }, + }) +}) + +test("dynamic model discovery - explicitly configured models returned when specified and dynamicModelList: false", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: false, + models: { + "explicit-model": { + name: "Explicit Model", + }, + }, + }, + }, + }), + ) + }, + }) + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "model-1", name: "Test Model" }], + }), + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + expect(provider).toBeDefined() + expect(provider.models["explicit-model"]).toBeDefined() + expect(mockFetch).not.toBeCalled() + }, + }) +}) + +test("dynamic model discovery - uses explicit models when both configured", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "http://localhost:1234/v1", + }, + dynamicModelList: true, + models: { + "explicit-model": { + name: "Explicit Model", + }, + }, + }, + }, + }), + ) + }, + }) + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: "discovered-model" }], + }), + }), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const provider = providers["test-openai-compatible"] + + // Explicit models should take precedence + expect(provider).toBeDefined() + expect(provider.models["explicit-model"]).toBeDefined() + // Discovered models should not replace explicit ones, + // and the endpoint shouldn't even be called + expect(provider.models["discovered-model"]).toBeUndefined() + expect(mockFetch).not.toBeCalled() + }, + }) +}) + +test("dynamic model discovery - provider doesn't load when no baseURL", 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", + provider: { + "test-openai-compatible": { + npm: "@ai-sdk/openai-compatible", + dynamicModelList: true, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Should not load when no baseURL + expect(Provider.list()).rejects.toThrow() + }, + }) +}) From e1c0fb5331b216db8d0fad42aaa52c7b25b962c3 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 15 Mar 2026 22:37:28 +0100 Subject: [PATCH 2/2] typecheck fixes --- packages/opencode/src/provider/provider.ts | 6 +++--- .../test/provider/provider.dynamic-discovery.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f0b0a656fd5..a04cb6d370d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -702,7 +702,7 @@ export namespace Provider { async function discoverModelsFromEndpoint( providerID: string, baseURL: string, - auth: Auth.ApiAuth | null, + auth: any | null, ): Promise> { const models: Record = {} @@ -760,8 +760,8 @@ export namespace Provider { } models[modelID] = { - id: modelID, - providerID, + id: ModelID.make(modelID), + providerID: ProviderID.make(providerID), name: modelData.name ?? modelID, family: modelData.family ?? "", api: { diff --git a/packages/opencode/test/provider/provider.dynamic-discovery.test.ts b/packages/opencode/test/provider/provider.dynamic-discovery.test.ts index cb4531d624c..5bc078dc8e6 100644 --- a/packages/opencode/test/provider/provider.dynamic-discovery.test.ts +++ b/packages/opencode/test/provider/provider.dynamic-discovery.test.ts @@ -7,11 +7,11 @@ import { Provider } from "../../src/provider/provider" // Mock fetch for dynamic model discovery tests let originalFetch: typeof global.fetch -let mockFetch: mock.Mock +let mockFetch: ReturnType beforeEach(() => { originalFetch = global.fetch - mockFetch = mock(() => + mockFetch = mock((url: string | URL | Request) => Promise.resolve({ ok: true, json: () => Promise.resolve({ data: [] }), @@ -357,7 +357,7 @@ test("dynamic model discovery - local config API key overrides the global one", npm: "@ai-sdk/openai-compatible", options: { baseURL: "http://localhost:1234/v1", - apiKey: "local-api-key" + apiKey: "local-api-key", }, dynamicModelList: true, },