From df1cada27cbec2063292c57e26be3d408a464558 Mon Sep 17 00:00:00 2001 From: ibrahim babal Date: Sun, 26 Jul 2026 12:18:06 +0300 Subject: [PATCH] fix: route discovered models by protocol Resolve provider and model SDK metadata dynamically so mixed CLIProxyAPI catalogs use their compatible endpoints.\n\nCloses #1 --- CHANGELOG.md | 8 +++ README.md | 11 +++- src/catalog.test.ts | 75 +++++++++++++++++++++++++- src/catalog.ts | 51 ++++++++++++++++++ src/index.test.ts | 125 ++++++++++++++++++++++++++++++++++++++++++-- src/index.ts | 110 +++++++++++++++++++++++++++++++++----- 6 files changed, 362 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2128b0e..5d81e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- Dynamically route models with Anthropic protocol metadata through + `/v1/messages` instead of OpenAI-compatible chat completions, without + hard-coding model IDs. +- Preserve discovered model protocol metadata when users customize individual + model settings. + ## [0.1.1] - 2026-07-25 ### Changed diff --git a/README.md b/README.md index 66e3a40..ffd1ee7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ directly in [OpenCode](https://opencode.ai/). The plugin discovers CLIProxyAPI's live `/v1/models` catalog whenever OpenCode starts. Available models appear in the normal `/models` picker under -**CLIProxyAPI**, with no hard-coded model list to maintain. +**CLIProxyAPI**. OpenCode Go models that expose Anthropic-compatible endpoints +are automatically routed through `/v1/messages` using live model metadata from +[models.dev](https://models.dev/); the remaining discovered models continue to +use the provider's configured default protocol. No model IDs are hard-coded. ## Quick start @@ -108,9 +111,13 @@ The recommended configuration is the global plugin entry shown above: | `apiKey` | `CLIPROXY_API_KEY` | CLIProxyAPI key | | `providerID` | `cliproxyapi` | ID used in `provider/model` names | | `providerName` | `CLIProxyAPI` | Name displayed in the model picker | -| `protocol` | `chat` | `chat` uses `/chat/completions`; `responses` uses `/responses` | +| `protocol` | `chat` | Default protocol: `chat` uses `/chat/completions`; `responses` uses `/responses`. Models marked as Anthropic-compatible by dynamic metadata override this per model. | +| `modelMetadataURL` | `https://models.dev/api.json` | Dynamic model-level protocol metadata. Set to `false` to disable enrichment and use only the default protocol. | | `discoveryTimeoutMs` | `10000` | Startup model-discovery timeout | +If model metadata cannot be reached, the plugin logs a warning and keeps the +CLIProxyAPI-discovered models available with the configured default protocol. + ### Optional environment variables Environment variables remain available for containers, CI, or users who prefer diff --git a/src/catalog.test.ts b/src/catalog.test.ts index 56b9737..7e6befc 100644 --- a/src/catalog.test.ts +++ b/src/catalog.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { discoverModels, normalizeBaseURL, parseCatalog } from "./catalog.js" +import { + discoverModelProtocols, + discoverModels, + normalizeBaseURL, + parseCatalog, + parseModelProtocolCatalog, +} from "./catalog.js" describe("normalizeBaseURL", () => { test.each([ @@ -64,3 +70,70 @@ describe("discoverModels", () => { ).rejects.toThrow('HTTP 401: {"error":"Missing API key"}') }) }) + +describe("parseModelProtocolCatalog", () => { + test("indexes provider defaults and model-level SDK overrides", () => { + expect( + parseModelProtocolCatalog({ + acme: { + npm: "@ai-sdk/openai-compatible", + models: { + "chat-model": {}, + "messages-model": { + provider: { + npm: "@ai-sdk/anthropic", + }, + }, + }, + }, + malformed: { + models: [], + }, + }), + ).toEqual({ + acme: { + npm: "@ai-sdk/openai-compatible", + models: { + "messages-model": "@ai-sdk/anthropic", + }, + }, + }) + }) + + test("rejects a malformed catalog", () => { + expect(() => parseModelProtocolCatalog([])).toThrow("non-object catalog") + }) +}) + +describe("discoverModelProtocols", () => { + test("fetches protocol metadata from the configured URL", async () => { + let requestedURL = "" + const catalog = await discoverModelProtocols({ + url: "https://metadata.test/models.json", + timeoutMs: 1_000, + fetcher: async (input) => { + requestedURL = String(input) + return Response.json({ + acme: { + models: { + "messages-model": { + provider: { + npm: "@ai-sdk/anthropic", + }, + }, + }, + }, + }) + }, + }) + + expect(requestedURL).toBe("https://metadata.test/models.json") + expect(catalog).toEqual({ + acme: { + models: { + "messages-model": "@ai-sdk/anthropic", + }, + }, + }) + }) +}) diff --git a/src/catalog.ts b/src/catalog.ts index b1a7904..d948871 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -3,6 +3,14 @@ export type CatalogModel = { ownedBy?: string } +export type ModelProtocolCatalog = Record< + string, + { + npm?: string + models: Record + } +> + type CatalogResponse = { data?: unknown } @@ -60,6 +68,49 @@ export async function discoverModels(input: { return parseCatalog(await response.json()) } +export function parseModelProtocolCatalog(input: unknown): ModelProtocolCatalog { + if (!isRecord(input)) throw new Error("Model metadata service returned a non-object catalog") + + return Object.fromEntries( + Object.entries(input).flatMap(([providerID, provider]) => { + if (!isRecord(provider)) return [] + + const models = Object.fromEntries( + Object.entries(isRecord(provider.models) ? provider.models : {}).flatMap(([modelID, model]) => { + if (!isRecord(model) || !isRecord(model.provider) || typeof model.provider.npm !== "string") { + return [] + } + return [[modelID, model.provider.npm]] + }), + ) + + const npm = typeof provider.npm === "string" ? provider.npm : undefined + return npm || Object.keys(models).length > 0 + ? [[providerID, { ...(npm ? { npm } : {}), models }]] + : [] + }), + ) +} + +export async function discoverModelProtocols(input: { + url: string + timeoutMs: number + fetcher?: typeof fetch +}) { + const response = await (input.fetcher ?? fetch)(input.url, { + signal: AbortSignal.timeout(input.timeoutMs), + }) + + if (!response.ok) { + const detail = (await response.text()).trim().slice(0, 300) + throw new Error( + `Model protocol discovery failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`, + ) + } + + return parseModelProtocolCatalog(await response.json()) +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/src/index.test.ts b/src/index.test.ts index 97a57b1..c90351d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -45,9 +45,12 @@ describe("CLIProxyAPIPlugin", () => { await plugin.config?.(config) - expect(requests).toHaveLength(1) - expect(requests[0]?.url).toBe("http://cliproxy.test:8317/v1/models") - expect(requests[0]?.headers.get("authorization")).toBe("Bearer secret") + expect(requests).toHaveLength(2) + const modelRequest = requests.find( + (request) => request.url === "http://cliproxy.test:8317/v1/models", + ) + expect(modelRequest?.headers.get("authorization")).toBe("Bearer secret") + expect(requests.some((request) => request.url === "https://models.dev/api.json")).toBe(true) expect(config.provider?.cliproxyapi).toMatchObject({ name: "CLIProxyAPI", npm: "@ai-sdk/openai-compatible", @@ -72,4 +75,120 @@ describe("CLIProxyAPIPlugin", () => { globalThis.fetch = originalFetch } }) + + test("routes models from dynamic catalog protocol metadata", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async (input) => { + if (String(input) === "https://models.dev/api.json") { + return Response.json({ + acme: { + npm: "@ai-sdk/anthropic", + models: { + "model-level-chat": { + provider: { + npm: "@ai-sdk/openai-compatible", + }, + }, + }, + }, + chat: { + npm: "@ai-sdk/openai-compatible", + models: {}, + }, + }) + } + + return Response.json({ + data: [ + { id: "chat-model", owned_by: "chat" }, + { id: "messages-model", owned_by: "acme" }, + { id: "model-level-chat", owned_by: "acme" }, + ], + }) + } + + try { + const plugin = await CLIProxyAPIPlugin( + { + client: { + app: { + log: async () => ({}), + }, + }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + apiKey: "secret", + }, + ) + const config: Config = { + provider: { + cliproxyapi: { + models: { + "messages-model": { + name: "My Messages Model", + }, + }, + }, + }, + } + + await plugin.config?.(config) + + expect(config.provider?.cliproxyapi?.npm).toBe("@ai-sdk/openai-compatible") + expect(config.provider?.cliproxyapi?.models?.["chat-model"]?.provider).toBeUndefined() + expect(config.provider?.cliproxyapi?.models?.["messages-model"]?.name).toBe("My Messages Model") + expect(config.provider?.cliproxyapi?.models?.["messages-model"]?.provider).toEqual({ + npm: "@ai-sdk/anthropic", + }) + expect(config.provider?.cliproxyapi?.models?.["model-level-chat"]?.provider).toBeUndefined() + } finally { + globalThis.fetch = originalFetch + } + }) + + test("keeps discovered models available when protocol metadata is unavailable", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async (input) => { + if (String(input) === "https://models.dev/api.json") { + return Response.json({ error: "unavailable" }, { status: 503 }) + } + return Response.json({ + data: [{ id: "chat-model", owned_by: "acme" }], + }) + } + + try { + const logs: unknown[] = [] + const plugin = await CLIProxyAPIPlugin( + { + client: { + app: { + log: async (input: unknown) => { + logs.push(input) + return {} + }, + }, + }, + } as PluginInput, + { + baseURL: "http://cliproxy.test:8317", + }, + ) + const config: Config = {} + + await plugin.config?.(config) + + expect(config.provider?.cliproxyapi?.models?.["chat-model"]).toBeDefined() + expect(logs).toHaveLength(2) + expect(logs[0]).toMatchObject({ + body: { + level: "warn", + message: expect.stringContaining("HTTP 503"), + }, + }) + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/src/index.ts b/src/index.ts index bc2555f..a35dd02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,17 @@ import type { Config, Plugin, PluginModule, PluginOptions } from "@opencode-ai/plugin" -import { discoverModels, normalizeBaseURL, type CatalogModel } from "./catalog.js" +import { + discoverModelProtocols, + discoverModels, + normalizeBaseURL, + type CatalogModel, + type ModelProtocolCatalog, +} from "./catalog.js" const DEFAULT_BASE_URL = "http://localhost:8317/v1" const DEFAULT_PROVIDER_ID = "cliproxyapi" const DEFAULT_PROVIDER_NAME = "CLIProxyAPI" const DEFAULT_DISCOVERY_TIMEOUT_MS = 10_000 +const DEFAULT_MODEL_METADATA_URL = "https://models.dev/api.json" type ConnectorOptions = { baseURL?: string @@ -12,6 +19,7 @@ type ConnectorOptions = { providerID?: string providerName?: string protocol?: "chat" | "responses" + modelMetadataURL?: string | false discoveryTimeoutMs?: number } @@ -35,11 +43,28 @@ export const CLIProxyAPIPlugin: Plugin = async ({ client }, rawOptions) => { options.apiKey ?? process.env.CLIPROXY_API_KEY ?? stringOption(existing?.options?.apiKey) try { - const catalog = await discoverModels({ - baseURL, - apiKey, - timeoutMs: options.discoveryTimeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS, - }) + const timeoutMs = options.discoveryTimeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS + const [catalog, protocolDiscovery] = await Promise.all([ + discoverModels({ + baseURL, + apiKey, + timeoutMs, + }), + discoverProtocols({ + url: options.modelMetadataURL ?? DEFAULT_MODEL_METADATA_URL, + timeoutMs, + }), + ]) + + if (protocolDiscovery.error) { + await client.app.log({ + body: { + service: "opencode-cliproxyapi", + level: "warn", + message: protocolDiscovery.error, + }, + }) + } addProvider(config, { providerID, @@ -48,6 +73,7 @@ export const CLIProxyAPIPlugin: Plugin = async ({ client }, rawOptions) => { apiKey, protocol: options.protocol ?? "chat", catalog, + protocolCatalog: protocolDiscovery.catalog, }) await client.app.log({ @@ -75,8 +101,14 @@ export default { id: "opencode-cliproxyapi", server: CLIProxyAPIPlugin, } satisfies PluginModule -export { discoverModels, normalizeBaseURL, parseCatalog } from "./catalog.js" -export type { CatalogModel } from "./catalog.js" +export { + discoverModelProtocols, + discoverModels, + normalizeBaseURL, + parseCatalog, + parseModelProtocolCatalog, +} from "./catalog.js" +export type { CatalogModel, ModelProtocolCatalog } from "./catalog.js" function addProvider( config: Config, @@ -87,6 +119,7 @@ function addProvider( apiKey?: string protocol: "chat" | "responses" catalog: CatalogModel[] + protocolCatalog: ModelProtocolCatalog }, ) { const existing = config.provider?.[input.providerID] @@ -98,6 +131,15 @@ function addProvider( tool_call: !isImageModel(model.id), reasoning: isReasoningModel(model.id), attachment: supportsAttachments(model.id), + ...(model.ownedBy && + resolveModelNpm(input.protocolCatalog, model.ownedBy, model.id) === + "@ai-sdk/anthropic" + ? { + provider: { + npm: "@ai-sdk/anthropic", + }, + } + : {}), ...(isImageModel(model.id) ? { modalities: { @@ -124,14 +166,39 @@ function addProvider( baseURL: input.baseURL, ...(input.apiKey ? { apiKey: input.apiKey } : {}), }, - models: { - ...discovered, - ...existing?.models, - }, + models: mergeModels(discovered, existing?.models), }, } } +function resolveModelNpm( + catalog: ModelProtocolCatalog, + providerID: string, + modelID: string, +): string | undefined { + const provider = catalog[providerID] + return provider?.models[modelID] ?? provider?.npm +} + +function mergeModels( + discovered: Record, + existing: ProviderConfig["models"], +): Record { + const merged = { ...discovered } + + for (const [modelID, model] of Object.entries(existing ?? {})) { + const discoveredModel = merged[modelID] + const providerNpm = model.provider?.npm ?? discoveredModel?.provider?.npm + merged[modelID] = { + ...discoveredModel, + ...model, + ...(providerNpm ? { provider: { npm: providerNpm } } : {}), + } + } + + return merged +} + function readOptions(input?: PluginOptions): ConnectorOptions { if (!input) return {} @@ -141,6 +208,8 @@ function readOptions(input?: PluginOptions): ConnectorOptions { providerID: stringOption(input.providerID), providerName: stringOption(input.providerName), protocol: input.protocol === "responses" ? "responses" : input.protocol === "chat" ? "chat" : undefined, + modelMetadataURL: + input.modelMetadataURL === false ? false : stringOption(input.modelMetadataURL), discoveryTimeoutMs: typeof input.discoveryTimeoutMs === "number" && input.discoveryTimeoutMs > 0 ? input.discoveryTimeoutMs @@ -148,6 +217,23 @@ function readOptions(input?: PluginOptions): ConnectorOptions { } } +function discoverProtocols(input: { + url: string | false + timeoutMs: number +}): Promise<{ catalog: ModelProtocolCatalog; error?: string }> { + if (input.url === false) return Promise.resolve({ catalog: {} as ModelProtocolCatalog }) + + return discoverModelProtocols({ + url: input.url, + timeoutMs: input.timeoutMs, + }) + .then((catalog) => ({ catalog })) + .catch((error) => ({ + catalog: {} as ModelProtocolCatalog, + error: error instanceof Error ? error.message : String(error), + })) +} + function stringOption(value: unknown) { return typeof value === "string" && value.trim() !== "" ? value : undefined }