From 5afe07aa015a1f373bbcc6238b3b8bfecaa4f558 Mon Sep 17 00:00:00 2001 From: Ben Brougher Date: Sun, 4 Oct 2026 22:29:07 -0700 Subject: [PATCH] Prefer live CLIProxyAPI model metadata over models.dev Read the optional context_length, max_completion_tokens, and display_name fields from CLIProxyAPI's /v1/models entries and merge them per field. User configuration still takes precedence over these live values, and both take precedence over the models.dev catalog and OpenCode's defaults. Live limits apply without a catalog match, with enrichment disabled, and when enrichment fails. Each refresh rebuilds every model, so changed values apply and removed fields fall back. When only a live context is known, an inherited output budget is capped at a quarter of it, and the output never exceeds the context. Malformed or unknown fields are ignored without dropping the model. Closes #3 --- CHANGELOG.md | 10 +++ README.md | 59 +++++++++++++- src/catalog.test.ts | 57 +++++++++++++ src/catalog.ts | 46 +++++++++++ src/index.test.ts | 194 +++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 59 +++++++++++++- 6 files changed, 420 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d61b3..e28d91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Read optional live metadata from CLIProxyAPI's `/v1/models` entries: + `context_length`, `max_completion_tokens`, and `display_name`. These take + precedence over models.dev per field, while user configuration still wins. + Live limits apply without a catalog match, with enrichment disabled, or when + enrichment fails. When only a live context is known, an inherited output + budget is capped to a quarter of it so the output never exceeds the context. + ([#3](https://github.com/moutansos/opencode2-cliproxyapi/issues/3)) + ## [0.2.2] - 2026-10-02 ### Fixed diff --git a/README.md b/README.md index 452a0c1..62afed7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ The plugin discovers CLIProxyAPI's live `/v1/models` catalog whenever it loads `ctx.provider.transform`. Available models appear in the normal `/models` picker under **CLIProxyAPI**. Model names, capabilities, limits, costs, and reasoning variants are enriched from live metadata on -[models.dev](https://models.dev/). No model IDs are hard-coded. +[models.dev](https://models.dev/). Deployment limits that the CLIProxyAPI server +itself reports take precedence (see +[Live model metadata](#live-model-metadata)). No model IDs are hard-coded. Claude models owned by Anthropic use OpenCode's Anthropic Messages runtime against CLIProxyAPI's `/v1/messages` endpoint, including thinking/effort @@ -107,6 +109,61 @@ export CLIPROXY_API_KEY="your-cli-proxy-api-key" Put these lines in your shell profile if you want them to persist. Explicit plugin options in `opencode.json` take precedence over environment variables. +### Live model metadata + +A server can describe what a deployment actually provides by adding these +optional fields to `/v1/models` entries. The plugin reads only these fields; it +ignores unknown fields, and it ignores a malformed value without dropping the +model. + +| `/v1/models` field | OpenCode field | Accepted values | +| --- | --- | --- | +| `context_length` | `limit.context` | Positive integer | +| `max_completion_tokens` | `limit.output` | Positive integer | +| `display_name` | `name` | Non-empty string | + +```json +{ + "object": "list", + "data": [ + { + "id": "deployment-coder", + "object": "model", + "owned_by": "local-provider", + "display_name": "Deployment Coder", + "context_length": 8192, + "max_completion_tokens": 2048 + } + ] +} +``` + +Stock CLIProxyAPI returns only `id`, `object`, `created`, and `owned_by`. The +extra fields require server-side enrichment, such as a CLIProxyAPI plugin that +rewrites the model list. Without them, behavior is unchanged. + +Each field is merged independently, from highest to lowest priority: + +1. Your configuration under `providers..models`, which OpenCode + layers on top of the plugin. +2. Live metadata from CLIProxyAPI. +3. The `modelMetadataURL` catalog (models.dev by default). +4. OpenCode defaults (`context` 200000, `output` 32000). + +A live context wins even when it is smaller than the catalog value. Live +metadata applies even if the model is not in the catalog, if enrichment is +disabled (`modelMetadataURL: false`), or if the catalog cannot be fetched. Each +background refresh rebuilds the models from the latest response, so changed +values apply and removed fields fall back to the next source. + +To keep budgets coherent when a live limit is present, the plugin also: + +- never lets the output limit exceed the context; +- caps an inherited output budget at a quarter of a live `context_length` when + the server sends no `max_completion_tokens`. For example, a live context of + 8192 with a catalog output of 32768 resolves to output 2048; +- caps an inherited input limit to context minus output. + ### Customizing discovered models The plugin registers a provider source. Anything you configure under diff --git a/src/catalog.test.ts b/src/catalog.test.ts index ece702c..9786640 100644 --- a/src/catalog.test.ts +++ b/src/catalog.test.ts @@ -4,6 +4,7 @@ import { discoverModels, normalizeBaseURL, parseCatalog, + parseLiveMetadata, parseModelMetadataCatalog, } from "./catalog.js" @@ -37,6 +38,51 @@ describe("parseCatalog", () => { ]) }) + test("keeps recognized live metadata from enriched entries", () => { + expect( + parseCatalog({ + object: "list", + data: [ + { + id: "deployment-coder", + object: "model", + owned_by: "local-provider", + display_name: "Deployment Coder", + context_length: 8192, + max_completion_tokens: 2048, + unknown_field: { nested: true }, + }, + { id: "context-only", context_length: 4096 }, + { id: "basic", owned_by: "openai" }, + ], + }), + ).toEqual([ + { + id: "deployment-coder", + ownedBy: "local-provider", + live: { name: "Deployment Coder", limit: { context: 8192, output: 2048 } }, + }, + { id: "context-only", live: { limit: { context: 4096 } } }, + { id: "basic", ownedBy: "openai" }, + ]) + }) + + test("ignores malformed live metadata without dropping the model", () => { + const models = parseCatalog({ + data: [ + { + id: "bad-limits", + display_name: " ", + context_length: -1, + max_completion_tokens: 1.5, + }, + { id: "string-limits", context_length: "8192", max_completion_tokens: null }, + { id: "huge", context_length: Number.POSITIVE_INFINITY, max_completion_tokens: 0 }, + ], + }) + expect(models).toEqual([{ id: "bad-limits" }, { id: "string-limits" }, { id: "huge" }]) + }) + test("rejects malformed responses", () => { expect(() => parseCatalog({ data: {} })).toThrow("missing the data array") expect(() => parseCatalog({ data: [] })).toThrow("no usable models") @@ -196,3 +242,14 @@ describe("discoverModelMetadata", () => { }) }) }) + +describe("parseLiveMetadata", () => { + test("returns nothing for basic OpenAI records", () => { + expect(parseLiveMetadata({ id: "gpt-5", object: "model", owned_by: "openai" })).toBeUndefined() + }) + + test("keeps output-only and name-only metadata", () => { + expect(parseLiveMetadata({ max_completion_tokens: 1024 })).toEqual({ limit: { output: 1024 } }) + expect(parseLiveMetadata({ display_name: " Local Model " })).toEqual({ name: "Local Model" }) + }) +}) diff --git a/src/catalog.ts b/src/catalog.ts index 1b83394..95e1513 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,6 +1,27 @@ export type CatalogModel = { id: string ownedBy?: string + live?: LiveMetadata +} + +/** + * Optional deployment metadata a CLIProxyAPI server may add to `/v1/models` + * entries. Stock CLIProxyAPI only returns `id`, `object`, `created`, and + * `owned_by`; these fields require server-side enrichment (for example a + * CLIProxyAPI plugin). Only the recognized fields below are read: + * + * | Wire field | Meaning | + * | ----------------------- | -------------------------------- | + * | `display_name` | Human-readable model name | + * | `context_length` | Deployment context window | + * | `max_completion_tokens` | Deployment output-token limit | + */ +export type LiveMetadata = { + name?: string + limit?: { + context?: number + output?: number + } } export type ModelMetadata = { @@ -55,9 +76,11 @@ export function parseCatalog(input: unknown): CatalogModel[] { const models = response.data .map((item) => { if (!isRecord(item) || typeof item.id !== "string" || item.id.trim() === "") return + const live = parseLiveMetadata(item) return { id: item.id, ...(typeof item.owned_by === "string" ? { ownedBy: item.owned_by } : {}), + ...(live ? { live } : {}), } }) .filter((item): item is CatalogModel => item !== undefined) @@ -181,6 +204,29 @@ function parseReasoningEfforts(input: unknown) { } } +export function parseLiveMetadata(item: Record): LiveMetadata | undefined { + const name = + typeof item.display_name === "string" && item.display_name.trim() !== "" + ? item.display_name.trim() + : undefined + const context = tokenLimit(item.context_length) + const output = tokenLimit(item.max_completion_tokens) + const limit = { + ...(context !== undefined ? { context } : {}), + ...(output !== undefined ? { output } : {}), + } + if (name === undefined && Object.keys(limit).length === 0) return + return { + ...(name !== undefined ? { name } : {}), + ...(Object.keys(limit).length > 0 ? { limit } : {}), + } +} + +/** Token limits must be finite positive integers; anything else is ignored. */ +function tokenLimit(value: unknown) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string") } diff --git a/src/index.test.ts b/src/index.test.ts index bf182b4..a61f4f2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Model, Plugin as PluginTypes, Provider } from "@opencode/plugin" -import plugin, { buildModel } from "./index.js" +import plugin, { buildModel, resolveLimit } from "./index.js" type Registered = { info: Provider.Info @@ -12,6 +12,7 @@ function context( existing?: { settings?: Record }, ) { const registered: Registered[] = [] + const transforms: Array<(editor: { add: (input: Registered) => void }) => void> = [] return { registered, @@ -23,9 +24,13 @@ function context( return { data: existing } }, transform: async (callback: (editor: { add: (input: Registered) => void }) => void) => { + transforms.push(callback) callback({ add: (input) => registered.push(input) }) return { dispose: async () => {} } }, + reload: async () => { + for (const callback of transforms) callback({ add: (input) => registered.push(input) }) + }, }, aisdk: { hook: async () => ({ dispose: async () => {} }), @@ -284,3 +289,190 @@ describe("buildModel", () => { expect(text.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) }) }) + +describe("live CLIProxyAPI metadata", () => { + const providerID = "cliproxyapi" as Provider.ID + const catalog = { + acme: { + models: { + "deployment-coder": { + name: "Catalog Coder", + family: "coder", + toolCall: true, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 131_072, input: 120_000, output: 32_768 }, + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + }, + }, + }, + } + + test("live context and output override the catalog", () => { + const model = buildModel({ + providerID, + model: { + id: "deployment-coder", + ownedBy: "acme", + live: { name: "Deployment Coder", limit: { context: 8_192, output: 2_048 } }, + }, + metadata: catalog, + }) + expect(model.limit).toEqual({ context: 8_192, input: 6_144, output: 2_048 }) + expect(model.name).toBe("Deployment Coder") + expect(model.family).toBe("coder") + expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] }) + expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 } }] as Model.Info["cost"]) + }) + + test("context-only live metadata caps the inherited output budget", () => { + const model = buildModel({ + providerID, + model: { id: "deployment-coder", ownedBy: "acme", live: { limit: { context: 8_192 } } }, + metadata: catalog, + }) + expect(model.limit).toEqual({ context: 8_192, input: 6_144, output: 2_048 }) + expect(model.name).toBe("Catalog Coder") + }) + + test("output-only live metadata keeps the catalog context", () => { + const model = buildModel({ + providerID, + model: { id: "deployment-coder", ownedBy: "acme", live: { limit: { output: 4_096 } } }, + metadata: catalog, + }) + expect(model.limit).toEqual({ context: 131_072, input: 120_000, output: 4_096 }) + }) + + test("live limits work without a catalog match", () => { + const model = buildModel({ + providerID, + model: { id: "local/qwen3:8b", ownedBy: "ollama", live: { limit: { context: 32_768 } } }, + metadata: {}, + }) + expect(model.limit).toEqual({ context: 32_768, output: 8_192 }) + + const large = buildModel({ + providerID, + model: { id: "local/large", live: { limit: { context: 196_608 } } }, + metadata: {}, + }) + expect(large.limit).toEqual({ context: 196_608, output: 32_000 }) + }) + + test("basic records keep the existing catalog and default behavior", () => { + expect( + buildModel({ providerID, model: { id: "deployment-coder", ownedBy: "acme" }, metadata: catalog }).limit, + ).toEqual({ context: 131_072, input: 120_000, output: 32_768 }) + const plain = buildModel({ providerID, model: { id: "unknown-model" }, metadata: {} }) + expect(plain.limit).toEqual( + buildModel({ providerID, model: { id: "other-unknown" }, metadata: {} }).limit, + ) + }) + + test("live limits keep explicit falsy catalog metadata", () => { + const model = buildModel({ + providerID, + model: { id: "no-tools", ownedBy: "acme", live: { limit: { context: 8_192 } } }, + metadata: { + acme: { + models: { + "no-tools": { + toolCall: false, + limit: { context: 65_536, output: 1_024 }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + }, + }, + }, + }) + expect(model.capabilities.tools).toBe(false) + expect(model.limit).toEqual({ context: 8_192, output: 1_024 }) + expect(model.cost).toEqual([{ input: 0, output: 0, cache: { read: 0, write: 0 } }] as Model.Info["cost"]) + }) + + test("resolveLimit never lets output exceed the effective context", () => { + const fallback = { context: 200_000, output: 32_000 } + expect(resolveLimit({ live: { context: 4_096, output: 8_192 }, fallback })).toEqual({ + context: 4_096, + output: 4_096, + }) + expect(resolveLimit({ live: { context: 2 }, fallback })).toEqual({ context: 2, output: 1 }) + expect(resolveLimit({ live: {}, catalog: { context: 10, output: 5 }, fallback })).toEqual({ + context: 10, + output: 5, + }) + expect(resolveLimit({ fallback })).toBeUndefined() + }) +}) + +describe("plugin live metadata", () => { + const enriched = (context: number | undefined) => ({ + object: "list", + data: [ + { + id: "local/qwen3:8b", + object: "model", + owned_by: "ollama", + display_name: "qwen3:8b (Ollama)", + ...(context !== undefined ? { context_length: context } : {}), + }, + ], + }) + + test("uses live limits when enrichment is disabled or fails", async () => { + const originalFetch = globalThis.fetch + try { + for (const modelMetadataURL of [false, "https://catalog.test/api.json"] as const) { + globalThis.fetch = async (input) => { + if (String(input) === "https://catalog.test/api.json") { + return Response.json({ error: "unavailable" }, { status: 503 }) + } + return Response.json(enriched(16_384)) + } + const { ctx, registered } = context({ baseURL: "http://cliproxy.test:8317", modelMetadataURL }) + await plugin.setup(ctx) + const model = registered.at(-1)?.models[0] + expect(model?.limit).toEqual({ context: 16_384, output: 4_096 }) + expect(model?.name).toBe("qwen3:8b (Ollama)") + } + } finally { + globalThis.fetch = originalFetch + } + }) + + test("refresh applies changed and removed live metadata for an unchanged model ID", async () => { + const originalFetch = globalThis.fetch + const responses = [enriched(8_192), enriched(32_768), enriched(undefined)] + let calls = 0 + globalThis.fetch = async () => Response.json(responses[Math.min(calls++, responses.length - 1)]) + + let cleanup: void | (() => void | Promise) + try { + const { ctx, registered } = context({ + baseURL: "http://cliproxy.test:8317", + modelMetadataURL: false, + refreshIntervalMs: 10, + }) + cleanup = await plugin.setup(ctx) + expect(registered.at(-1)?.models[0]?.limit).toEqual({ context: 8_192, output: 2_048 }) + + const waitFor = async (predicate: () => boolean) => { + for (let i = 0; i < 200 && !predicate(); i++) await Bun.sleep(5) + } + await waitFor(() => registered.at(-1)?.models[0]?.limit.context === 32_768) + expect(registered.at(-1)?.models[0]?.limit).toEqual({ context: 32_768, output: 8_192 }) + + await waitFor(() => registered.at(-1)?.models[0]?.limit.context !== 32_768) + const defaults = buildModel({ + providerID: "cliproxyapi" as Provider.ID, + model: { id: "local/qwen3:8b" }, + metadata: {}, + }).limit + expect(registered.at(-1)?.models[0]?.limit).toEqual(defaults) + expect(registered.at(-1)?.models.map((model) => model.id)).toEqual(["local/qwen3:8b"]) + } finally { + if (typeof cleanup === "function") await cleanup() + globalThis.fetch = originalFetch + } + }) +}) diff --git a/src/index.ts b/src/index.ts index 4aa2849..803422a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { discoverModels, normalizeBaseURL, type CatalogModel, + type LiveMetadata, type ModelMetadata, type ModelMetadataCatalog, } from "./catalog.js" @@ -38,7 +39,13 @@ type ConnectorOptions = { refreshIntervalMs?: number } +// When a server supplies a context window but no output limit, an inherited +// (catalog or default) output budget is capped to this fraction of the context so +// the remaining prompt budget stays usable. +const INHERITED_OUTPUT_CONTEXT_DIVISOR = 4 + type Cost = Model.Info["cost"][number] +type Limit = Model.Info["limit"] type Money = Cost["input"] export default Plugin.define({ @@ -152,10 +159,16 @@ export function buildModel(input: { const image = isImageModel(input.model.id) const anthropic = usesAnthropicMessages(input.model, resolved.npm) const efforts = metadata?.reasoningEfforts ?? (anthropic ? [...DEFAULT_ANTHROPIC_EFFORTS] : undefined) + const defaults = Model.Info.default(input.providerID, modelID) + const limit = resolveLimit({ + live: input.model.live?.limit, + catalog: metadata?.limit, + fallback: defaults.limit, + }) return { - ...Model.Info.default(input.providerID, modelID), - name: metadata?.name ?? displayName(input.model.id), + ...defaults, + name: input.model.live?.name ?? metadata?.name ?? displayName(input.model.id), ...(metadata?.family ? { family: Model.Family.make(metadata.family) } : {}), ...(anthropic ? { package: MESSAGES_PACKAGE } : {}), capabilities: { @@ -173,7 +186,7 @@ export function buildModel(input: { })), } : {}), - ...(metadata?.limit ? { limit: metadata.limit } : {}), + ...(limit ? { limit } : {}), ...(metadata?.cost ? { cost: [ @@ -192,6 +205,46 @@ export function buildModel(input: { } } +/** + * Merges token limits per field. Live CLIProxyAPI metadata wins over the + * models.dev-compatible catalog, which wins over OpenCode's defaults. Explicit + * user configuration is layered on top by OpenCode itself. + * + * Coherence policy when a live value is present: + * - a live output limit is kept, but never exceeds the effective context; + * - with a live context and no live output, an inherited output budget is capped + * to a quarter of the live context (so a small deployment window is not + * consumed by a catalog-sized output reservation); + * - an inherited input limit is capped to context minus output. + * Without live limits the catalog limit is used unchanged. + */ +export function resolveLimit(input: { + live?: LiveMetadata["limit"] + catalog?: ModelMetadata["limit"] + fallback: Limit +}): Limit | undefined { + const { live, catalog, fallback } = input + if (!live || (live.context === undefined && live.output === undefined)) return catalog + + const context = live.context ?? catalog?.context ?? fallback.context + const inheritedOutput = catalog?.output ?? fallback.output + let output: number + if (live.output !== undefined) { + output = Math.min(live.output, context) + } else { + output = Math.min(inheritedOutput, Math.max(1, Math.floor(context / INHERITED_OUTPUT_CONTEXT_DIVISOR))) + } + const inheritedInput = catalog?.input + const inputLimit = + inheritedInput === undefined ? undefined : Math.max(1, Math.min(inheritedInput, context - output)) + + return { + context, + ...(inputLimit !== undefined ? { input: inputLimit } : {}), + output, + } +} + export function usesAnthropicMessages(model: CatalogModel, npm?: string) { return npm === ANTHROPIC_NPM || model.ownedBy === "anthropic" }