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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- Register the provider before any network request and load both the
CLIProxyAPI catalog and optional models.dev metadata in the background.
OpenCode can activate this plugin in a separate process for every location,
so waiting for either request could exceed the activation budget and leave
the plugin failed. A disk cache, shared by server URL, supplies the last
catalogs immediately. A file lock ensures only one process downloads the
metadata catalog; the others wait for that download. A failed request does
not extend the cache lifetime, and disposing the plugin stops further
provider reloads.

## [0.3.0] - 2026-10-04

### Added
Expand Down
33 changes: 22 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ This is the OpenCode V2 port of
targets OpenCode V1. The V1 plugin API does not run in V2, so this package is a
separate release built on `@opencode/plugin`.

The plugin discovers CLIProxyAPI's live `/v1/models` catalog whenever it loads
(and refreshes it in the background), then registers them as a provider with
`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/). Deployment limits that the CLIProxyAPI server
itself reports take precedence (see
The plugin registers a provider with `ctx.provider.transform` immediately, then
discovers CLIProxyAPI's live `/v1/models` catalog in the background. 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/), also in the background. Neither
request can delay plugin activation. 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
Expand Down Expand Up @@ -89,12 +89,23 @@ pick up changes immediately.
| `providerName` | `CLIProxyAPI` | Name displayed in the model picker |
| `protocol` | `chat` | Default protocol for non-Anthropic models: `chat` uses `/chat/completions`; `responses` uses `/responses`. Claude models still use `/v1/messages`. |
| `modelMetadataURL` | `https://models.dev/api.json` | Dynamic model metadata. Set to `false` to disable enrichment and use only inferred defaults. |
| `discoveryTimeoutMs` | `10000` | Startup model-discovery timeout |
| `discoveryTimeoutMs` | `10000` | CLIProxyAPI model-discovery timeout. Also bounds the optional, non-blocking metadata request. |
| `refreshIntervalMs` | `300000` | Background catalog refresh interval. Set to `0` to refresh only at startup. |

If model metadata cannot be reached, the plugin logs a warning and keeps the
CLIProxyAPI-discovered models available with inferred capabilities and the
configured default protocol.
The plugin registers the provider before contacting either service, so a slow
CLIProxyAPI server or models.dev cannot exceed OpenCode's plugin activation
budget. The model list is filled in as soon as discovery finishes and is
reloaded when optional metadata arrives. If metadata is slow or cannot be
reached, the plugin logs a warning and keeps the CLIProxyAPI-discovered models
with inferred capabilities and the configured default protocol.

OpenCode may load the plugin in a separate process for every location. The last
successful catalogs are cached under `~/.cache/opencode2-cliproxyapi` (or
`CLIPROXYAPI_CACHE_DIR`) so later processes can register models immediately.
The catalog cache is shared by server URL, not by API key. A file lock lets
only one process download the metadata catalog; the others wait for that
download and then read the cache. A failed request does not extend the cache
lifetime, and disposing the plugin stops it from reloading the provider.

### Optional environment variables

Expand Down
128 changes: 128 additions & 0 deletions src/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { mkdtemp, mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { describe, expect, test } from "bun:test"
import {
acquireMetadataLock,
cacheInternals,
catalogCacheIsFresh,
LOCK_STALE_MS,
metadataCacheIsFresh,
readCatalogCache,
readMetadataCache,
writeCatalogCache,
writeMetadataCache,
} from "./cache.js"

async function root() {
return mkdtemp(join(tmpdir(), "cliproxyapi-cache-"))
}

describe("disk cache", () => {
test("round-trips the model catalog and metadata", async () => {
const directory = await root()
await writeCatalogCache("http://cliproxy.test/v1", [{ id: "chat-model" }], directory)
await writeMetadataCache(
"https://models.dev/api.json",
{ chat: { models: { "chat-model": { name: "Chat Model" } } } },
directory,
)

expect(await readCatalogCache("http://cliproxy.test/v1", directory)).toMatchObject({
models: [{ id: "chat-model" }],
})
const metadata = await readMetadataCache("https://models.dev/api.json", directory)
expect(metadata?.catalog.chat?.models["chat-model"]?.name).toBe("Chat Model")
expect(metadataCacheIsFresh(metadata?.fetchedAt ?? 0)).toBe(true)
expect(catalogCacheIsFresh(Date.now())).toBe(true)
expect(catalogCacheIsFresh(Date.now() - 31_000)).toBe(false)
expect(metadataCacheIsFresh(Date.now() - 7 * 60 * 60_000)).toBe(false)
})

test("lets only one process hold the metadata lock", async () => {
const directory = await root()
const first = await acquireMetadataLock("https://models.dev/api.json", directory)
const second = await acquireMetadataLock("https://models.dev/api.json", directory)

expect(first).toBeFunction()
expect(second).toBeUndefined()

await first?.()
const third = await acquireMetadataLock("https://models.dev/api.json", directory)
expect(third).toBeFunction()
await third?.()
})

test("ignores a malformed cache file", async () => {
const directory = await root()
await Bun.write(join(directory, "not-used.json"), "{")
expect(await readCatalogCache("missing", directory)).toBeUndefined()
expect(await readMetadataCache("missing", directory)).toBeUndefined()
})

test("rejects a catalog entry that is missing a model id", async () => {
const directory = await root()
const path = cacheInternals.catalogPath("http://cliproxy.test/v1", directory)
await mkdir(directory, { recursive: true })
await writeFile(
path,
JSON.stringify({
version: 1,
fetchedAt: Date.now(),
models: [{ ownedBy: "acme" }, { id: "" }],
}),
)

expect(await readCatalogCache("http://cliproxy.test/v1", directory)).toBeUndefined()
})

test("removes a temporary file when replacing the cache fails", async () => {
const directory = await root()
const path = join(directory, "nested", "catalog.json")
await mkdir(path, { recursive: true })

await expect(cacheInternals.writeJson(path, { ok: false })).rejects.toThrow()
const entries = await Array.fromAsync(
new Bun.Glob("*.tmp").scan({ cwd: join(directory, "nested") }),
)
expect(entries).toEqual([])
})

test("replaces a stale metadata lock and leaves a live lock alone", async () => {
const directory = await root()
const url = "https://models.dev/api.json"
const path = cacheInternals.lockPath(`metadata-${cacheInternals.cacheKey(url)}`, directory)
await mkdir(directory, { recursive: true })
await writeFile(path, "stale")
const stale = new Date(Date.now() - LOCK_STALE_MS - 1_000)
await utimes(path, stale, stale)

const recovered = await acquireMetadataLock(url, directory)
expect(recovered).toBeFunction()
expect(await readFile(path, "utf8")).not.toBe("stale")

const blocked = await acquireMetadataLock(url, directory)
expect(blocked).toBeUndefined()
const age = Date.now() - (await stat(path)).mtimeMs
expect(age).toBeLessThan(LOCK_STALE_MS)

await recovered?.()
expect(await readFile(path, "utf8").catch(() => undefined)).toBeUndefined()
})

test("does not delete a lock that another process has taken over", async () => {
const directory = await root()
const url = "https://models.dev/api.json"
const path = cacheInternals.lockPath(`metadata-${cacheInternals.cacheKey(url)}`, directory)
await mkdir(directory, { recursive: true })
await writeFile(path, "previous")
const stale = new Date(Date.now() - LOCK_STALE_MS - 1_000)
await utimes(path, stale, stale)

const recovered = await acquireMetadataLock(url, directory)
await writeFile(path, "taken-over")
await recovered?.()

expect(await readFile(path, "utf8")).toBe("taken-over")
})
})
Loading
Loading