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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.<providerID>.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
Expand Down
57 changes: 57 additions & 0 deletions src/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
discoverModels,
normalizeBaseURL,
parseCatalog,
parseLiveMetadata,
parseModelMetadataCatalog,
} from "./catalog.js"

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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" })
})
})
46 changes: 46 additions & 0 deletions src/catalog.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -181,6 +204,29 @@ function parseReasoningEfforts(input: unknown) {
}
}

export function parseLiveMetadata(item: Record<string, unknown>): 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")
}
Expand Down
Loading
Loading