Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
75 changes: 74 additions & 1 deletion src/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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",
},
},
})
})
})
51 changes: 51 additions & 0 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ export type CatalogModel = {
ownedBy?: string
}

export type ModelProtocolCatalog = Record<
string,
{
npm?: string
models: Record<string, string>
}
>

type CatalogResponse = {
data?: unknown
}
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
125 changes: 122 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
}
})
})
Loading
Loading