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" }