From 1918e0c5a0aff0f19f6cb790a61b4fc165009eec Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Wed, 27 May 2026 02:48:44 +0200 Subject: [PATCH 1/5] feat(models-info): add @vymalo/opencode-models-info plugin Add a second OpenCode plugin that enriches existing model entries with full metadata (context length, output limit, USD/M-token cost, modalities, tool_call/reasoning/attachment flags) by fetching from a provider-supplied OpenRouter-shaped endpoint declared via `options.meta.modelsInfoUrl`. Auth-agnostic by design: runs as a Hooks.config hook after other plugins have populated providers and headers, so it composes with @vymalo/opencode-oauth2, static API keys, or any other auth scheme without depending on any of them. Upstream values are never overwritten. Includes a TTL'd two-layer cache (in-memory + disk, atomic writes, ETag honored) mirroring the oauth2 cache pattern, with stale-on-failure fallback so a flaky endpoint never blocks OpenCode startup. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opencode-models-info/README.md | 122 +++++++++++ packages/opencode-models-info/package.json | 63 ++++++ packages/opencode-models-info/src/cache.ts | 105 ++++++++++ packages/opencode-models-info/src/config.ts | 90 ++++++++ packages/opencode-models-info/src/fetcher.ts | 82 ++++++++ packages/opencode-models-info/src/index.ts | 4 + packages/opencode-models-info/src/lib.ts | 55 +++++ packages/opencode-models-info/src/logging.ts | 89 ++++++++ packages/opencode-models-info/src/mapping.ts | 150 ++++++++++++++ packages/opencode-models-info/src/opencode.ts | 87 ++++++++ packages/opencode-models-info/src/plugin.ts | 168 +++++++++++++++ packages/opencode-models-info/src/types.ts | 60 ++++++ .../opencode-models-info/test/cache.test.ts | 84 ++++++++ .../opencode-models-info/test/mapping.test.ts | 85 ++++++++ .../opencode-models-info/test/plugin.test.ts | 196 ++++++++++++++++++ packages/opencode-models-info/tsconfig.json | 10 + .../opencode-models-info/vitest.config.ts | 10 + plans/models-info-plan.md | 155 ++++++++++++++ pnpm-lock.yaml | 13 ++ 19 files changed, 1628 insertions(+) create mode 100644 packages/opencode-models-info/README.md create mode 100644 packages/opencode-models-info/package.json create mode 100644 packages/opencode-models-info/src/cache.ts create mode 100644 packages/opencode-models-info/src/config.ts create mode 100644 packages/opencode-models-info/src/fetcher.ts create mode 100644 packages/opencode-models-info/src/index.ts create mode 100644 packages/opencode-models-info/src/lib.ts create mode 100644 packages/opencode-models-info/src/logging.ts create mode 100644 packages/opencode-models-info/src/mapping.ts create mode 100644 packages/opencode-models-info/src/opencode.ts create mode 100644 packages/opencode-models-info/src/plugin.ts create mode 100644 packages/opencode-models-info/src/types.ts create mode 100644 packages/opencode-models-info/test/cache.test.ts create mode 100644 packages/opencode-models-info/test/mapping.test.ts create mode 100644 packages/opencode-models-info/test/plugin.test.ts create mode 100644 packages/opencode-models-info/tsconfig.json create mode 100644 packages/opencode-models-info/vitest.config.ts create mode 100644 plans/models-info-plan.md diff --git a/packages/opencode-models-info/README.md b/packages/opencode-models-info/README.md new file mode 100644 index 0000000..5f54e42 --- /dev/null +++ b/packages/opencode-models-info/README.md @@ -0,0 +1,122 @@ +# @vymalo/opencode-models-info + +OpenCode plugin that **enriches** model entries already contributed by other plugins (or by your `opencode.json`) with full metadata — context length, output limit, pricing, modalities, and capability flags (`tool_call`, `reasoning`, `attachment`) — by fetching from a provider-supplied **OpenRouter-shaped** endpoint. + +Auth-agnostic by design: the plugin runs as an OpenCode `config` hook *after* other plugins have populated providers and headers, so it composes with `@vymalo/opencode-oauth2`, static API keys, or any other auth scheme without depending on any of them. + +## Why use this + +OpenCode supports rich per-model metadata (context window, USD/M-token cost, tool-call/reasoning/attachment flags) but you usually have to handwrite it in `opencode.json`. If your provider exposes a JSON endpoint with this info (OpenRouter, LiteLLM with the OpenRouter-compat extension, your own gateway), this plugin fetches it once, merges it onto every model, caches the result, and stays out of the way. + +## Installation + +```sh +npm install @vymalo/opencode-models-info +``` + +Add it to your `opencode.json` plugin list: + +```json +{ + "plugin": ["@vymalo/opencode-models-info"] +} +``` + +## Usage + +For every provider you want enriched, add `options.meta.modelsInfoUrl`: + +```json +{ + "plugin": ["@vymalo/opencode-models-info"], + "provider": { + "my-gateway": { + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "https://gateway.example.com/v1", + "meta": { + "modelsInfoUrl": "/models/info", + "modelsInfoTtlSeconds": 86400, + "modelsInfoTimeoutMs": 5000 + } + }, + "models": { + "gpt-x-large": {} + } + } + } +} +``` + +That's it. After OpenCode starts: + +1. The hook picks up every provider with a `meta.modelsInfoUrl`. +2. It `GET`s that URL once (relative paths resolve against `baseURL`), reusing whatever auth headers the provider's other plugins/config already set. +3. Each model entry whose `id` matches an entry in the response gets `limit`, `cost`, `modalities`, `tool_call`, `reasoning`, `attachment`, etc. filled in — **only where they were not already set** (upstream wins). +4. The response is cached on disk for `modelsInfoTtlSeconds` (default 24h), keyed by `(providerId, url)`. ETags are honored. +5. On fetch error with a valid cache, the stale snapshot is served — the plugin never blocks OpenCode startup on a network failure. + +### Options + +| Option | Default | Notes | +| ------------------------------- | ------------------ | --------------------------------------------------------------------- | +| `meta.modelsInfoUrl` | _(required)_ | Absolute URL or path relative to `options.baseURL`. | +| `meta.modelsInfoTtlSeconds` | `86400` (24h) | Cache TTL. | +| `meta.modelsInfoTimeoutMs` | `5000` | Per-fetch HTTP timeout. | +| `meta.modelsInfoHeaders` | _(none)_ | Extra headers merged onto the request (rare — most users won't need). | + +### Expected response shape (OpenRouter) + +```json +{ + "data": [ + { + "id": "model-a", + "name": "Model A", + "context_length": 128000, + "pricing": { "prompt": "0.000003", "completion": "0.000015" }, + "architecture": { "input_modalities": ["text", "image"], "output_modalities": ["text"] }, + "top_provider": { "max_completion_tokens": 4096 }, + "supported_parameters": ["tools", "temperature", "reasoning"] + } + ] +} +``` + +A bare top-level array (no `data` wrapper) is also accepted. + +### Field mapping + +| OpenRouter | OpenCode | +| ------------------------------------------------------- | ------------------------- | +| `context_length` + `top_provider.max_completion_tokens` | `limit.context` / `limit.output` | +| `pricing.prompt` / `.completion` (USD/token) | `cost.input` / `cost.output` (USD per 1M tokens — converted) | +| `pricing.input_cache_read` / `.input_cache_write` | `cost.cache_read` / `cost.cache_write` | +| `architecture.input_modalities` / `.output_modalities` | `modalities.input` / `modalities.output` (filtered to OpenCode's enum) | +| `supported_parameters: ["tools" or "tool_choice"]` | `tool_call: true` | +| `supported_parameters: ["reasoning" / "thinking" / …]` | `reasoning: true` | +| `supported_parameters: ["temperature"]` | `temperature: true` | +| Non-text input modality present | `attachment: true` | +| `name` | `name` (if absent) | + +## Cache location + +| OS | Path | +| ------- | -------------------------------------------------------------------- | +| macOS | `~/Library/Caches/opencode-models-info/` | +| Linux | `${XDG_CACHE_HOME:-~/.cache}/opencode-models-info/` | +| Windows | `%LOCALAPPDATA%\opencode-models-info\` | + +Files are named by `sha256(providerId::url)`, `0o600`, atomic-rename-on-write. + +## Library API + +For embedding the enrichment logic outside an OpenCode hook (e.g. tests or custom tooling), import from the `/lib` subpath: + +```ts +import { enrichConfig, FileCacheStore, createJsonConsoleLogger } from "@vymalo/opencode-models-info/lib"; +``` + +## License + +MIT diff --git a/packages/opencode-models-info/package.json b/packages/opencode-models-info/package.json new file mode 100644 index 0000000..b0541cc --- /dev/null +++ b/packages/opencode-models-info/package.json @@ -0,0 +1,63 @@ +{ + "name": "@vymalo/opencode-models-info", + "version": "0.1.0", + "description": "OpenCode plugin that enriches model entries with full metadata (context length, pricing, modalities, capability flags) fetched from a provider-supplied OpenRouter-shaped endpoint.", + "license": "MIT", + "author": "vymalo contributors", + "homepage": "https://github.com/vymalo/opencode-oauth2#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/vymalo/opencode-oauth2.git", + "directory": "packages/opencode-models-info" + }, + "bugs": { + "url": "https://github.com/vymalo/opencode-oauth2/issues" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "models", + "metadata", + "openrouter", + "ai-sdk" + ], + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./lib": { + "types": "./dist/lib.d.ts", + "import": "./dist/lib.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "biome lint .", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "format": "biome format --write .", + "format:check": "biome format ." + }, + "dependencies": { + "@opencode-ai/plugin": "1.15.10" + }, + "devDependencies": { + "vite": "^8.0.14", + "vitest": "^4.1.7" + } +} diff --git a/packages/opencode-models-info/src/cache.ts b/packages/opencode-models-info/src/cache.ts new file mode 100644 index 0000000..9ad8b81 --- /dev/null +++ b/packages/opencode-models-info/src/cache.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import type { CachedModelsRecord } from "./types.js"; + +function resolveDefaultCacheRoot(): string { + if (process.platform === "win32") { + return process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"); + } + if (process.platform === "darwin") { + return join(homedir(), "Library", "Caches"); + } + return process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"); +} + +export function resolveCacheDir(namespace = "opencode-models-info"): string { + return join(resolveDefaultCacheRoot(), namespace); +} + +export function cacheKey(providerId: string, url: string): string { + return createHash("sha256").update(`${providerId}::${url}`).digest("hex"); +} + +export interface CacheStore { + get(key: string): Promise; + put(key: string, record: CachedModelsRecord): Promise; +} + +/** + * Two-layer cache: an in-memory map for the process lifetime, backed by JSON + * files on disk so cold starts reuse the last good snapshot. Disk writes are + * atomic via rename-after-write so a crashed process can't leave a torn file. + */ +export class FileCacheStore implements CacheStore { + private readonly memory = new Map(); + private ready: Promise | undefined; + + constructor(private readonly baseDir: string = resolveCacheDir()) {} + + private async ensureReady(): Promise { + if (!this.ready) { + this.ready = mkdir(this.baseDir, { recursive: true, mode: 0o700 }).then(() => undefined); + } + await this.ready; + } + + private filePath(key: string): string { + return join(this.baseDir, `${key}.json`); + } + + async get(key: string): Promise { + const memHit = this.memory.get(key); + if (memHit) { + return memHit; + } + try { + await this.ensureReady(); + const raw = await readFile(this.filePath(key), "utf8"); + const parsed = JSON.parse(raw) as CachedModelsRecord; + if (!isValidRecord(parsed)) { + return undefined; + } + this.memory.set(key, parsed); + return parsed; + } catch (error) { + if (isFileNotFound(error)) { + return undefined; + } + return undefined; + } + } + + async put(key: string, record: CachedModelsRecord): Promise { + this.memory.set(key, record); + await this.ensureReady(); + const target = this.filePath(key); + const tmp = `${target}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(record), { mode: 0o600 }); + await rename(tmp, target); + } +} + +export function isExpired(record: CachedModelsRecord, now: number = Date.now()): boolean { + return now - record.fetchedAt > record.ttlSeconds * 1000; +} + +function isValidRecord(value: unknown): value is CachedModelsRecord { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + typeof record.fetchedAt === "number" && + typeof record.ttlSeconds === "number" && + Array.isArray(record.models) + ); +} + +function isFileNotFound(error: unknown): boolean { + return Boolean( + error && typeof error === "object" && (error as { code?: string }).code === "ENOENT" + ); +} diff --git a/packages/opencode-models-info/src/config.ts b/packages/opencode-models-info/src/config.ts new file mode 100644 index 0000000..023b604 --- /dev/null +++ b/packages/opencode-models-info/src/config.ts @@ -0,0 +1,90 @@ +import type { MetaProviderOptions } from "./types.js"; + +export const DEFAULT_TTL_SECONDS = 86_400; +export const DEFAULT_TIMEOUT_MS = 5_000; + +const META_KEY = "meta"; + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asStringMap(value: unknown): Record | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const out: Record = {}; + for (const [key, raw] of Object.entries(record)) { + if (typeof raw === "string" && raw.length > 0) { + out[key] = raw; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function asPositiveInt(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + return fallback; +} + +/** + * Parse a provider's `options.meta` for opt-in model-info fields. Returns + * `null` if the provider has not opted in (no `meta.modelsInfoUrl`). + * + * Resolves `modelsInfoUrl` against `baseURL` when it is a relative path so + * config authors can write `"meta": { "modelsInfoUrl": "/v1/models/info" }`. + */ +export function parseMetaOptions( + providerOptions: Record | undefined +): MetaProviderOptions | null { + if (!providerOptions) { + return null; + } + + const meta = asRecord(providerOptions[META_KEY]); + if (!meta) { + return null; + } + + const rawUrl = asString(meta.modelsInfoUrl); + if (!rawUrl) { + return null; + } + + const baseURL = asString(providerOptions.baseURL); + const modelsInfoUrl = resolveUrl(rawUrl, baseURL); + + return { + modelsInfoUrl, + modelsInfoTtlSeconds: asPositiveInt(meta.modelsInfoTtlSeconds, DEFAULT_TTL_SECONDS), + modelsInfoTimeoutMs: asPositiveInt(meta.modelsInfoTimeoutMs, DEFAULT_TIMEOUT_MS), + modelsInfoHeaders: asStringMap(meta.modelsInfoHeaders), + modelsInfoFormat: "openrouter" + }; +} + +function resolveUrl(candidate: string, baseURL: string | undefined): string { + if (/^https?:\/\//i.test(candidate)) { + return candidate; + } + if (!baseURL) { + return candidate; + } + const base = baseURL.endsWith("/") ? baseURL : `${baseURL}/`; + const rel = candidate.startsWith("/") ? candidate.slice(1) : candidate; + try { + return new URL(rel, base).toString(); + } catch { + return candidate; + } +} diff --git a/packages/opencode-models-info/src/fetcher.ts b/packages/opencode-models-info/src/fetcher.ts new file mode 100644 index 0000000..446e2e2 --- /dev/null +++ b/packages/opencode-models-info/src/fetcher.ts @@ -0,0 +1,82 @@ +import type { FetchModelsResult, OpenRouterModel, OpenRouterModelsResponse } from "./types.js"; + +export interface FetchOptions { + url: string; + headers?: Record; + timeoutMs: number; + etag?: string; + fetchImpl?: typeof fetch; +} + +/** + * GET the models-info endpoint and return either the parsed entries, a + * not-modified marker (when the server respects the supplied `If-None-Match`), + * or an error result. Never throws — the plugin must remain non-fatal. + */ +export async function fetchOpenRouterModels(opts: FetchOptions): Promise { + const impl = opts.fetchImpl ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs); + + try { + const headers: Record = { + accept: "application/json", + ...(opts.headers ?? {}) + }; + if (opts.etag) { + headers["if-none-match"] = opts.etag; + } + + const response = await impl(opts.url, { + method: "GET", + headers, + signal: controller.signal + }); + + if (response.status === 304) { + return { status: "not-modified", etag: opts.etag }; + } + + if (!response.ok) { + return { status: "error", error: `HTTP ${response.status}` }; + } + + const body = (await response.json()) as unknown; + const models = normalizeResponse(body); + if (!models) { + return { status: "error", error: "unexpected response shape" }; + } + + return { + status: "ok", + etag: response.headers.get("etag") ?? undefined, + models + }; + } catch (error) { + return { + status: "error", + error: error instanceof Error ? error.message : String(error) + }; + } finally { + clearTimeout(timer); + } +} + +function normalizeResponse(body: unknown): OpenRouterModel[] | undefined { + if (Array.isArray(body)) { + return body.filter(isOpenRouterModel); + } + if (body && typeof body === "object") { + const data = (body as OpenRouterModelsResponse).data; + if (Array.isArray(data)) { + return data.filter(isOpenRouterModel); + } + } + return undefined; +} + +function isOpenRouterModel(value: unknown): value is OpenRouterModel { + return ( + Boolean(value) && typeof value === "object" && typeof (value as { id?: unknown }).id === "string" + ); +} diff --git a/packages/opencode-models-info/src/index.ts b/packages/opencode-models-info/src/index.ts new file mode 100644 index 0000000..9ee7618 --- /dev/null +++ b/packages/opencode-models-info/src/index.ts @@ -0,0 +1,4 @@ +// OpenCode plugin entry. The host iterates every named export of this module +// and rejects any export that isn't a Plugin function (or { server: Plugin }). +// Library API is exposed via the "./lib" subpath in package.json. +export { default } from "./opencode.js"; diff --git a/packages/opencode-models-info/src/lib.ts b/packages/opencode-models-info/src/lib.ts new file mode 100644 index 0000000..fb0172b --- /dev/null +++ b/packages/opencode-models-info/src/lib.ts @@ -0,0 +1,55 @@ +export { + createOpencodeModelsInfoPlugin, + OpencodeModelsInfoPlugin, + type OpenCodePluginFactoryOptions +} from "./opencode.js"; + +export { + cacheKey, + type CacheStore, + FileCacheStore, + isExpired, + resolveCacheDir +} from "./cache.js"; + +export { + DEFAULT_TIMEOUT_MS, + DEFAULT_TTL_SECONDS, + parseMetaOptions +} from "./config.js"; + +export { fetchOpenRouterModels, type FetchOptions } from "./fetcher.js"; + +export { + createJsonConsoleLogger, + DEFAULT_LOG_LEVEL, + fromOpenCodeLogLevel, + type LogFields, + type Logger, + type LogLevel +} from "./logging.js"; + +export { + mapOpenRouterEntry, + mergeIntoModel, + type ModelMetadata +} from "./mapping.js"; + +export { + type EnrichConfigInput, + type EnrichDeps, + enrichConfig, + type ProviderConfigLike +} from "./plugin.js"; + +export type { + CachedModelsRecord, + FetchModelsResult, + MetaProviderOptions, + OpenRouterArchitecture, + OpenRouterModality, + OpenRouterModel, + OpenRouterModelsResponse, + OpenRouterPricing, + OpenRouterTopProvider +} from "./types.js"; diff --git a/packages/opencode-models-info/src/logging.ts b/packages/opencode-models-info/src/logging.ts new file mode 100644 index 0000000..7ec1dac --- /dev/null +++ b/packages/opencode-models-info/src/logging.ts @@ -0,0 +1,89 @@ +import type { LogLevel } from "./types.js"; + +export type { LogLevel } from "./types.js"; + +export const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +export const DEFAULT_LOG_LEVEL: LogLevel = "info"; + +export interface LogFields { + [key: string]: unknown; +} + +export interface Logger { + debug(event: string, fields?: LogFields): void; + info(event: string, fields?: LogFields): void; + warn(event: string, fields?: LogFields): void; + error(event: string, fields?: LogFields): void; +} + +function redactFields(fields?: LogFields): LogFields | undefined { + if (!fields) { + return undefined; + } + const redacted: LogFields = {}; + for (const [key, value] of Object.entries(fields)) { + if (/token|secret|password|authorization/i.test(key)) { + redacted[key] = "[redacted]"; + continue; + } + redacted[key] = value; + } + return redacted; +} + +export function createJsonConsoleLogger(minLevel: LogLevel = DEFAULT_LOG_LEVEL): Logger { + const minPriority = LOG_LEVEL_PRIORITY[minLevel]; + + const write = (level: LogLevel, event: string, fields?: LogFields): void => { + if (LOG_LEVEL_PRIORITY[level] < minPriority) { + return; + } + const payload = { + ts: new Date().toISOString(), + level, + event, + ...(redactFields(fields) ?? {}) + }; + const line = JSON.stringify(payload); + if (level === "error") { + console.error(line); + return; + } + if (level === "warn") { + console.warn(line); + return; + } + console.log(line); + }; + + return { + debug: (event, fields) => write("debug", event, fields), + info: (event, fields) => write("info", event, fields), + warn: (event, fields) => write("warn", event, fields), + error: (event, fields) => write("error", event, fields) + }; +} + +export function fromOpenCodeLogLevel(value: unknown): LogLevel | undefined { + if (typeof value !== "string") { + return undefined; + } + switch (value.toUpperCase()) { + case "DEBUG": + return "debug"; + case "INFO": + return "info"; + case "WARN": + return "warn"; + case "ERROR": + return "error"; + default: + return undefined; + } +} diff --git a/packages/opencode-models-info/src/mapping.ts b/packages/opencode-models-info/src/mapping.ts new file mode 100644 index 0000000..3997372 --- /dev/null +++ b/packages/opencode-models-info/src/mapping.ts @@ -0,0 +1,150 @@ +import type { OpenRouterModality, OpenRouterModel } from "./types.js"; + +const OPENCODE_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"] as const); +type OpenCodeModality = "text" | "audio" | "image" | "video" | "pdf"; + +export interface ModelMetadata { + name?: string; + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_call?: boolean; + cost?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + limit?: { + context: number; + output: number; + }; + modalities?: { + input: OpenCodeModality[]; + output: OpenCodeModality[]; + }; +} + +/** + * Pure transformation from an OpenRouter model entry to the subset of + * OpenCode `ModelConfig` fields we know how to populate. Returns only the + * fields we can derive; callers do the upstream-wins merge. + */ +export function mapOpenRouterEntry(entry: OpenRouterModel): ModelMetadata { + const out: ModelMetadata = {}; + + if (entry.name) { + out.name = entry.name; + } + + const context = entry.top_provider?.context_length ?? entry.context_length; + const output = entry.top_provider?.max_completion_tokens; + if (typeof context === "number" && typeof output === "number") { + out.limit = { context, output }; + } else if (typeof context === "number") { + // OpenCode requires both fields when `limit` is set. Skip rather than fake. + } + + const cost = mapPricing(entry.pricing); + if (cost) { + out.cost = cost; + } + + const inputMods = filterModalities(entry.architecture?.input_modalities); + const outputMods = filterModalities(entry.architecture?.output_modalities); + if (inputMods.length > 0 && outputMods.length > 0) { + out.modalities = { input: inputMods, output: outputMods }; + } + + const params = entry.supported_parameters ?? []; + const paramSet = new Set(params.map((p) => p.toLowerCase())); + + if (paramSet.has("tools") || paramSet.has("tool_choice")) { + out.tool_call = true; + } + if (paramSet.has("reasoning") || paramSet.has("reasoning_effort") || paramSet.has("thinking")) { + out.reasoning = true; + } + if (paramSet.has("temperature")) { + out.temperature = true; + } + + if (inputMods.some((m) => m !== "text")) { + out.attachment = true; + } + + return out; +} + +function mapPricing(pricing: OpenRouterModel["pricing"]): ModelMetadata["cost"] | undefined { + if (!pricing) { + return undefined; + } + + const input = perMillion(pricing.prompt); + const output = perMillion(pricing.completion); + if (input === undefined || output === undefined) { + return undefined; + } + + const cost: NonNullable = { input, output }; + const cacheRead = perMillion(pricing.input_cache_read); + if (cacheRead !== undefined) { + cost.cache_read = cacheRead; + } + const cacheWrite = perMillion(pricing.input_cache_write); + if (cacheWrite !== undefined) { + cost.cache_write = cacheWrite; + } + return cost; +} + +/** OpenRouter pricing is a string per-token in USD; OpenCode stores per-1M-token. */ +function perMillion(raw: string | undefined): number | undefined { + if (raw === undefined || raw === null || raw === "") { + return undefined; + } + const parsed = Number.parseFloat(raw); + if (!Number.isFinite(parsed)) { + return undefined; + } + return roundTo(parsed * 1_000_000, 6); +} + +function roundTo(value: number, decimals: number): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +function filterModalities(values: OpenRouterModality[] | undefined): OpenCodeModality[] { + if (!values) { + return []; + } + const out: OpenCodeModality[] = []; + for (const value of values) { + if (OPENCODE_MODALITIES.has(value as OpenCodeModality) && !out.includes(value as OpenCodeModality)) { + out.push(value as OpenCodeModality); + } + } + return out; +} + +/** + * Merge a derived metadata snapshot onto an existing OpenCode model entry. + * Upstream wins: any field already present is left untouched. Returns the + * same object reference (mutated) for ergonomic chaining. + */ +export function mergeIntoModel>( + existing: T, + derived: ModelMetadata +): T { + for (const [key, value] of Object.entries(derived)) { + if (value === undefined) { + continue; + } + if (existing[key] === undefined) { + (existing as Record)[key] = value; + } + } + return existing; +} diff --git a/packages/opencode-models-info/src/opencode.ts b/packages/opencode-models-info/src/opencode.ts new file mode 100644 index 0000000..83b37cf --- /dev/null +++ b/packages/opencode-models-info/src/opencode.ts @@ -0,0 +1,87 @@ +import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; + +import { type CacheStore, FileCacheStore } from "./cache.js"; +import { + createJsonConsoleLogger, + DEFAULT_LOG_LEVEL, + fromOpenCodeLogLevel, + type LogFields, + LOG_LEVEL_PRIORITY, + type Logger, + type LogLevel +} from "./logging.js"; +import { type EnrichConfigInput, enrichConfig } from "./plugin.js"; + +const PLUGIN_SERVICE_NAME = "opencode-models-info-plugin"; + +type OpenCodeConfig = Parameters>[0]; + +export interface OpenCodePluginFactoryOptions { + logger?: Logger; + fetchImpl?: typeof fetch; + cache?: CacheStore; + cacheDir?: string; +} + +/** + * Pipe plugin logs through OpenCode's `client.app.log` so they show up in the + * host's structured log stream, with the JSON console as a reliable fallback. + * Mirrors the pattern used by `@vymalo/opencode-oauth2`. + */ +function createOpenCodeLogger( + client: PluginInput["client"], + getMinLevel: () => LogLevel +): Logger { + const fallback = createJsonConsoleLogger("debug"); + + const write = (level: LogLevel, event: string, fields?: LogFields) => { + if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) { + return; + } + fallback[level](event, fields); + void client.app + .log({ + body: { + service: PLUGIN_SERVICE_NAME, + level, + message: event, + extra: fields + } + }) + .catch(() => { + /* best-effort */ + }); + }; + + return { + debug: (event, fields) => write("debug", event, fields), + info: (event, fields) => write("info", event, fields), + warn: (event, fields) => write("warn", event, fields), + error: (event, fields) => write("error", event, fields) + }; +} + +export function createOpencodeModelsInfoPlugin( + factoryOptions: OpenCodePluginFactoryOptions = {} +): Plugin { + return async ({ client }) => { + let currentLogLevel: LogLevel = DEFAULT_LOG_LEVEL; + const logger = factoryOptions.logger ?? createOpenCodeLogger(client, () => currentLogLevel); + const cache = factoryOptions.cache ?? new FileCacheStore(factoryOptions.cacheDir); + + return { + config: async (config: OpenCodeConfig) => { + currentLogLevel = fromOpenCodeLogLevel(config.logLevel) ?? DEFAULT_LOG_LEVEL; + await enrichConfig(config as EnrichConfigInput, { + cache, + logger, + fetchImpl: factoryOptions.fetchImpl + }); + } + }; + }; +} + +export const OpencodeModelsInfoPlugin = createOpencodeModelsInfoPlugin(); + +export default OpencodeModelsInfoPlugin; diff --git a/packages/opencode-models-info/src/plugin.ts b/packages/opencode-models-info/src/plugin.ts new file mode 100644 index 0000000..4f2ff8f --- /dev/null +++ b/packages/opencode-models-info/src/plugin.ts @@ -0,0 +1,168 @@ +import { cacheKey, type CacheStore, FileCacheStore, isExpired } from "./cache.js"; +import { parseMetaOptions } from "./config.js"; +import { fetchOpenRouterModels } from "./fetcher.js"; +import type { Logger } from "./logging.js"; +import { mapOpenRouterEntry, mergeIntoModel } from "./mapping.js"; +import type { CachedModelsRecord, MetaProviderOptions, OpenRouterModel } from "./types.js"; + +export type ProviderOptions = Record | undefined; + +export interface ProviderConfigLike { + options?: Record; + models?: Record>; +} + +export interface EnrichConfigInput { + provider?: Record; +} + +export interface EnrichDeps { + cache: CacheStore; + logger: Logger; + fetchImpl?: typeof fetch; + now?: () => number; +} + +/** + * Walk every provider in the assembled OpenCode config, fetch its + * `meta.modelsInfoUrl` (if any) — honoring the cache — and merge derived + * metadata onto each matching model entry. Runs providers in parallel; one + * failure never blocks others. + */ +export async function enrichConfig( + input: EnrichConfigInput, + deps: EnrichDeps +): Promise { + const providers = input.provider; + if (!providers) { + return; + } + + await Promise.allSettled( + Object.entries(providers).map(([providerId, providerConfig]) => + enrichProvider(providerId, providerConfig, deps) + ) + ); +} + +async function enrichProvider( + providerId: string, + providerConfig: ProviderConfigLike | undefined, + deps: EnrichDeps +): Promise { + if (!providerConfig) { + return; + } + const opts = parseMetaOptions(providerConfig.options); + if (!opts) { + return; + } + const models = providerConfig.models; + if (!models || Object.keys(models).length === 0) { + deps.logger.debug("models_info_provider_skipped_no_models", { providerId }); + return; + } + + const record = await loadRecord(providerId, opts, deps); + if (!record) { + return; + } + + const byId = new Map(record.models.map((m) => [m.id, m])); + + let enrichedCount = 0; + for (const [modelId, modelConfig] of Object.entries(models)) { + const declaredId = typeof modelConfig.id === "string" ? modelConfig.id : undefined; + const match = byId.get(modelId) ?? (declaredId ? byId.get(declaredId) : undefined); + if (!match) { + continue; + } + const derived = mapOpenRouterEntry(match); + mergeIntoModel(modelConfig, derived); + enrichedCount += 1; + } + + deps.logger.info("models_info_enriched", { + providerId, + enrichedCount, + totalModels: Object.keys(models).length, + sourceModels: record.models.length + }); +} + +async function loadRecord( + providerId: string, + opts: MetaProviderOptions, + deps: EnrichDeps +): Promise { + const key = cacheKey(providerId, opts.modelsInfoUrl); + const now = deps.now ? deps.now() : Date.now(); + const cached = await deps.cache.get(key); + + if (cached && !isExpired(cached, now)) { + deps.logger.debug("models_info_cache_hit", { + providerId, + url: opts.modelsInfoUrl, + ageMs: now - cached.fetchedAt + }); + return cached; + } + + const headers = buildFetchHeaders(opts); + const result = await fetchOpenRouterModels({ + url: opts.modelsInfoUrl, + headers, + timeoutMs: opts.modelsInfoTimeoutMs, + etag: cached?.etag, + fetchImpl: deps.fetchImpl + }); + + if (result.status === "ok" && result.models) { + const next: CachedModelsRecord = { + fetchedAt: now, + ttlSeconds: opts.modelsInfoTtlSeconds, + etag: result.etag, + models: result.models + }; + await deps.cache.put(key, next); + deps.logger.info("models_info_fetched", { + providerId, + url: opts.modelsInfoUrl, + count: result.models.length + }); + return next; + } + + if (result.status === "not-modified" && cached) { + const refreshed: CachedModelsRecord = { ...cached, fetchedAt: now }; + await deps.cache.put(key, refreshed); + deps.logger.debug("models_info_not_modified", { + providerId, + url: opts.modelsInfoUrl + }); + return refreshed; + } + + if (cached) { + deps.logger.warn("models_info_fetch_failed_using_stale", { + providerId, + url: opts.modelsInfoUrl, + error: result.error, + ageMs: now - cached.fetchedAt + }); + return cached; + } + + deps.logger.warn("models_info_fetch_failed_no_cache", { + providerId, + url: opts.modelsInfoUrl, + error: result.error + }); + return undefined; +} + +function buildFetchHeaders(opts: MetaProviderOptions): Record | undefined { + return opts.modelsInfoHeaders; +} + +export { FileCacheStore }; diff --git a/packages/opencode-models-info/src/types.ts b/packages/opencode-models-info/src/types.ts new file mode 100644 index 0000000..d623d7a --- /dev/null +++ b/packages/opencode-models-info/src/types.ts @@ -0,0 +1,60 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface OpenRouterPricing { + prompt?: string; + completion?: string; + request?: string; + image?: string; + input_cache_read?: string; + input_cache_write?: string; +} + +export type OpenRouterModality = "text" | "image" | "audio" | "video" | "pdf" | "file"; + +export interface OpenRouterArchitecture { + input_modalities?: OpenRouterModality[]; + output_modalities?: OpenRouterModality[]; + modality?: string; + tokenizer?: string; +} + +export interface OpenRouterTopProvider { + max_completion_tokens?: number; + context_length?: number; +} + +export interface OpenRouterModel { + id: string; + name?: string; + context_length?: number; + pricing?: OpenRouterPricing; + architecture?: OpenRouterArchitecture; + top_provider?: OpenRouterTopProvider; + supported_parameters?: string[]; +} + +export interface OpenRouterModelsResponse { + data: OpenRouterModel[]; +} + +export interface MetaProviderOptions { + modelsInfoUrl: string; + modelsInfoTtlSeconds: number; + modelsInfoTimeoutMs: number; + modelsInfoHeaders?: Record; + modelsInfoFormat: "openrouter"; +} + +export interface CachedModelsRecord { + fetchedAt: number; + ttlSeconds: number; + etag?: string; + models: OpenRouterModel[]; +} + +export interface FetchModelsResult { + status: "ok" | "not-modified" | "error"; + etag?: string; + models?: OpenRouterModel[]; + error?: string; +} diff --git a/packages/opencode-models-info/test/cache.test.ts b/packages/opencode-models-info/test/cache.test.ts new file mode 100644 index 0000000..21ef484 --- /dev/null +++ b/packages/opencode-models-info/test/cache.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { cacheKey, FileCacheStore, isExpired } from "../src/cache.js"; +import type { CachedModelsRecord } from "../src/types.js"; + +describe("FileCacheStore", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "opencode-models-info-cache-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + const sample = (now: number): CachedModelsRecord => ({ + fetchedAt: now, + ttlSeconds: 60, + etag: "abc", + models: [{ id: "model-a", context_length: 1024 }] + }); + + it("round-trips through disk and serves from memory on second read", async () => { + const store = new FileCacheStore(dir); + const key = cacheKey("p", "https://example.test/models"); + const record = sample(1000); + + await store.put(key, record); + const fromDisk = await store.get(key); + expect(fromDisk).toEqual(record); + + const onDisk = JSON.parse(await readFile(join(dir, `${key}.json`), "utf8")); + expect(onDisk.fetchedAt).toBe(1000); + + const fromMem = await store.get(key); + expect(fromMem).toEqual(record); + }); + + it("returns undefined on cache miss", async () => { + const store = new FileCacheStore(dir); + expect(await store.get("missing")).toBeUndefined(); + }); + + it("survives garbage on disk by returning undefined", async () => { + const store = new FileCacheStore(dir); + const key = cacheKey("p", "https://example.test/models"); + const { writeFile } = await import("node:fs/promises"); + await writeFile(join(dir, `${key}.json`), "{not json", { mode: 0o600 }); + expect(await store.get(key)).toBeUndefined(); + }); +}); + +describe("isExpired", () => { + it("returns false within the TTL window", () => { + const record: CachedModelsRecord = { + fetchedAt: 1_000_000, + ttlSeconds: 60, + models: [] + }; + expect(isExpired(record, 1_000_000 + 30_000)).toBe(false); + }); + + it("returns true past the TTL window", () => { + const record: CachedModelsRecord = { + fetchedAt: 1_000_000, + ttlSeconds: 60, + models: [] + }; + expect(isExpired(record, 1_000_000 + 61_000)).toBe(true); + }); +}); + +describe("cacheKey", () => { + it("is deterministic and namespace-isolated per provider/url pair", () => { + expect(cacheKey("a", "u")).toBe(cacheKey("a", "u")); + expect(cacheKey("a", "u")).not.toBe(cacheKey("b", "u")); + expect(cacheKey("a", "u")).not.toBe(cacheKey("a", "u2")); + }); +}); diff --git a/packages/opencode-models-info/test/mapping.test.ts b/packages/opencode-models-info/test/mapping.test.ts new file mode 100644 index 0000000..d84c7ff --- /dev/null +++ b/packages/opencode-models-info/test/mapping.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { mapOpenRouterEntry, mergeIntoModel } from "../src/mapping.js"; +import type { OpenRouterModel } from "../src/types.js"; + +describe("mapOpenRouterEntry", () => { + it("converts pricing strings to per-1M USD numbers", () => { + const entry: OpenRouterModel = { + id: "x", + pricing: { prompt: "0.000003", completion: "0.000015", input_cache_read: "0.0000003" } + }; + const out = mapOpenRouterEntry(entry); + expect(out.cost).toEqual({ input: 3, output: 15, cache_read: 0.3 }); + }); + + it("requires both prompt and completion pricing to emit a cost block", () => { + const out = mapOpenRouterEntry({ id: "x", pricing: { prompt: "0.001" } }); + expect(out.cost).toBeUndefined(); + }); + + it("sets limit only when both context and output are known", () => { + const both = mapOpenRouterEntry({ + id: "x", + context_length: 128000, + top_provider: { max_completion_tokens: 4096 } + }); + expect(both.limit).toEqual({ context: 128000, output: 4096 }); + + const contextOnly = mapOpenRouterEntry({ id: "x", context_length: 128000 }); + expect(contextOnly.limit).toBeUndefined(); + }); + + it("derives capability flags from supported_parameters", () => { + const out = mapOpenRouterEntry({ + id: "x", + supported_parameters: ["tools", "temperature", "reasoning"] + }); + expect(out.tool_call).toBe(true); + expect(out.temperature).toBe(true); + expect(out.reasoning).toBe(true); + }); + + it("filters modalities to OpenCode's enum and marks attachment when non-text", () => { + const out = mapOpenRouterEntry({ + id: "x", + architecture: { + input_modalities: ["text", "image", "file"], + output_modalities: ["text"] + } + }); + expect(out.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); + expect(out.attachment).toBe(true); + }); + + it("does not set attachment for text-only models", () => { + const out = mapOpenRouterEntry({ + id: "x", + architecture: { input_modalities: ["text"], output_modalities: ["text"] } + }); + expect(out.attachment).toBeUndefined(); + }); +}); + +describe("mergeIntoModel", () => { + it("only writes fields that are undefined upstream", () => { + const existing: Record = { name: "Pre-named", tool_call: true }; + mergeIntoModel(existing, { + name: "From OpenRouter", + tool_call: false, + reasoning: true, + cost: { input: 1, output: 2 } + }); + expect(existing.name).toBe("Pre-named"); + expect(existing.tool_call).toBe(true); + expect(existing.reasoning).toBe(true); + expect(existing.cost).toEqual({ input: 1, output: 2 }); + }); + + it("is idempotent", () => { + const existing: Record = {}; + mergeIntoModel(existing, { reasoning: true }); + mergeIntoModel(existing, { reasoning: false }); + expect(existing.reasoning).toBe(true); + }); +}); diff --git a/packages/opencode-models-info/test/plugin.test.ts b/packages/opencode-models-info/test/plugin.test.ts new file mode 100644 index 0000000..ab44717 --- /dev/null +++ b/packages/opencode-models-info/test/plugin.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CacheStore } from "../src/cache.js"; +import type { Logger } from "../src/logging.js"; +import { enrichConfig, type EnrichConfigInput, type ProviderConfigLike } from "../src/plugin.js"; +import type { CachedModelsRecord, OpenRouterModel } from "../src/types.js"; + +function silentLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }; +} + +function memoryCache(seed: Map = new Map()): CacheStore { + return { + get: async (key) => seed.get(key), + put: async (key, record) => void seed.set(key, record) + }; +} + +function getModel( + config: EnrichConfigInput, + providerId: string, + modelId: string +): Record { + const provider = config.provider?.[providerId]; + if (!provider) { + throw new Error(`provider ${providerId} missing`); + } + const model = provider.models?.[modelId]; + if (!model) { + throw new Error(`model ${providerId}.${modelId} missing`); + } + return model; +} + +function withProvider( + providerId: string, + provider: ProviderConfigLike +): EnrichConfigInput { + return { provider: { [providerId]: provider } }; +} + +const openRouterEntry: OpenRouterModel = { + id: "model-a", + name: "Model A", + context_length: 128_000, + pricing: { prompt: "0.000003", completion: "0.000015" }, + top_provider: { max_completion_tokens: 4096 }, + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + supported_parameters: ["tools", "temperature"] +}; + +describe("enrichConfig", () => { + it("skips providers without meta.modelsInfoUrl", async () => { + const config = withProvider("bare", { + options: { baseURL: "https://x.test" }, + models: { "model-a": {} } + }); + const fetchImpl = vi.fn(); + await enrichConfig(config, { + cache: memoryCache(), + logger: silentLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(getModel(config, "bare", "model-a")).toEqual({}); + }); + + it("fetches once, caches, and merges metadata onto matching models", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [openRouterEntry] }), { + status: 200, + headers: { "content-type": "application/json", etag: "v1" } + }) + ); + const config = withProvider("custom", { + options: { + baseURL: "https://x.test/v1", + meta: { modelsInfoUrl: "/models/info" } + }, + models: { "model-a": {}, unmatched: { name: "Untouched" } } + }); + + await enrichConfig(config, { + cache: memoryCache(), + logger: silentLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => 0 + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [calledUrl] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(calledUrl).toBe("https://x.test/v1/models/info"); + + const enriched = getModel(config, "custom", "model-a"); + expect(enriched.limit).toEqual({ context: 128_000, output: 4096 }); + expect(enriched.cost).toEqual({ input: 3, output: 15 }); + expect(enriched.tool_call).toBe(true); + expect(enriched.attachment).toBe(true); + expect(enriched.name).toBe("Model A"); + + expect(getModel(config, "custom", "unmatched").name).toBe("Untouched"); + }); + + it("does not refetch when a non-expired cache entry exists", async () => { + const seed = new Map(); + const fetchImpl = vi.fn(); + const config = withProvider("custom", { + options: { baseURL: "https://x.test", meta: { modelsInfoUrl: "https://x.test/m" } }, + models: { "model-a": {} } + }); + + const { cacheKey } = await import("../src/cache.js"); + seed.set(cacheKey("custom", "https://x.test/m"), { + fetchedAt: 0, + ttlSeconds: 3600, + models: [openRouterEntry] + }); + + await enrichConfig(config, { + cache: memoryCache(seed), + logger: silentLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => 1000 + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(getModel(config, "custom", "model-a").cost).toEqual({ input: 3, output: 15 }); + }); + + it("serves stale on fetch failure when a previous cache entry exists", async () => { + const seed = new Map(); + const { cacheKey } = await import("../src/cache.js"); + seed.set(cacheKey("custom", "https://x.test/m"), { + fetchedAt: 0, + ttlSeconds: 1, + etag: "v1", + models: [openRouterEntry] + }); + + const fetchImpl = vi.fn().mockResolvedValue(new Response("boom", { status: 502 })); + const logger = silentLogger(); + const config = withProvider("custom", { + options: { meta: { modelsInfoUrl: "https://x.test/m" } }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: memoryCache(seed), + logger, + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => 1_000_000 + }); + + expect(getModel(config, "custom", "model-a").cost).toEqual({ input: 3, output: 15 }); + expect(logger.warn).toHaveBeenCalledWith( + "models_info_fetch_failed_using_stale", + expect.objectContaining({ providerId: "custom" }) + ); + }); + + it("respects 304 Not Modified by reusing cached models and refreshing fetchedAt", async () => { + const seed = new Map(); + const { cacheKey } = await import("../src/cache.js"); + const key = cacheKey("custom", "https://x.test/m"); + seed.set(key, { + fetchedAt: 0, + ttlSeconds: 1, + etag: "v1", + models: [openRouterEntry] + }); + + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 304 })); + const config = withProvider("custom", { + options: { meta: { modelsInfoUrl: "https://x.test/m" } }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: memoryCache(seed), + logger: silentLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => 9_000_000 + }); + + expect(getModel(config, "custom", "model-a").limit).toEqual({ + context: 128_000, + output: 4096 + }); + expect(seed.get(key)?.fetchedAt).toBe(9_000_000); + }); +}); diff --git a/packages/opencode-models-info/tsconfig.json b/packages/opencode-models-info/tsconfig.json new file mode 100644 index 0000000..f55d573 --- /dev/null +++ b/packages/opencode-models-info/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/opencode-models-info/vitest.config.ts b/packages/opencode-models-info/vitest.config.ts new file mode 100644 index 0000000..7a2fffc --- /dev/null +++ b/packages/opencode-models-info/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + clearMocks: true, + restoreMocks: true + } +}); diff --git a/plans/models-info-plan.md b/plans/models-info-plan.md new file mode 100644 index 0000000..5a9131e --- /dev/null +++ b/plans/models-info-plan.md @@ -0,0 +1,155 @@ +# Models Info Plugin — Plan + +## Name + +`@vymalo/opencode-models-info` + +## Summary + +A second, **independent** OpenCode plugin that enriches the model listings already contributed by other plugins (or by the user's static `opencode.json`) with full metadata — context length, output limit, pricing, modalities, tool-call / reasoning / attachment capability flags — by fetching from a provider-supplied `modelsInfoUrl`. Auth-agnostic: it reuses whatever `headers` the provider config has already been resolved with by the time OpenCode runs the `config` hook, so it composes naturally with `@vymalo/opencode-oauth2`, static API keys, or any other auth scheme. + +## Why a separate plugin + +* The oauth2 plugin's job is authentication + discovery (provider/model **identity**). Metadata enrichment is a different concern (model **attributes**) and is useful even for providers that don't need OAuth2. +* Splitting keeps both plugins single-purpose, smaller blast radius, and lets users opt into one without the other. + +## Non-goals + +* No auth code. Authentication is the upstream plugin's job (or static config). We send whatever `headers`/auth the resolved provider config already carries. +* No model discovery (provider/model list). We only **enrich** entries that already exist after the `config` hook chain. +* No live cost telemetry, billing, or usage tracking — only the static metadata the provider advertises. + +## Design + +### Hook + +Register `Hooks.config` (signature `(input: SDKConfig) => Promise`). The host calls every plugin's `config` hook in registration order; by the time we run, the oauth2 plugin (or any other) has already populated `input.provider[*]`. We mutate the config in place to add metadata fields. + +### Opt-in per provider + +A provider opts in by setting `options.meta.modelsInfoUrl` (and optionally `options.meta.modelsInfoTtlSeconds`, `options.meta.modelsInfoHeaders`) in its OpenCode provider config. Providers without `meta.modelsInfoUrl` are left untouched. + +* `modelsInfoUrl: string` — required. Absolute URL or path relative to `options.baseURL`. +* `modelsInfoTtlSeconds?: number` — cache TTL. Default `86400` (24h). +* `modelsInfoHeaders?: Record` — extra headers merged onto the fetch (rare; for endpoints that need a different auth shape than the inference endpoint). +* `modelsInfoFormat?: "openrouter"` — reserved for future schema variants; defaults to `"openrouter"`. + +### Endpoint contract (OpenRouter shape) + +```json +{ "data": [ + { + "id": "model-a", + "name": "Model A", + "context_length": 128000, + "pricing": { + "prompt": "0.000003", // USD per token + "completion": "0.000015", + "input_cache_read": "0", + "input_cache_write": "0" + }, + "architecture": { + "input_modalities": ["text", "image"], + "output_modalities": ["text"], + "tokenizer": "..." + }, + "top_provider": { "max_completion_tokens": 4096 }, + "supported_parameters": ["tools", "tool_choice", "temperature", "reasoning", "..."] + } +] } +``` + +Single batched GET — no N+1. If the server returns a bare array (no `data` wrapper) we accept that too. + +### Mapping → OpenCode `ProviderConfig.models[id]` + +| OpenRouter field | OpenCode field | Conversion | +| ---------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `context_length` | `limit.context` | as-is | +| `top_provider.max_completion_tokens` | `limit.output` | as-is | +| `pricing.prompt` | `cost.input` | `parseFloat(str) * 1_000_000` (OpenCode cost fields are USD per **1M tokens**) | +| `pricing.completion` | `cost.output` | × 1M | +| `pricing.input_cache_read` | `cost.cache_read` | × 1M | +| `pricing.input_cache_write` | `cost.cache_write` | × 1M | +| `architecture.input_modalities` | `modalities.input` | filter to OpenCode's enum | +| `architecture.output_modalities` | `modalities.output` | same | +| `supported_parameters.includes("tools")` | `tool_call` | boolean | +| `supported_parameters.includes("reasoning")` | `reasoning` | boolean | +| `architecture.input_modalities.includes("image"\|"pdf"…)` | `attachment` | boolean | +| `supported_parameters.includes("temperature")` | `temperature` | boolean (when explicitly listed) | +| `name` | `name` | only if existing entry has no `name` | + +**Merge policy:** for every field we map, we only write if the existing value is `undefined` (i.e. upstream wins). Idempotent — running twice is a no-op. + +### Cache + +* Mirror oauth2's `FileCacheStore` pattern: per-OS cache dir (`~/Library/Caches/opencode-models-info/` on macOS, `XDG_CACHE_HOME` on Linux, `LOCALAPPDATA` on Windows). +* Key = sha256 of `(providerId + "::" + resolvedUrl)`, file `.json`. +* Shape: `{ fetchedAt, ttlSeconds, etag?, raw }`. +* Read flow: + * If cache hit AND not expired → use it; no network. + * If expired or miss → fetch with conditional `If-None-Match` if etag stored. On `304`, bump `fetchedAt`. On `200`, write new file (atomic rename, `0o600`). + * If fetch fails AND stale cache exists → **serve stale**, log a warning, schedule background refresh on next run. Never block the config hook on a network error. +* In-memory L1 keyed the same way for the lifetime of the process (avoids re-reading the same JSON if multiple providers point at the same URL). + +### Timeouts & failure modes + +* Per-fetch timeout: default 5s, override via `options.meta.modelsInfoTimeoutMs`. +* All errors are caught per provider — one bad endpoint does not block other providers' enrichment. +* Logging via the host's `config.logLevel` (same mapping helper as oauth2 — copy or re-export). + +## Package layout + +``` +packages/opencode-models-info/ +├── package.json +├── tsconfig.json +├── vitest.config.ts +├── README.md +├── src/ +│ ├── index.ts # OpenCode entry — re-exports default plugin from opencode.ts +│ ├── opencode.ts # Plugin factory, registers Hooks.config +│ ├── plugin.ts # Core enrichment logic (provider-iteration, merge) +│ ├── fetcher.ts # HTTP fetch with timeout + etag +│ ├── cache.ts # Disk + in-memory TTL cache (mirrors oauth2 cache.ts) +│ ├── mapping.ts # OpenRouter → OpenCode ModelConfig field mapping +│ ├── config.ts # Validate per-provider opts (meta.modelsInfoUrl, ttl, etc.) +│ ├── logging.ts # Re-export from @vymalo/opencode-oauth2/lib if cheap, else copy +│ └── types.ts # OpenRouter response shape + cache record shape +└── test/ + ├── mapping.test.ts + ├── cache.test.ts + └── plugin.test.ts # Hook integration with a fake config object +``` + +`logging.ts` — copy the small `LogLevel`/`createJsonConsoleLogger` shape rather than introduce a workspace dependency on the oauth2 package. Keeps the new plugin standalone and trivially publishable. + +## Steps + +1. Scaffold package — `package.json`, `tsconfig.json`, `vitest.config.ts`, `README.md` stub. Workspace pickup is automatic via `packages/*`. +2. `types.ts` — OpenRouter response + cache record. +3. `config.ts` — `parseMetaOptions(providerOptions)` → typed opts or `null` (opted out). Validation. +4. `mapping.ts` — pure functions: `mapOpenRouterEntry(entry) → Partial` + `mergeIntoModel(existing, mapped)` (upstream-wins). Pure → easy to unit test. +5. `cache.ts` — `FileCacheStore` with `get(key)`, `put(key, record)`, TTL check, in-memory L1. +6. `fetcher.ts` — `fetchOpenRouterModels(url, {headers, timeoutMs, etag})` → `{ status, etag?, data?, raw? }`. +7. `plugin.ts` — `enrichConfig(input, deps)`: + * For each provider in `input.provider`: + * `opts = parseMetaOptions(provider.options)`; if null, skip. + * `record = await cacheGetOrFetch(opts, provider)`. + * For each model in `provider.models`, look up the same id in `record.data`, run `mergeIntoModel`. + * Run providers in parallel (`Promise.allSettled`). +8. `opencode.ts` — `createPlugin(opts?)` → returns a function matching `Plugin` signature, exposing `{ config }` hook. Honors host `config.logLevel`. +9. `index.ts` — `export { default } from "./opencode.js";` (matches oauth2 layout for OpenCode's discovery contract). +10. Tests — mapping (rounding, modality filter, missing fields), cache (TTL expiry, atomic write), plugin (fake config in, mutated config out, stale-on-error fallback). +11. Plugin bundle integration — add to `packages/plugin-bundle` exports / docs if applicable. +12. README — usage example showing `options.meta.modelsInfoUrl` in `opencode.json`. +13. Workspace touchups — `pnpm install` (if needed for new deps; aim for zero — `@opencode-ai/plugin` is the only runtime dep, same as oauth2). +14. `pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm lint`. Fix anything red. +15. Single commit on `main` (per user instruction — no PR). + +## Open questions for later (not blocking v0) + +* If `modelsInfoUrl` is **relative** to `baseURL`, we resolve via `new URL(rel, baseURL).toString()`. Document this. +* OpenRouter's `pricing.request` (per-request) and `pricing.image` — no OpenCode field for these yet; ignore in v0, easy to add later. +* `release_date`, `experimental`, `status` ("alpha"|"beta"|"deprecated") — OpenRouter has no direct equivalent; leave to upstream. +* Future: a `provider` hook to re-enrich at runtime if OpenCode ever exposes one (today only `config` is contributor-time). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a525e4..339af6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,19 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/opencode-models-info: + dependencies: + '@opencode-ai/plugin': + specifier: 1.15.10 + version: 1.15.10 + devDependencies: + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.9.1)(yaml@2.9.0) + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(yaml@2.9.0)) + packages/opencode-oauth2: dependencies: '@opencode-ai/plugin': From 68daa86d74a7ad696a3d40c7f8018a280db52281 Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Wed, 27 May 2026 11:05:08 +0200 Subject: [PATCH 2/5] test(models-info): add unit gaps + Docker-based integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests: - test/config.test.ts — parseMetaOptions edge cases (relative URL resolution, header coercion, defaulting, invalid inputs) - test/fetcher.test.ts — fetchOpenRouterModels (envelope vs bare-array, ETag round-trip, header merging, timeout via AbortController, non-throwing error results) Test env (reusable across the workspace): - test-env/docker-compose.yml — WireMock service with healthcheck on port 18080 (Keycloak placeholder commented in, ready for the oauth2 integration suite later). - test-env/wiremock/ — OpenRouter-shaped catalog fixture + mappings covering happy-path 200, ETag-aware 304, and a Bearer-required ?auth=required variant for header-propagation tests. - test-env/README.md — quick start, endpoints, stub-editing notes. Integration suite: - packages/opencode-models-info/test/integration/*.integration.test.ts — runs against the live WireMock; verifies enrichment, disk cache + 304 path, header propagation, and 401-on-missing-auth fallback. - Separate vitest.integration.config.ts so the default `pnpm test` stays hermetic and fast. - Tests skip themselves when INTEGRATION_MODELS_INFO_URL is unset. Scripts: - Root: test:env:up / test:env:down (with --wait healthcheck gating) and test:integration which orchestrates up → all packages' integration suites (via `pnpm -r --if-present`) → down. - Package: test:integration runs the integration vitest config. Docs: - CLAUDE.md — new repo-wide guide for future Claude Code sessions, including the integration env section. - packages/opencode-models-info/README.md — Testing section with both unit and integration recipes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 119 +++++++++++++ package.json | 4 + packages/opencode-models-info/README.md | 21 +++ packages/opencode-models-info/package.json | 1 + .../opencode-models-info/test/config.test.ts | 98 +++++++++++ .../opencode-models-info/test/fetcher.test.ts | 151 ++++++++++++++++ .../enrichment.integration.test.ts | 164 ++++++++++++++++++ .../opencode-models-info/vitest.config.ts | 1 + .../vitest.integration.config.ts | 15 ++ test-env/README.md | 45 +++++ test-env/docker-compose.yml | 71 ++++++++ test-env/scripts/wait-for-wiremock.sh | 20 +++ .../wiremock/__files/openrouter-catalog.json | 72 ++++++++ .../wiremock/mappings/openrouter-models.json | 74 ++++++++ 14 files changed, 856 insertions(+) create mode 100644 CLAUDE.md create mode 100644 packages/opencode-models-info/test/config.test.ts create mode 100644 packages/opencode-models-info/test/fetcher.test.ts create mode 100644 packages/opencode-models-info/test/integration/enrichment.integration.test.ts create mode 100644 packages/opencode-models-info/vitest.integration.config.ts create mode 100644 test-env/README.md create mode 100644 test-env/docker-compose.yml create mode 100755 test-env/scripts/wait-for-wiremock.sh create mode 100644 test-env/wiremock/__files/openrouter-catalog.json create mode 100644 test-env/wiremock/mappings/openrouter-models.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fd31243 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this repo is + +A **pnpm workspace** of OpenCode plugins under the `@vymalo` npm scope. There are two runtime plugins plus a Rolldown-based bundler — both plugins target OpenCode's plugin API (`@opencode-ai/plugin`) and ship to npm independently. + +| Package | Purpose | +| --- | --- | +| `packages/opencode-oauth2` → `@vymalo/opencode-oauth2` | OAuth2 / OIDC auth + dynamic model discovery for OpenAI-compatible providers. The mature plugin; five auth flows, persistent token cache, periodic sync scheduler. | +| `packages/opencode-models-info` → `@vymalo/opencode-models-info` | **Auth-agnostic** metadata enrichment plugin: fetches OpenRouter-shaped `/models` JSON and merges `limit` / `cost` / `modalities` / capability flags onto existing provider model entries. Runs as a `Hooks.config` hook *after* other plugins. | +| `packages/plugin-bundle` → `@vymalo/opencode-oauth2-bundle` (private) | Rolldown build that ships a single-file distribution of the oauth2 plugin. | + +The two plugins are deliberately decoupled: `opencode-models-info` does not import from `opencode-oauth2` and works with any auth scheme (static API key, oauth2, none) because it only mutates the already-resolved OpenCode config. + +## Common commands + +```sh +pnpm install # bootstrap workspace +pnpm -r build # compile all packages (tsc → dist/) +pnpm -r typecheck # tsc --noEmit across packages +pnpm -r test # vitest run in each package that has tests +pnpm lint # biome lint (full repo) +pnpm format # biome format --write +``` + +Per-package iteration (much faster): + +```sh +pnpm --filter @vymalo/opencode-oauth2 test +pnpm --filter @vymalo/opencode-oauth2 build +pnpm --filter @vymalo/opencode-models-info typecheck +``` + +Single-test run inside a package: + +```sh +pnpm --filter @vymalo/opencode-oauth2 exec vitest run path/to/file.test.ts +pnpm --filter @vymalo/opencode-oauth2 exec vitest run -t "ensureAccessToken" # by test name +``` + +Watch mode: `pnpm --filter exec vitest` (no `run`). + +### Integration tests (Docker) + +A reusable compose stack of HTTP backends lives under [`test-env/`](test-env/). Currently a WireMock service stubs the OpenRouter-shaped `/v1/models` endpoint for `@vymalo/opencode-models-info`; a Keycloak service is sketched-in (commented out) for the upcoming `@vymalo/opencode-oauth2` integration suite. + +```sh +pnpm test:env:up # docker compose up (waits for healthcheck) +pnpm --filter @vymalo/opencode-models-info test:integration +pnpm test:env:down # docker compose down -v +# or one-shot: +pnpm test:integration # compose up → all packages' integration suites → compose down +``` + +Integration tests live under `test/integration/**/*.test.ts`, run via a separate `vitest.integration.config.ts`, and **skip themselves** when `INTEGRATION_MODELS_INFO_URL` is unset — so the default `pnpm test` stays hermetic. Stubs are at [`test-env/wiremock/mappings/`](test-env/wiremock/mappings/) and [`test-env/wiremock/__files/`](test-env/wiremock/__files/); editing them needs either a `wiremock` container restart or `curl -X POST http://127.0.0.1:18080/__admin/mappings/reset`. + +## Architecture: how the plugins fit OpenCode + +OpenCode plugins implement a `Hooks` object (see `@opencode-ai/plugin`'s `index.d.ts`). The two hooks this repo uses: + +- **`Hooks.config(input: SDKConfig)`** — runs once at plugin load, mutates the assembled OpenCode config (`input.provider`, `input.pluginConfig`, etc.). Both plugins use this — oauth2 to **register** managed providers and merge discovered models; models-info to **enrich** whatever providers/models are already there. +- **`Hooks["chat.headers"](input, output)`** — runs per chat request. Only oauth2 uses this; it injects `Authorization: Bearer ` for providers it manages. + +The whole picture sits in [`docs/architecture.md`](docs/architecture.md). If you're modifying hook behavior, read it first — it documents token lifecycle per flow, cache layout, the TTY-aware warmup logic, and which events you should expect in the log stream. + +### Per-package file layout convention + +Both plugins follow the same shape: + +``` +packages// +├── src/ +│ ├── index.ts # OpenCode entry — re-exports default plugin +│ ├── opencode.ts # Plugin factory: createXxxPlugin(opts) → Plugin +│ ├── lib.ts # Public library API (exposed via "./lib" subpath in exports) +│ ├── plugin.ts # Core runtime logic (split from opencode.ts so it stays testable) +│ ├── cache.ts # FileCacheStore — per-OS cache dir, atomic rename, 0o600 +│ ├── logging.ts # JSON console logger, host log-level mapping, secret redaction +│ └── … +└── test/ # vitest, *.test.ts +``` + +**Important — two entry points per published package:** + +- `"."` resolves to `dist/index.js` and is what OpenCode discovers. The host iterates every named export and rejects anything that isn't a `Plugin` function, so `index.ts` is kept *intentionally tiny* (a single `export { default } from "./opencode.js";`). See [`packages/opencode-oauth2/src/index.ts`](packages/opencode-oauth2/src/index.ts) and the matching `slim main entry` fix in commit history. +- `"./lib"` resolves to `dist/lib.js` and is the library API for embedders. New utility exports go through `lib.ts`, not `index.ts`. + +### Composition contract (models-info) + +`@vymalo/opencode-models-info` runs after other `config` hooks have populated `input.provider`. It opts in per provider via `options.meta.modelsInfoUrl` and the merge is **upstream-wins**: a field already present on a model entry is never overwritten. This is deliberate — it means the plugin is safe to enable globally and lets handwritten `opencode.json` config take precedence. + +When changing the mapping in [`packages/opencode-models-info/src/mapping.ts`](packages/opencode-models-info/src/mapping.ts): + +- OpenRouter's `pricing.prompt` / `.completion` are USD-per-token strings; OpenCode's `cost.input` / `cost.output` are USD-per-1M-tokens numbers. The conversion (`* 1_000_000` then round to 6 decimals) lives in `mapping.ts`. Don't move it. +- `limit` only emits if **both** `context` and `output` are known — partial `limit` blocks are invalid in OpenCode's schema. +- Modalities are filtered to OpenCode's enum (`text | audio | image | video | pdf`) — `"file"` and other OpenRouter values are dropped. + +## Conventions worth knowing + +- **Biome, not ESLint/Prettier.** Config in [`biome.json`](biome.json) — double quotes, 100-col, no trailing commas, semicolons always. `noNonNullAssertion` is a warning the existing code stays clean of; mirror that in new code (`@vymalo/opencode-oauth2` has 0 warnings, treat that as the bar). +- **Strict TS.** Base config is in [`tsconfig.base.json`](tsconfig.base.json) — `ES2022` + `NodeNext` + `strict: true`. Per-package tsconfig only sets `rootDir`/`outDir`. `lib.ts` re-exports are the public surface. +- **Vitest** is the test runner; each package owns a `vitest.config.ts`. Tests live in `test/`, not co-located. +- **Node ≥ 22** for the runtime packages (set in each package.json `engines`). Use `node:` prefixed imports for built-ins (`node:fs/promises`, `node:crypto`). +- **Logging pattern**: every plugin emits structured events through both a JSON console fallback and `client.app.log` (so the host log stream picks them up). Event names use `snake_case` (`models_info_cache_hit`, `oauth2_token_refreshed`). Add new events to that pattern, not ad-hoc `console.log`. +- **Cache layout** mirrors per-OS conventions — `~/Library/Caches//` on macOS, `XDG_CACHE_HOME` on Linux, `LOCALAPPDATA` on Windows. Each plugin uses its own namespace (`opencode-oauth2`, `opencode-models-info`). Disk writes are atomic-rename + `0o600`. + +## Shell / GitHub gotchas + +- **Default shell is zsh** on this laptop. `bash -c` scripts in tooling should stay POSIX-portable or be invoked under zsh explicitly. +- **`gh` auth** lives in the interactive zsh profile. If `gh` looks unauthenticated under a plain non-interactive shell, retry under `zsh -i -c '…'` — `GITHUB_TOKEN` is loaded from `.zshrc`. +- **Biome ignores `**/.claude`** in `biome.json`. The Claude Code worktree path lives under `.claude/worktrees//`, which means running `pnpm lint` from a worktree silently lints zero files. Lint per-package (`pnpm --filter exec biome lint .`) from a worktree, or run the workspace lint from the main checkout. + +## Design docs and plans + +- [`plans/prd.md`](plans/prd.md) — original oauth2 PRD with the phased roadmap. +- [`plans/models-info-plan.md`](plans/models-info-plan.md) — design doc for the metadata plugin, including the OpenRouter→OpenCode field mapping table. +- [`docs/`](docs/) — architecture, GitHub Actions / Kubernetes cookbooks, local-dev setup, troubleshooting. The architecture doc is canonical for hook behavior. diff --git a/package.json b/package.json index 2a54bdf..5e5fb5d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "lint": "biome lint .", "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", + "test:env:up": "docker compose -f test-env/docker-compose.yml up -d --wait", + "test:env:down": "docker compose -f test-env/docker-compose.yml down -v", + "test:integration:run": "INTEGRATION_MODELS_INFO_URL=${INTEGRATION_MODELS_INFO_URL:-http://127.0.0.1:18080/v1/models} pnpm -r --if-present test:integration", + "test:integration": "pnpm test:env:up && (pnpm test:integration:run; status=$?; pnpm test:env:down; exit $status)", "format": "biome format --write .", "format:check": "biome format ." }, diff --git a/packages/opencode-models-info/README.md b/packages/opencode-models-info/README.md index 5f54e42..9f2893c 100644 --- a/packages/opencode-models-info/README.md +++ b/packages/opencode-models-info/README.md @@ -109,6 +109,27 @@ A bare top-level array (no `data` wrapper) is also accepted. Files are named by `sha256(providerId::url)`, `0o600`, atomic-rename-on-write. +## Testing + +Unit tests run against mocked `fetch`: + +```sh +pnpm --filter @vymalo/opencode-models-info test +``` + +Integration tests run against a real HTTP server (WireMock) from the workspace's shared [`test-env/`](../../test-env/) compose stack. They skip themselves when `INTEGRATION_MODELS_INFO_URL` is unset: + +```sh +pnpm test:env:up # from repo root +pnpm --filter @vymalo/opencode-models-info test:integration +pnpm test:env:down + +# Or one-shot from repo root: spin up, run all integration suites, tear down. +pnpm test:integration +``` + +The integration suite exercises real network round-trips, ETag handling (`304 Not Modified`), `modelsInfoHeaders` propagation, and the disk cache — all against a fixed catalog fixture under [`test-env/wiremock/__files/openrouter-catalog.json`](../../test-env/wiremock/__files/openrouter-catalog.json). + ## Library API For embedding the enrichment logic outside an OpenCode hook (e.g. tests or custom tooling), import from the `/lib` subpath: diff --git a/packages/opencode-models-info/package.json b/packages/opencode-models-info/package.json index b0541cc..2624135 100644 --- a/packages/opencode-models-info/package.json +++ b/packages/opencode-models-info/package.json @@ -50,6 +50,7 @@ "lint": "biome lint .", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", "format": "biome format --write .", "format:check": "biome format ." }, diff --git a/packages/opencode-models-info/test/config.test.ts b/packages/opencode-models-info/test/config.test.ts new file mode 100644 index 0000000..45a5d61 --- /dev/null +++ b/packages/opencode-models-info/test/config.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_TIMEOUT_MS, DEFAULT_TTL_SECONDS, parseMetaOptions } from "../src/config.js"; + +describe("parseMetaOptions", () => { + it("returns null when no provider options exist", () => { + expect(parseMetaOptions(undefined)).toBeNull(); + }); + + it("returns null when meta is absent or not an object", () => { + expect(parseMetaOptions({})).toBeNull(); + expect(parseMetaOptions({ meta: "no" })).toBeNull(); + expect(parseMetaOptions({ meta: [] })).toBeNull(); + }); + + it("returns null when modelsInfoUrl is missing or empty", () => { + expect(parseMetaOptions({ meta: {} })).toBeNull(); + expect(parseMetaOptions({ meta: { modelsInfoUrl: "" } })).toBeNull(); + expect(parseMetaOptions({ meta: { modelsInfoUrl: " " } })).toBeNull(); + expect(parseMetaOptions({ meta: { modelsInfoUrl: 42 } })).toBeNull(); + }); + + it("applies defaults for optional fields", () => { + const out = parseMetaOptions({ meta: { modelsInfoUrl: "https://x.test/m" } }); + expect(out).toEqual({ + modelsInfoUrl: "https://x.test/m", + modelsInfoTtlSeconds: DEFAULT_TTL_SECONDS, + modelsInfoTimeoutMs: DEFAULT_TIMEOUT_MS, + modelsInfoHeaders: undefined, + modelsInfoFormat: "openrouter" + }); + }); + + it("coerces positive integers and ignores invalid numeric inputs", () => { + const out = parseMetaOptions({ + meta: { + modelsInfoUrl: "https://x.test/m", + modelsInfoTtlSeconds: 60.7, + modelsInfoTimeoutMs: -1 + } + }); + expect(out?.modelsInfoTtlSeconds).toBe(60); + expect(out?.modelsInfoTimeoutMs).toBe(DEFAULT_TIMEOUT_MS); + }); + + it("filters non-string header values out of modelsInfoHeaders", () => { + const out = parseMetaOptions({ + meta: { + modelsInfoUrl: "https://x.test/m", + modelsInfoHeaders: { "x-tenant": "t1", bogus: 123, empty: "" } + } + }); + expect(out?.modelsInfoHeaders).toEqual({ "x-tenant": "t1" }); + }); + + it("returns undefined headers when the map is empty after filtering", () => { + const out = parseMetaOptions({ + meta: { + modelsInfoUrl: "https://x.test/m", + modelsInfoHeaders: { bogus: 123 } + } + }); + expect(out?.modelsInfoHeaders).toBeUndefined(); + }); + + it("resolves relative URLs against baseURL with or without trailing slash", () => { + expect( + parseMetaOptions({ + baseURL: "https://x.test/v1", + meta: { modelsInfoUrl: "/models" } + })?.modelsInfoUrl + ).toBe("https://x.test/v1/models"); + + expect( + parseMetaOptions({ + baseURL: "https://x.test/v1/", + meta: { modelsInfoUrl: "models" } + })?.modelsInfoUrl + ).toBe("https://x.test/v1/models"); + }); + + it("leaves absolute URLs untouched even when baseURL is present", () => { + expect( + parseMetaOptions({ + baseURL: "https://x.test/v1", + meta: { modelsInfoUrl: "https://other.test/models" } + })?.modelsInfoUrl + ).toBe("https://other.test/models"); + }); + + it("keeps the relative path verbatim when baseURL is absent", () => { + expect( + parseMetaOptions({ + meta: { modelsInfoUrl: "/models" } + })?.modelsInfoUrl + ).toBe("/models"); + }); +}); diff --git a/packages/opencode-models-info/test/fetcher.test.ts b/packages/opencode-models-info/test/fetcher.test.ts new file mode 100644 index 0000000..f51776b --- /dev/null +++ b/packages/opencode-models-info/test/fetcher.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fetchOpenRouterModels } from "../src/fetcher.js"; + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + "content-type": "application/json", + ...(init.headers ?? {}) + } + }); +} + +describe("fetchOpenRouterModels", () => { + it("returns parsed models from a `{data: []}` envelope and captures ETag", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse( + { data: [{ id: "a" }, { id: "b" }] }, + { status: 200, headers: { etag: "v1" } } + ) + ); + + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + expect(result.status).toBe("ok"); + expect(result.models?.map((m) => m.id)).toEqual(["a", "b"]); + expect(result.etag).toBe("v1"); + }); + + it("accepts a bare top-level array response", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse([{ id: "a" }], { status: 200 })); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("ok"); + expect(result.models?.map((m) => m.id)).toEqual(["a"]); + }); + + it("filters out entries without a string `id`", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ data: [{ id: "a" }, { id: 42 }, { name: "no id" }, { id: "b" }] }) + ); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.models?.map((m) => m.id)).toEqual(["a", "b"]); + }); + + it("returns an error result on a non-2xx response", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("nope", { status: 500 })); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("error"); + expect(result.error).toMatch(/500/); + }); + + it("returns not-modified and echoes the supplied etag on 304", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 304 })); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + etag: "v1", + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("not-modified"); + expect(result.etag).toBe("v1"); + }); + + it("sends If-None-Match when an etag is provided", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ data: [] })); + await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + etag: "v1", + fetchImpl: fetchImpl as unknown as typeof fetch + }); + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers["if-none-match"]).toBe("v1"); + }); + + it("merges caller-supplied headers without dropping defaults", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ data: [] })); + await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + headers: { authorization: "Bearer t", "x-tenant": "t1" }, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.accept).toBe("application/json"); + expect(headers.authorization).toBe("Bearer t"); + expect(headers["x-tenant"]).toBe("t1"); + }); + + it("returns an error result instead of throwing on malformed JSON shape", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ unexpected: true })); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("error"); + expect(result.error).toMatch(/unexpected response shape/); + }); + + it("returns an error result instead of throwing when fetch throws", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error("network down")); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("error"); + expect(result.error).toBe("network down"); + }); + + it("aborts via AbortController when the timeout fires", async () => { + const fetchImpl = vi.fn().mockImplementation((_url: string, init: RequestInit) => { + return new Promise((_, reject) => { + const signal = init.signal as AbortSignal | undefined; + signal?.addEventListener("abort", () => { + reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + }); + }); + }); + + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 10, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("error"); + expect(result.error).toMatch(/abort/i); + }); +}); diff --git a/packages/opencode-models-info/test/integration/enrichment.integration.test.ts b/packages/opencode-models-info/test/integration/enrichment.integration.test.ts new file mode 100644 index 0000000..c0a73ae --- /dev/null +++ b/packages/opencode-models-info/test/integration/enrichment.integration.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { FileCacheStore } from "../../src/cache.js"; +import type { Logger } from "../../src/logging.js"; +import { enrichConfig, type EnrichConfigInput } from "../../src/plugin.js"; + +const INTEGRATION_URL = process.env.INTEGRATION_MODELS_INFO_URL; + +function logger(): Logger { + // Silence everything by default; flip to console for debugging. + const noop = () => undefined; + return { debug: noop, info: noop, warn: noop, error: noop }; +} + +describe.skipIf(!INTEGRATION_URL)("models-info ↔ WireMock integration", () => { + let cacheDir: string; + + beforeAll(async () => { + cacheDir = await mkdtemp(join(tmpdir(), "opencode-models-info-int-")); + }); + + afterAll(async () => { + await rm(cacheDir, { recursive: true, force: true }); + }); + + it("populates limit / cost / capability flags from a live OpenRouter-shaped endpoint", async () => { + const cache = new FileCacheStore(cacheDir); + const config: EnrichConfigInput = { + provider: { + wiremock: { + options: { meta: { modelsInfoUrl: INTEGRATION_URL } }, + models: { + "anthropic/claude-3.5-sonnet": {}, + "openai/gpt-4o": {}, + "test/text-only": {} + } + } + } + }; + + await enrichConfig(config, { cache, logger: logger() }); + + const sonnet = config.provider?.wiremock.models?.["anthropic/claude-3.5-sonnet"] as + | Record + | undefined; + expect(sonnet?.limit).toEqual({ context: 200_000, output: 8192 }); + expect(sonnet?.cost).toMatchObject({ input: 3, output: 15, cache_read: 0.3 }); + expect(sonnet?.tool_call).toBe(true); + expect(sonnet?.attachment).toBe(true); + expect(sonnet?.name).toBe("Anthropic: Claude 3.5 Sonnet"); + + const gpt = config.provider?.wiremock.models?.["openai/gpt-4o"] as + | Record + | undefined; + expect(gpt?.reasoning).toBe(true); + expect(gpt?.attachment).toBe(true); + + const textOnly = config.provider?.wiremock.models?.["test/text-only"] as + | Record + | undefined; + expect(textOnly?.attachment).toBeUndefined(); + expect(textOnly?.cost).toEqual({ input: 0, output: 0 }); + }); + + it("uses the disk cache on the second run and round-trips through 304", async () => { + const cache = new FileCacheStore(cacheDir); + + // First call seeds the cache + persists the ETag from WireMock. + const first: EnrichConfigInput = { + provider: { + etag: { + options: { meta: { modelsInfoUrl: INTEGRATION_URL, modelsInfoTtlSeconds: 1 } }, + models: { "anthropic/claude-3.5-sonnet": {} } + } + } + }; + await enrichConfig(first, { cache, logger: logger() }); + + // Wait past the 1-second TTL so the plugin must re-fetch — WireMock's + // ETag rule then answers with 304, and the cached models are reused. + await new Promise((r) => setTimeout(r, 1100)); + + const second: EnrichConfigInput = { + provider: { + etag: { + options: { meta: { modelsInfoUrl: INTEGRATION_URL, modelsInfoTtlSeconds: 1 } }, + models: { "anthropic/claude-3.5-sonnet": {} } + } + } + }; + await enrichConfig(second, { cache, logger: logger() }); + + const enriched = second.provider?.etag.models?.["anthropic/claude-3.5-sonnet"] as + | Record + | undefined; + expect(enriched?.limit).toEqual({ context: 200_000, output: 8192 }); + }); + + it("forwards provider headers — confirmed by the authenticated WireMock stub", async () => { + const cache = new FileCacheStore(cacheDir); + const url = new URL(INTEGRATION_URL ?? ""); + url.searchParams.set("auth", "required"); + + const config: EnrichConfigInput = { + provider: { + authed: { + options: { + meta: { + modelsInfoUrl: url.toString(), + modelsInfoHeaders: { Authorization: "Bearer integration-test-token" } + } + }, + models: { "anthropic/claude-3.5-sonnet": {} } + } + } + }; + + await enrichConfig(config, { cache, logger: logger() }); + + const enriched = config.provider?.authed.models?.["anthropic/claude-3.5-sonnet"] as + | Record + | undefined; + expect(enriched?.limit).toEqual({ context: 200_000, output: 8192 }); + }); + + it("falls through to a 401 error result when the required Bearer header is missing", async () => { + const cache = new FileCacheStore(await mkdtemp(join(tmpdir(), "no-auth-"))); + const url = new URL(INTEGRATION_URL ?? ""); + url.searchParams.set("auth", "required"); + + let lastWarn: { event: string; fields?: Record } | undefined; + const recordingLogger: Logger = { + debug: () => undefined, + info: () => undefined, + warn: (event, fields) => { + lastWarn = { event, fields: fields as Record }; + }, + error: () => undefined + }; + + const config: EnrichConfigInput = { + provider: { + noauth: { + options: { meta: { modelsInfoUrl: url.toString() } }, + models: { "anthropic/claude-3.5-sonnet": {} } + } + } + }; + + await enrichConfig(config, { cache, logger: recordingLogger }); + + expect(lastWarn?.event).toBe("models_info_fetch_failed_no_cache"); + expect(String(lastWarn?.fields?.error ?? "")).toMatch(/401/); + // No cache + failed fetch → model entry stays bare. + const untouched = config.provider?.noauth.models?.["anthropic/claude-3.5-sonnet"] as + | Record + | undefined; + expect(untouched).toEqual({}); + }); +}); diff --git a/packages/opencode-models-info/vitest.config.ts b/packages/opencode-models-info/vitest.config.ts index 7a2fffc..d72a2c5 100644 --- a/packages/opencode-models-info/vitest.config.ts +++ b/packages/opencode-models-info/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"], + exclude: ["test/integration/**", "node_modules/**", "dist/**"], clearMocks: true, restoreMocks: true } diff --git a/packages/opencode-models-info/vitest.integration.config.ts b/packages/opencode-models-info/vitest.integration.config.ts new file mode 100644 index 0000000..8e05832 --- /dev/null +++ b/packages/opencode-models-info/vitest.integration.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +// Separate config so the default `pnpm test` (unit tests) stays fast and +// hermetic. Integration tests require the test-env compose stack to be up; +// they skip themselves when INTEGRATION_MODELS_INFO_URL is unset. +export default defineConfig({ + test: { + environment: "node", + include: ["test/integration/**/*.test.ts"], + clearMocks: true, + restoreMocks: true, + testTimeout: 15_000, + hookTimeout: 15_000 + } +}); diff --git a/test-env/README.md b/test-env/README.md new file mode 100644 index 0000000..a0b1531 --- /dev/null +++ b/test-env/README.md @@ -0,0 +1,45 @@ +# test-env + +Reusable Docker Compose stack of backends that this workspace's plugins talk to during **integration** tests. Designed so each package can run real-network tests against fixed, scriptable services instead of mocking `fetch` — and so the same env is shared across every package in the monorepo. + +## Services + +| Service | Image | Host port | What it fakes | +| --- | --- | --- | --- | +| `wiremock` | `wiremock/wiremock:3.9.1` | `18080` | OpenRouter-shaped `/v1/models` endpoint for `@vymalo/opencode-models-info`. ETag-aware (`If-None-Match: "openrouter-v1"` → `304`) and has an `?auth=required` variant that demands a `Bearer` header. | +| `keycloak` _(placeholder, commented out)_ | `quay.io/keycloak/keycloak:25.0` | `18081` | OIDC server for the upcoming `@vymalo/opencode-oauth2` integration suite. Uncomment in `docker-compose.yml` and drop a realm export under `test-env/keycloak/` to wire it up. | + +## Quick start + +```sh +# bring the stack up +pnpm test:env:up + +# run integration tests for the models-info package +pnpm --filter @vymalo/opencode-models-info test:integration + +# tear down (and wipe volumes) +pnpm test:env:down +``` + +Or one-shot — orchestrates compose-up, the integration suites in every package, then compose-down: + +```sh +pnpm test:integration +``` + +## Endpoints when the stack is up + +- `http://127.0.0.1:18080/v1/models` — OpenRouter-shaped catalog (3 models). Returns `ETag: "openrouter-v1"`. +- `http://127.0.0.1:18080/v1/models` with `If-None-Match: "openrouter-v1"` — `304 Not Modified`. +- `http://127.0.0.1:18080/v1/models?auth=required` — `401` unless an `Authorization: Bearer …` header is present. +- `http://127.0.0.1:18080/__admin/health` — WireMock's liveness probe. CI scripts wait on this. +- `http://127.0.0.1:18080/__admin/requests` — request journal; handy for debugging. + +## Editing stubs + +The stubs live in [`wiremock/mappings/`](wiremock/mappings/) (request→response definitions) and [`wiremock/__files/`](wiremock/__files/) (response bodies). They're mounted **read-only** into the container — after editing, either `docker compose restart wiremock` or `curl -X POST http://127.0.0.1:18080/__admin/mappings/reset` to reload. + +## Why not testcontainers? + +Compose gives every package the same backends with no per-package wiring, and the env is also reachable from `curl`, browsers, and a debugger — useful when an integration test misbehaves. If a specific test needs ad-hoc lifecycle control later, layering testcontainers on top of an already-running compose stack is straightforward. diff --git a/test-env/docker-compose.yml b/test-env/docker-compose.yml new file mode 100644 index 0000000..6317ea4 --- /dev/null +++ b/test-env/docker-compose.yml @@ -0,0 +1,71 @@ +# Reusable integration-test backends for the workspace. +# +# Usage: +# docker compose -f test-env/docker-compose.yml up -d +# INTEGRATION_MODELS_INFO_URL=http://127.0.0.1:18080/v1/models \ +# pnpm --filter @vymalo/opencode-models-info test:integration +# docker compose -f test-env/docker-compose.yml down -v +# +# Or run everything end-to-end via the root scripts: +# pnpm test:integration +# +# Designed so every package in this monorepo can layer its own service in +# (e.g. Keycloak for @vymalo/opencode-oauth2 — see the commented block below) +# without forking the compose file per package. + +services: + wiremock: + image: wiremock/wiremock:3.9.1 + container_name: vymalo-test-wiremock + restart: unless-stopped + command: + - --verbose + - --disable-banner + - --global-response-templating + ports: + - "18080:8080" + volumes: + - ./wiremock/mappings:/home/wiremock/mappings:ro + - ./wiremock/__files:/home/wiremock/__files:ro + healthcheck: + test: + [ + "CMD-SHELL", + "wget -q -O - http://127.0.0.1:8080/__admin/health | grep -q healthy" + ] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + + # --- Future: drop-in Keycloak for @vymalo/opencode-oauth2 integration tests. + # Keep the realm export under test-env/keycloak/realm-export.json so the + # plugin's auth-code / device-code / refresh flows can run against a real + # OIDC server. Uncomment when you wire up the oauth2 integration suite. + # + # keycloak: + # image: quay.io/keycloak/keycloak:25.0 + # container_name: vymalo-test-keycloak + # restart: unless-stopped + # command: + # - start-dev + # - --import-realm + # - --health-enabled=true + # environment: + # KEYCLOAK_ADMIN: admin + # KEYCLOAK_ADMIN_PASSWORD: admin + # KC_HTTP_PORT: 8080 + # ports: + # - "18081:8080" + # volumes: + # - ./keycloak:/opt/keycloak/data/import:ro + # healthcheck: + # test: + # [ + # "CMD-SHELL", + # "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nhost: localhost\\r\\n' >&3 && cat <&3 | grep -q UP" + # ] + # interval: 5s + # timeout: 5s + # retries: 30 + # start_period: 15s diff --git a/test-env/scripts/wait-for-wiremock.sh b/test-env/scripts/wait-for-wiremock.sh new file mode 100755 index 0000000..eefaa2f --- /dev/null +++ b/test-env/scripts/wait-for-wiremock.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +# Wait until WireMock answers the admin health probe. +# Usage: wait-for-wiremock.sh [base-url] [timeout-seconds] +set -eu + +BASE_URL="${1:-http://127.0.0.1:18080}" +TIMEOUT="${2:-60}" +DEADLINE=$(( $(date +%s) + TIMEOUT )) + +while :; do + if curl -fsS "${BASE_URL}/__admin/health" >/dev/null 2>&1; then + echo "wiremock ready at ${BASE_URL}" + exit 0 + fi + if [ "$(date +%s)" -ge "${DEADLINE}" ]; then + echo "wiremock did not become healthy within ${TIMEOUT}s" >&2 + exit 1 + fi + sleep 1 +done diff --git a/test-env/wiremock/__files/openrouter-catalog.json b/test-env/wiremock/__files/openrouter-catalog.json new file mode 100644 index 0000000..18fccc1 --- /dev/null +++ b/test-env/wiremock/__files/openrouter-catalog.json @@ -0,0 +1,72 @@ +{ + "data": [ + { + "id": "anthropic/claude-3.5-sonnet", + "name": "Anthropic: Claude 3.5 Sonnet", + "context_length": 200000, + "pricing": { + "prompt": "0.000003", + "completion": "0.000015", + "input_cache_read": "0.0000003", + "input_cache_write": "0.00000375" + }, + "architecture": { + "input_modalities": ["text", "image"], + "output_modalities": ["text"], + "tokenizer": "Claude" + }, + "top_provider": { + "max_completion_tokens": 8192, + "context_length": 200000 + }, + "supported_parameters": [ + "tools", + "tool_choice", + "temperature", + "max_tokens", + "stop" + ] + }, + { + "id": "openai/gpt-4o", + "name": "OpenAI: GPT-4o", + "context_length": 128000, + "pricing": { + "prompt": "0.0000025", + "completion": "0.00001" + }, + "architecture": { + "input_modalities": ["text", "image", "audio"], + "output_modalities": ["text"], + "tokenizer": "GPT" + }, + "top_provider": { + "max_completion_tokens": 16384, + "context_length": 128000 + }, + "supported_parameters": [ + "tools", + "tool_choice", + "temperature", + "reasoning" + ] + }, + { + "id": "test/text-only", + "name": "Text Only Test Model", + "context_length": 32768, + "pricing": { + "prompt": "0", + "completion": "0" + }, + "architecture": { + "input_modalities": ["text"], + "output_modalities": ["text"] + }, + "top_provider": { + "max_completion_tokens": 4096 + }, + "supported_parameters": ["temperature"] + } + ] +} diff --git a/test-env/wiremock/mappings/openrouter-models.json b/test-env/wiremock/mappings/openrouter-models.json new file mode 100644 index 0000000..33f49be --- /dev/null +++ b/test-env/wiremock/mappings/openrouter-models.json @@ -0,0 +1,74 @@ +{ + "mappings": [ + { + "name": "openrouter-models-200", + "priority": 5, + "request": { + "method": "GET", + "urlPath": "/v1/models" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json", + "ETag": "\"openrouter-v1\"" + }, + "bodyFileName": "openrouter-catalog.json" + } + }, + { + "name": "openrouter-models-304", + "priority": 1, + "request": { + "method": "GET", + "urlPath": "/v1/models", + "headers": { + "If-None-Match": { "equalTo": "\"openrouter-v1\"" } + } + }, + "response": { + "status": 304, + "headers": { + "ETag": "\"openrouter-v1\"" + } + } + }, + { + "name": "openrouter-models-tenant-auth-required", + "priority": 3, + "request": { + "method": "GET", + "urlPath": "/v1/models", + "queryParameters": { + "auth": { "equalTo": "required" } + } + }, + "response": { + "status": 401, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { "error": "missing bearer token" } + } + }, + { + "name": "openrouter-models-tenant-auth-ok", + "priority": 2, + "request": { + "method": "GET", + "urlPath": "/v1/models", + "queryParameters": { + "auth": { "equalTo": "required" } + }, + "headers": { + "Authorization": { "matches": "Bearer .+" } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "openrouter-catalog.json" + } + } + ] +} From 66dfb841c4a4f85fbcfb53c4cb0fc7b6c286da3a Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Wed, 27 May 2026 11:39:53 +0200 Subject: [PATCH 3/5] chore(release): publish @vymalo/opencode-models-info from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing publish workflow to also build, pack, verify, and publish @vymalo/opencode-models-info alongside the oauth2 plugin. - Tarball verification mirrors the oauth2 checks (dist/index.js present, src/test/node_modules absent, LICENSE present). - Each package's publish step is now guarded by an `npm view` check — if the version in package.json is already on the registry, the step logs a notice and exits 0 instead of failing the whole workflow. Means a release that only bumps one package's version no longer fails on the unchanged one. - Release-attachment glob updated to `dist-tarball/**/*.tgz` so both packages' tarballs are uploaded. Also pick up biome format --write auto-fixes across the new package's source and tests — purely cosmetic, makes `pnpm format:check` green so the publish workflow's format-check gate stays clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 119 ++++++++++++------ packages/opencode-models-info/src/fetcher.ts | 4 +- packages/opencode-models-info/src/mapping.ts | 5 +- packages/opencode-models-info/src/opencode.ts | 5 +- packages/opencode-models-info/src/plugin.ts | 5 +- .../opencode-models-info/test/fetcher.test.ts | 23 ++-- .../opencode-models-info/test/plugin.test.ts | 5 +- 7 files changed, 102 insertions(+), 64 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b007f5..6077641 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -76,55 +76,73 @@ jobs: - name: Format check run: pnpm format:check - - name: Build plugin (tsc → packages/opencode-oauth2/dist) + - name: Build oauth2 plugin (tsc → packages/opencode-oauth2/dist) run: pnpm --filter @vymalo/opencode-oauth2 build - - name: Build release bundle (Rolldown) + - name: Build oauth2 release bundle (Rolldown) # Not what we publish to npm (that's the tsc output), but we still # build it on every publish so a broken bundler is caught here. run: pnpm --filter @vymalo/opencode-oauth2-bundle build - - name: Pack tarball (for inspection and as a release asset) + - name: Build models-info plugin (tsc → packages/opencode-models-info/dist) + run: pnpm --filter @vymalo/opencode-models-info build + + - name: Pack oauth2 tarball (for inspection and as a release asset) run: | - mkdir -p dist-tarball + mkdir -p dist-tarball/oauth2 pnpm --filter @vymalo/opencode-oauth2 pack \ - --pack-destination "$PWD/dist-tarball" - ls -la dist-tarball/ + --pack-destination "$PWD/dist-tarball/oauth2" + ls -la dist-tarball/oauth2/ + + - name: Pack models-info tarball + run: | + mkdir -p dist-tarball/models-info + pnpm --filter @vymalo/opencode-models-info pack \ + --pack-destination "$PWD/dist-tarball/models-info" + ls -la dist-tarball/models-info/ - name: Verify tarball contents - # Defense-in-depth: confirms we're shipping the dist/, NOT shipping - # source or tests, and that the package isn't accidentally empty. + # Defense-in-depth: confirms each package is shipping its dist/, NOT + # shipping source or tests, and that the package isn't accidentally + # empty. Runs against both tarballs. run: | set -euo pipefail - tarball=$(ls dist-tarball/*.tgz | head -1) - echo "=== Tarball: $tarball ===" - # List once into a variable. We then match against the variable with - # bash pattern matching — no pipes, no SIGPIPE, immune to the - # `grep -q | pipefail` trap regardless of listing size. - entries=$(tar -tzf "$tarball") - printf '%s\n' "$entries" - echo - echo "=== Validation ===" - # Wrap with newlines so a literal full-line match becomes a simple - # substring search. - haystack=$'\n'"$entries"$'\n' - if [[ "$haystack" != *$'\n'"package/dist/index.js"$'\n'* ]]; then - echo "FAIL: package/dist/index.js missing from tarball" - exit 1 - fi - if [[ "$haystack" =~ $'\n'package/(test|src|node_modules)/ ]]; then - echo "FAIL: tarball contains forbidden directories (test/src/node_modules)" - echo "Offending entries:" - printf '%s\n' "$entries" | grep -E '^package/(test|src|node_modules)/' || true - exit 1 - fi - if [[ "$haystack" != *$'\n'"package/LICENSE"$'\n'* ]]; then - echo "FAIL: LICENSE missing from tarball" - exit 1 - fi - echo "OK" - - - name: Publish to npm (with provenance) + verify_tarball() { + local tarball="$1" + echo "=== Tarball: $tarball ===" + # List once into a variable. We then match against the variable + # with bash pattern matching — no pipes, no SIGPIPE, immune to + # the `grep -q | pipefail` trap regardless of listing size. + local entries + entries=$(tar -tzf "$tarball") + printf '%s\n' "$entries" + echo + echo "=== Validation: $tarball ===" + # Wrap with newlines so a literal full-line match becomes a + # simple substring search. + local haystack=$'\n'"$entries"$'\n' + if [[ "$haystack" != *$'\n'"package/dist/index.js"$'\n'* ]]; then + echo "FAIL: package/dist/index.js missing from $tarball" + exit 1 + fi + if [[ "$haystack" =~ $'\n'package/(test|src|node_modules)/ ]]; then + echo "FAIL: $tarball contains forbidden directories (test/src/node_modules)" + echo "Offending entries:" + printf '%s\n' "$entries" | grep -E '^package/(test|src|node_modules)/' || true + exit 1 + fi + if [[ "$haystack" != *$'\n'"package/LICENSE"$'\n'* ]]; then + echo "FAIL: LICENSE missing from $tarball" + exit 1 + fi + echo "OK: $tarball" + } + + for tarball in dist-tarball/oauth2/*.tgz dist-tarball/models-info/*.tgz; do + verify_tarball "$tarball" + done + + - name: Publish oauth2 to npm (with provenance) if: ${{ inputs.dry_run != true }} # --provenance: generates a signed SLSA L2 attestation linking the # tarball to this workflow run + commit SHA. npm @@ -135,7 +153,15 @@ jobs: # guard. Release events check out a detached HEAD on # the tag SHA, which would otherwise trip the check. # --access public: required for first publish under a new scope. + # Skips publish if the version is already on the registry — so a + # release that only bumps one package's version doesn't fail. run: | + set -e + version=$(node -p "require('./packages/opencode-oauth2/package.json').version") + if npm view "@vymalo/opencode-oauth2@${version}" version >/dev/null 2>&1; then + echo "::notice::@vymalo/opencode-oauth2@${version} already on npm — skipping." + exit 0 + fi pnpm --filter @vymalo/opencode-oauth2 publish \ --access public \ --no-git-checks \ @@ -146,6 +172,23 @@ jobs: # CLI invocation that pnpm delegates to. NPM_CONFIG_PROVENANCE: "true" + - name: Publish models-info to npm (with provenance) + if: ${{ inputs.dry_run != true }} + run: | + set -e + version=$(node -p "require('./packages/opencode-models-info/package.json').version") + if npm view "@vymalo/opencode-models-info@${version}" version >/dev/null 2>&1; then + echo "::notice::@vymalo/opencode-models-info@${version} already on npm — skipping." + exit 0 + fi + pnpm --filter @vymalo/opencode-models-info publish \ + --access public \ + --no-git-checks \ + --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.MCP_NPM_AUTH_TOKEN }} + NPM_CONFIG_PROVENANCE: "true" + - name: Dry-run notice if: ${{ inputs.dry_run == true }} run: | @@ -158,4 +201,4 @@ jobs: with: # The tarball alongside the release page makes downstream # verification trivial (no need to fetch from npm just to inspect). - files: dist-tarball/*.tgz + files: dist-tarball/**/*.tgz diff --git a/packages/opencode-models-info/src/fetcher.ts b/packages/opencode-models-info/src/fetcher.ts index 446e2e2..ef80d47 100644 --- a/packages/opencode-models-info/src/fetcher.ts +++ b/packages/opencode-models-info/src/fetcher.ts @@ -77,6 +77,8 @@ function normalizeResponse(body: unknown): OpenRouterModel[] | undefined { function isOpenRouterModel(value: unknown): value is OpenRouterModel { return ( - Boolean(value) && typeof value === "object" && typeof (value as { id?: unknown }).id === "string" + Boolean(value) && + typeof value === "object" && + typeof (value as { id?: unknown }).id === "string" ); } diff --git a/packages/opencode-models-info/src/mapping.ts b/packages/opencode-models-info/src/mapping.ts index 3997372..0a26b75 100644 --- a/packages/opencode-models-info/src/mapping.ts +++ b/packages/opencode-models-info/src/mapping.ts @@ -122,7 +122,10 @@ function filterModalities(values: OpenRouterModality[] | undefined): OpenCodeMod } const out: OpenCodeModality[] = []; for (const value of values) { - if (OPENCODE_MODALITIES.has(value as OpenCodeModality) && !out.includes(value as OpenCodeModality)) { + if ( + OPENCODE_MODALITIES.has(value as OpenCodeModality) && + !out.includes(value as OpenCodeModality) + ) { out.push(value as OpenCodeModality); } } diff --git a/packages/opencode-models-info/src/opencode.ts b/packages/opencode-models-info/src/opencode.ts index 83b37cf..9aa6ca9 100644 --- a/packages/opencode-models-info/src/opencode.ts +++ b/packages/opencode-models-info/src/opencode.ts @@ -28,10 +28,7 @@ export interface OpenCodePluginFactoryOptions { * host's structured log stream, with the JSON console as a reliable fallback. * Mirrors the pattern used by `@vymalo/opencode-oauth2`. */ -function createOpenCodeLogger( - client: PluginInput["client"], - getMinLevel: () => LogLevel -): Logger { +function createOpenCodeLogger(client: PluginInput["client"], getMinLevel: () => LogLevel): Logger { const fallback = createJsonConsoleLogger("debug"); const write = (level: LogLevel, event: string, fields?: LogFields) => { diff --git a/packages/opencode-models-info/src/plugin.ts b/packages/opencode-models-info/src/plugin.ts index 4f2ff8f..39f39df 100644 --- a/packages/opencode-models-info/src/plugin.ts +++ b/packages/opencode-models-info/src/plugin.ts @@ -29,10 +29,7 @@ export interface EnrichDeps { * metadata onto each matching model entry. Runs providers in parallel; one * failure never blocks others. */ -export async function enrichConfig( - input: EnrichConfigInput, - deps: EnrichDeps -): Promise { +export async function enrichConfig(input: EnrichConfigInput, deps: EnrichDeps): Promise { const providers = input.provider; if (!providers) { return; diff --git a/packages/opencode-models-info/test/fetcher.test.ts b/packages/opencode-models-info/test/fetcher.test.ts index f51776b..728679d 100644 --- a/packages/opencode-models-info/test/fetcher.test.ts +++ b/packages/opencode-models-info/test/fetcher.test.ts @@ -14,12 +14,11 @@ function jsonResponse(body: unknown, init: ResponseInit = {}): Response { describe("fetchOpenRouterModels", () => { it("returns parsed models from a `{data: []}` envelope and captures ETag", async () => { - const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse( - { data: [{ id: "a" }, { id: "b" }] }, - { status: 200, headers: { etag: "v1" } } - ) - ); + const fetchImpl = vi + .fn() + .mockResolvedValue( + jsonResponse({ data: [{ id: "a" }, { id: "b" }] }, { status: 200, headers: { etag: "v1" } }) + ); const result = await fetchOpenRouterModels({ url: "https://x.test/models", @@ -33,9 +32,7 @@ describe("fetchOpenRouterModels", () => { }); it("accepts a bare top-level array response", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(jsonResponse([{ id: "a" }], { status: 200 })); + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([{ id: "a" }], { status: 200 })); const result = await fetchOpenRouterModels({ url: "https://x.test/models", timeoutMs: 5000, @@ -46,9 +43,11 @@ describe("fetchOpenRouterModels", () => { }); it("filters out entries without a string `id`", async () => { - const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse({ data: [{ id: "a" }, { id: 42 }, { name: "no id" }, { id: "b" }] }) - ); + const fetchImpl = vi + .fn() + .mockResolvedValue( + jsonResponse({ data: [{ id: "a" }, { id: 42 }, { name: "no id" }, { id: "b" }] }) + ); const result = await fetchOpenRouterModels({ url: "https://x.test/models", timeoutMs: 5000, diff --git a/packages/opencode-models-info/test/plugin.test.ts b/packages/opencode-models-info/test/plugin.test.ts index ab44717..41b04bf 100644 --- a/packages/opencode-models-info/test/plugin.test.ts +++ b/packages/opencode-models-info/test/plugin.test.ts @@ -37,10 +37,7 @@ function getModel( return model; } -function withProvider( - providerId: string, - provider: ProviderConfigLike -): EnrichConfigInput { +function withProvider(providerId: string, provider: ProviderConfigLike): EnrichConfigInput { return { provider: { [providerId]: provider } }; } From d8cbb79cfb7909c77a8cb196fd609a9630288598 Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Wed, 27 May 2026 11:56:22 +0200 Subject: [PATCH 4/5] fix(models-info): address PR review feedback (auth-agnostic, robustness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses inline review comments from gemini-code-assist and codex on PR #18. H1 — buildFetchHeaders now merges provider.options.headers ⨁ meta.modelsInfoHeaders (meta wins on conflict). Restores the auth-agnostic claim: static API keys and any plugin that has already populated provider.options.headers (e.g. @vymalo/opencode-oauth2 ≥ 0.4.0) flow through naturally without coupling. H2 — enrichProvider wrapped in try/catch. Promise.allSettled was silently swallowing any throw (disk write failures, mapping bugs); now surfaced via deps.logger.error("models_info_enrichment_failed"). M1 — resolveUrl uses standard WHATWG URL semantics. Previously stripped the leading slash from the candidate before resolving, which produced `https://x.test/v1/v1/models` when a user wrote `modelsInfoUrl: "/v1/models"` under `baseURL: "https://x.test/v1"`. Now: - `modelsInfoUrl: "models/info"` joins under baseURL's path (recommended). - `modelsInfoUrl: "/models/info"` is origin-rooted (escape hatch for metadata endpoints at a different path than the inference API). README and JSDoc updated; the integration test fixture was already absolute-URL based so untouched. M2 — 304 path applies the current TTL from config instead of carrying over the stored ttlSeconds. A tightened TTL takes effect on the next revalidation, not on the next full 200. M3 — fetcher treats "non-empty input filtered down to empty" as a parse error. A malformed catalog response (entries missing string `id`) no longer overwrites previously good cached data with []; the stale-cache fallback kicks in instead. M4 — cache.put is now best-effort. A read-only $HOME / cache dir previously made the fetched record evaporate; now we log `models_info_cache_write_failed` and still return the in-memory record so enrichment proceeds. M5 — cache key includes the meta.modelsInfoHeaders fingerprint. Switching an `x-tenant` header (or any user-specified meta header) busts the cache. Provider-level headers are intentionally NOT keyed in — a rotating OAuth2 bearer would otherwise thrash the cache on every refresh. Test coverage grew 39 → 52 unit tests; integration still 4. New cases cover header propagation + override, error-log on cache failure, parse error vs empty catalog, best-effort cache write, TTL refresh on 304, and header-keyed cache partitioning. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opencode-models-info/README.md | 32 ++- packages/opencode-models-info/src/cache.ts | 26 ++- packages/opencode-models-info/src/config.ts | 28 ++- packages/opencode-models-info/src/fetcher.ts | 19 +- packages/opencode-models-info/src/plugin.ts | 154 +++++++++++---- .../opencode-models-info/test/cache.test.ts | 18 ++ .../opencode-models-info/test/config.test.ts | 36 +++- .../opencode-models-info/test/fetcher.test.ts | 27 +++ .../opencode-models-info/test/plugin.test.ts | 183 +++++++++++++++++- 9 files changed, 463 insertions(+), 60 deletions(-) diff --git a/packages/opencode-models-info/README.md b/packages/opencode-models-info/README.md index 9f2893c..6d9eba3 100644 --- a/packages/opencode-models-info/README.md +++ b/packages/opencode-models-info/README.md @@ -35,7 +35,7 @@ For every provider you want enriched, add `options.meta.modelsInfoUrl`: "options": { "baseURL": "https://gateway.example.com/v1", "meta": { - "modelsInfoUrl": "/models/info", + "modelsInfoUrl": "models/info", "modelsInfoTtlSeconds": 86400, "modelsInfoTimeoutMs": 5000 } @@ -51,19 +51,41 @@ For every provider you want enriched, add `options.meta.modelsInfoUrl`: That's it. After OpenCode starts: 1. The hook picks up every provider with a `meta.modelsInfoUrl`. -2. It `GET`s that URL once (relative paths resolve against `baseURL`), reusing whatever auth headers the provider's other plugins/config already set. +2. It `GET`s that URL once, sending whatever `options.headers` the provider already has (so it composes with any auth plugin — see [Auth composition](#auth-composition)). 3. Each model entry whose `id` matches an entry in the response gets `limit`, `cost`, `modalities`, `tool_call`, `reasoning`, `attachment`, etc. filled in — **only where they were not already set** (upstream wins). -4. The response is cached on disk for `modelsInfoTtlSeconds` (default 24h), keyed by `(providerId, url)`. ETags are honored. +4. The response is cached on disk for `modelsInfoTtlSeconds` (default 24h), keyed by `(providerId, url, modelsInfoHeaders)`. ETags are honored. 5. On fetch error with a valid cache, the stale snapshot is served — the plugin never blocks OpenCode startup on a network failure. +### URL resolution + +`meta.modelsInfoUrl` resolves against `options.baseURL` using standard WHATWG URL semantics: + +| `baseURL` | `modelsInfoUrl` | Resolved URL | +| -------------------------- | ---------------------- | ------------------------------------- | +| `https://x.test/v1` | `models/info` | `https://x.test/v1/models/info` | +| `https://x.test/v1` | `/models/info` | `https://x.test/models/info` | +| `https://x.test/v1` | `https://o.test/m` | `https://o.test/m` | + +Two practical rules: drop the leading `/` to keep the metadata path under your inference API path; keep the leading `/` to escape to a different path under the same host. + ### Options | Option | Default | Notes | | ------------------------------- | ------------------ | --------------------------------------------------------------------- | -| `meta.modelsInfoUrl` | _(required)_ | Absolute URL or path relative to `options.baseURL`. | +| `meta.modelsInfoUrl` | _(required)_ | Absolute URL or path resolved against `options.baseURL` (see above). | | `meta.modelsInfoTtlSeconds` | `86400` (24h) | Cache TTL. | | `meta.modelsInfoTimeoutMs` | `5000` | Per-fetch HTTP timeout. | -| `meta.modelsInfoHeaders` | _(none)_ | Extra headers merged onto the request (rare — most users won't need). | +| `meta.modelsInfoHeaders` | _(none)_ | Extra request headers. Override `options.headers` on conflict. Included in the cache key, so a tenant switch busts the cache. | + +### Auth composition + +The plugin sends the union of `options.headers` and `meta.modelsInfoHeaders` (meta wins on conflict). This makes three common setups work without configuration: + +1. **Public metadata endpoint** (e.g. OpenRouter's `/models`) — no auth needed. +2. **Static API key** — drop a `Bearer` into `options.headers` once, both inference and metadata use it. +3. **OAuth2 via [`@vymalo/opencode-oauth2`](../opencode-oauth2/README.md) ≥ 0.4.0** — that plugin stamps the cached bearer into `options.headers.Authorization` at config time so the metadata fetch inherits it automatically. The chat-time path still uses freshly-refreshed tokens. + +If you need a different token for the metadata endpoint than for inference (e.g. a service-account bearer), set it explicitly under `meta.modelsInfoHeaders.Authorization` — it'll override whatever the provider has set. ### Expected response shape (OpenRouter) diff --git a/packages/opencode-models-info/src/cache.ts b/packages/opencode-models-info/src/cache.ts index 9ad8b81..e49c52a 100644 --- a/packages/opencode-models-info/src/cache.ts +++ b/packages/opencode-models-info/src/cache.ts @@ -19,8 +19,30 @@ export function resolveCacheDir(namespace = "opencode-models-info"): string { return join(resolveDefaultCacheRoot(), namespace); } -export function cacheKey(providerId: string, url: string): string { - return createHash("sha256").update(`${providerId}::${url}`).digest("hex"); +/** + * Cache key = sha256(providerId :: url :: stableJSON(headers)). + * + * Only the **caller-specified** headers (i.e. `meta.modelsInfoHeaders`) go + * into the key — NOT the provider's other request headers. Rationale: if a + * rotating bearer (e.g. from `@vymalo/opencode-oauth2`) were keyed in, the + * cache would thrash on every token refresh. Headers the user explicitly + * configures for the metadata fetch (tenant selectors, static auth, etc.) + * are exactly the ones that should bust the cache when they change. + */ +export function cacheKey( + providerId: string, + url: string, + headers?: Record +): string { + const headerPart = headers ? stableStringify(headers) : ""; + return createHash("sha256").update(`${providerId}::${url}::${headerPart}`).digest("hex"); +} + +function stableStringify(headers: Record): string { + const sorted = Object.keys(headers) + .sort() + .map((k) => [k.toLowerCase(), headers[k]] as const); + return JSON.stringify(sorted); } export interface CacheStore { diff --git a/packages/opencode-models-info/src/config.ts b/packages/opencode-models-info/src/config.ts index 023b604..5997890 100644 --- a/packages/opencode-models-info/src/config.ts +++ b/packages/opencode-models-info/src/config.ts @@ -41,8 +41,24 @@ function asPositiveInt(value: unknown, fallback: number): number { * Parse a provider's `options.meta` for opt-in model-info fields. Returns * `null` if the provider has not opted in (no `meta.modelsInfoUrl`). * - * Resolves `modelsInfoUrl` against `baseURL` when it is a relative path so - * config authors can write `"meta": { "modelsInfoUrl": "/v1/models/info" }`. + * URL resolution follows the WHATWG URL spec when `modelsInfoUrl` is not + * absolute: + * - Absolute URL (`https://…`) → used as-is. + * - Path starting with `/` → resolves from the **origin** + * of `baseURL`. So with + * `baseURL: "https://x.test/v1"` + * and `modelsInfoUrl: "/models"`, + * you get `https://x.test/models`. + * Useful when your metadata + * endpoint sits at a different + * path than the inference API. + * - Path without leading `/` → resolves **relative to** + * `baseURL`. So with + * `baseURL: "https://x.test/v1"` + * and `modelsInfoUrl: "models"`, + * you get `https://x.test/v1/models`. + * Useful when metadata sits under + * the same path as inference. */ export function parseMetaOptions( providerOptions: Record | undefined @@ -80,10 +96,14 @@ function resolveUrl(candidate: string, baseURL: string | undefined): string { if (!baseURL) { return candidate; } + // Always treat the baseURL as a directory by appending a trailing slash if + // it's missing. This way a path-relative `modelsInfoUrl` ("models/info") + // resolves under the baseURL's path instead of replacing its last segment + // (the WHATWG default). A leading-slash candidate ("/models/info") still + // resolves from the origin per spec. const base = baseURL.endsWith("/") ? baseURL : `${baseURL}/`; - const rel = candidate.startsWith("/") ? candidate.slice(1) : candidate; try { - return new URL(rel, base).toString(); + return new URL(candidate, base).toString(); } catch { return candidate; } diff --git a/packages/opencode-models-info/src/fetcher.ts b/packages/opencode-models-info/src/fetcher.ts index ef80d47..c6dfb83 100644 --- a/packages/opencode-models-info/src/fetcher.ts +++ b/packages/opencode-models-info/src/fetcher.ts @@ -64,17 +64,32 @@ export async function fetchOpenRouterModels(opts: FetchOptions): Promise 0 && filtered.length === 0) { + return undefined; + } + return filtered; +} + function isOpenRouterModel(value: unknown): value is OpenRouterModel { return ( Boolean(value) && diff --git a/packages/opencode-models-info/src/plugin.ts b/packages/opencode-models-info/src/plugin.ts index 39f39df..c4cf5b7 100644 --- a/packages/opencode-models-info/src/plugin.ts +++ b/packages/opencode-models-info/src/plugin.ts @@ -47,52 +47,70 @@ async function enrichProvider( providerConfig: ProviderConfigLike | undefined, deps: EnrichDeps ): Promise { - if (!providerConfig) { - return; - } - const opts = parseMetaOptions(providerConfig.options); - if (!opts) { - return; - } - const models = providerConfig.models; - if (!models || Object.keys(models).length === 0) { - deps.logger.debug("models_info_provider_skipped_no_models", { providerId }); - return; - } - - const record = await loadRecord(providerId, opts, deps); - if (!record) { - return; - } + try { + if (!providerConfig) { + return; + } + const opts = parseMetaOptions(providerConfig.options); + if (!opts) { + return; + } + const models = providerConfig.models; + if (!models || Object.keys(models).length === 0) { + deps.logger.debug("models_info_provider_skipped_no_models", { providerId }); + return; + } - const byId = new Map(record.models.map((m) => [m.id, m])); + // Pull whatever headers the upstream config (oauth2 plugin, static API + // key, etc.) has already attached to the provider; the meta-specific + // `modelsInfoHeaders` win on conflict. This is what makes the plugin + // truly auth-agnostic — we never need to know how the token was acquired. + const providerHeaders = asHeaderMap(providerConfig.options?.headers); + const record = await loadRecord(providerId, opts, providerHeaders, deps); + if (!record) { + return; + } - let enrichedCount = 0; - for (const [modelId, modelConfig] of Object.entries(models)) { - const declaredId = typeof modelConfig.id === "string" ? modelConfig.id : undefined; - const match = byId.get(modelId) ?? (declaredId ? byId.get(declaredId) : undefined); - if (!match) { - continue; + const byId = new Map(record.models.map((m) => [m.id, m])); + + let enrichedCount = 0; + for (const [modelId, modelConfig] of Object.entries(models)) { + const declaredId = typeof modelConfig.id === "string" ? modelConfig.id : undefined; + const match = byId.get(modelId) ?? (declaredId ? byId.get(declaredId) : undefined); + if (!match) { + continue; + } + const derived = mapOpenRouterEntry(match); + mergeIntoModel(modelConfig, derived); + enrichedCount += 1; } - const derived = mapOpenRouterEntry(match); - mergeIntoModel(modelConfig, derived); - enrichedCount += 1; - } - deps.logger.info("models_info_enriched", { - providerId, - enrichedCount, - totalModels: Object.keys(models).length, - sourceModels: record.models.length - }); + deps.logger.info("models_info_enriched", { + providerId, + enrichedCount, + totalModels: Object.keys(models).length, + sourceModels: record.models.length + }); + } catch (error) { + // Promise.allSettled would otherwise swallow this — surface it loudly so + // a broken cache disk or mapping bug isn't silently no-op'd per provider. + deps.logger.error("models_info_enrichment_failed", { + providerId, + error: error instanceof Error ? error.message : String(error) + }); + } } async function loadRecord( providerId: string, opts: MetaProviderOptions, + providerHeaders: Record | undefined, deps: EnrichDeps ): Promise { - const key = cacheKey(providerId, opts.modelsInfoUrl); + // Cache key is keyed on the user-specified `modelsInfoHeaders` (NOT the + // provider's rotating auth header) — so switching tenants busts the cache, + // but an OAuth2 token rotation does not thrash it. See cacheKey() docstring. + const key = cacheKey(providerId, opts.modelsInfoUrl, opts.modelsInfoHeaders); const now = deps.now ? deps.now() : Date.now(); const cached = await deps.cache.get(key); @@ -105,7 +123,7 @@ async function loadRecord( return cached; } - const headers = buildFetchHeaders(opts); + const headers = buildFetchHeaders(opts, providerHeaders); const result = await fetchOpenRouterModels({ url: opts.modelsInfoUrl, headers, @@ -121,7 +139,9 @@ async function loadRecord( etag: result.etag, models: result.models }; - await deps.cache.put(key, next); + // Disk write is best-effort — a read-only $HOME / cache dir shouldn't + // make us throw away a perfectly good fresh response. + await safePut(deps, key, next, providerId, opts.modelsInfoUrl); deps.logger.info("models_info_fetched", { providerId, url: opts.modelsInfoUrl, @@ -131,8 +151,15 @@ async function loadRecord( } if (result.status === "not-modified" && cached) { - const refreshed: CachedModelsRecord = { ...cached, fetchedAt: now }; - await deps.cache.put(key, refreshed); + // Apply the CURRENT TTL from config — a tightened TTL in opencode.json + // should take effect on the next revalidation, not on the next full + // 200 fetch (which might be 24h away). + const refreshed: CachedModelsRecord = { + ...cached, + fetchedAt: now, + ttlSeconds: opts.modelsInfoTtlSeconds + }; + await safePut(deps, key, refreshed, providerId, opts.modelsInfoUrl); deps.logger.debug("models_info_not_modified", { providerId, url: opts.modelsInfoUrl @@ -158,8 +185,53 @@ async function loadRecord( return undefined; } -function buildFetchHeaders(opts: MetaProviderOptions): Record | undefined { - return opts.modelsInfoHeaders; +/** + * Merge the provider's resolved request headers with the meta-specific + * `modelsInfoHeaders`. Meta wins on conflict so a user can override e.g. a + * dynamic `Authorization` header for the metadata endpoint specifically. + */ +function buildFetchHeaders( + opts: MetaProviderOptions, + providerHeaders: Record | undefined +): Record | undefined { + if (!providerHeaders && !opts.modelsInfoHeaders) { + return undefined; + } + return { + ...(providerHeaders ?? {}), + ...(opts.modelsInfoHeaders ?? {}) + }; +} + +async function safePut( + deps: EnrichDeps, + key: string, + record: CachedModelsRecord, + providerId: string, + url: string +): Promise { + try { + await deps.cache.put(key, record); + } catch (error) { + deps.logger.warn("models_info_cache_write_failed", { + providerId, + url, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +function asHeaderMap(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === "string" && v.length > 0) { + out[k] = v; + } + } + return Object.keys(out).length > 0 ? out : undefined; } export { FileCacheStore }; diff --git a/packages/opencode-models-info/test/cache.test.ts b/packages/opencode-models-info/test/cache.test.ts index 21ef484..845781f 100644 --- a/packages/opencode-models-info/test/cache.test.ts +++ b/packages/opencode-models-info/test/cache.test.ts @@ -81,4 +81,22 @@ describe("cacheKey", () => { expect(cacheKey("a", "u")).not.toBe(cacheKey("b", "u")); expect(cacheKey("a", "u")).not.toBe(cacheKey("a", "u2")); }); + + it("partitions the cache by caller-supplied headers", () => { + const noHeaders = cacheKey("a", "u"); + const tenant1 = cacheKey("a", "u", { "x-tenant": "t1" }); + const tenant2 = cacheKey("a", "u", { "x-tenant": "t2" }); + expect(tenant1).not.toBe(noHeaders); + expect(tenant1).not.toBe(tenant2); + }); + + it("ignores header key order and case", () => { + const a = cacheKey("a", "u", { "X-Tenant": "t1", Accept: "json" }); + const b = cacheKey("a", "u", { accept: "json", "x-tenant": "t1" }); + expect(a).toBe(b); + }); + + it("treats undefined and empty header maps the same", () => { + expect(cacheKey("a", "u", undefined)).toBe(cacheKey("a", "u")); + }); }); diff --git a/packages/opencode-models-info/test/config.test.ts b/packages/opencode-models-info/test/config.test.ts index 45a5d61..0a8eeed 100644 --- a/packages/opencode-models-info/test/config.test.ts +++ b/packages/opencode-models-info/test/config.test.ts @@ -63,20 +63,46 @@ describe("parseMetaOptions", () => { expect(out?.modelsInfoHeaders).toBeUndefined(); }); - it("resolves relative URLs against baseURL with or without trailing slash", () => { + it("resolves a path-relative modelsInfoUrl under the baseURL's path", () => { + // No leading slash + baseURL with or without trailing slash → joins under + // baseURL's path. baseURL is always treated as a directory. expect( parseMetaOptions({ baseURL: "https://x.test/v1", - meta: { modelsInfoUrl: "/models" } + meta: { modelsInfoUrl: "models/info" } })?.modelsInfoUrl - ).toBe("https://x.test/v1/models"); + ).toBe("https://x.test/v1/models/info"); expect( parseMetaOptions({ baseURL: "https://x.test/v1/", - meta: { modelsInfoUrl: "models" } + meta: { modelsInfoUrl: "models/info" } + })?.modelsInfoUrl + ).toBe("https://x.test/v1/models/info"); + }); + + it("resolves a leading-slash modelsInfoUrl from the origin of baseURL", () => { + // Standard WHATWG semantics: `/path` is origin-rooted. This is the + // escape hatch for users whose metadata endpoint sits at a different + // path than the inference API. + expect( + parseMetaOptions({ + baseURL: "https://x.test/v1", + meta: { modelsInfoUrl: "/models" } + })?.modelsInfoUrl + ).toBe("https://x.test/models"); + }); + + it("avoids path duplication when the modelsInfoUrl includes the same prefix as baseURL", () => { + // Regression: previously stripping the leading slash before resolving + // produced `https://x.test/v1/v1/models`. Standard semantics give the + // intuitive result. + expect( + parseMetaOptions({ + baseURL: "https://x.test/v1", + meta: { modelsInfoUrl: "/v1/models/info" } })?.modelsInfoUrl - ).toBe("https://x.test/v1/models"); + ).toBe("https://x.test/v1/models/info"); }); it("leaves absolute URLs untouched even when baseURL is present", () => { diff --git a/packages/opencode-models-info/test/fetcher.test.ts b/packages/opencode-models-info/test/fetcher.test.ts index 728679d..f760ac8 100644 --- a/packages/opencode-models-info/test/fetcher.test.ts +++ b/packages/opencode-models-info/test/fetcher.test.ts @@ -107,6 +107,33 @@ describe("fetchOpenRouterModels", () => { expect(headers["x-tenant"]).toBe("t1"); }); + it("treats a non-empty input that filters down to empty as a parse error", async () => { + // Catalog with two malformed entries — no `id: string` anywhere. We + // should NOT report this as a successful empty fetch (that would + // overwrite a previously good cache). + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse({ data: [{ id: 42 }, { name: "no id" }] })); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("error"); + expect(result.error).toMatch(/unexpected response shape/); + }); + + it("accepts a legitimately empty catalog as a successful (empty) response", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ data: [] })); + const result = await fetchOpenRouterModels({ + url: "https://x.test/models", + timeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + expect(result.status).toBe("ok"); + expect(result.models).toEqual([]); + }); + it("returns an error result instead of throwing on malformed JSON shape", async () => { const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ unexpected: true })); const result = await fetchOpenRouterModels({ diff --git a/packages/opencode-models-info/test/plugin.test.ts b/packages/opencode-models-info/test/plugin.test.ts index 41b04bf..98b8b83 100644 --- a/packages/opencode-models-info/test/plugin.test.ts +++ b/packages/opencode-models-info/test/plugin.test.ts @@ -77,7 +77,7 @@ describe("enrichConfig", () => { const config = withProvider("custom", { options: { baseURL: "https://x.test/v1", - meta: { modelsInfoUrl: "/models/info" } + meta: { modelsInfoUrl: "models/info" } }, models: { "model-a": {}, unmatched: { name: "Untouched" } } }); @@ -190,4 +190,185 @@ describe("enrichConfig", () => { }); expect(seed.get(key)?.fetchedAt).toBe(9_000_000); }); + + it("forwards provider.options.headers into the fetch (auth-agnostic composition)", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ data: [openRouterEntry] }), { status: 200 }) + ); + const config = withProvider("custom", { + options: { + headers: { Authorization: "Bearer from-provider", "x-tenant": "t1" }, + meta: { modelsInfoUrl: "https://x.test/m" } + }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: memoryCache(), + logger: silentLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Authorization).toBe("Bearer from-provider"); + expect(headers["x-tenant"]).toBe("t1"); + }); + + it("lets meta.modelsInfoHeaders override provider.options.headers on conflict", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ data: [openRouterEntry] }), { status: 200 }) + ); + const config = withProvider("custom", { + options: { + headers: { Authorization: "Bearer provider" }, + meta: { + modelsInfoUrl: "https://x.test/m", + modelsInfoHeaders: { Authorization: "Bearer meta-wins" } + } + }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: memoryCache(), + logger: silentLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Authorization).toBe("Bearer meta-wins"); + }); + + it("logs models_info_enrichment_failed instead of silently swallowing an unexpected error", async () => { + const logger = silentLogger(); + const explodingCache: CacheStore = { + get: async () => { + throw new Error("disk on fire"); + }, + put: async () => undefined + }; + const config = withProvider("custom", { + options: { meta: { modelsInfoUrl: "https://x.test/m" } }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: explodingCache, + logger, + fetchImpl: vi.fn() as unknown as typeof fetch + }); + + expect(logger.error).toHaveBeenCalledWith( + "models_info_enrichment_failed", + expect.objectContaining({ providerId: "custom", error: "disk on fire" }) + ); + }); + + it("treats a fully-filtered response as a parse error and serves stale cache", async () => { + const seed = new Map(); + const { cacheKey } = await import("../src/cache.js"); + seed.set(cacheKey("custom", "https://x.test/m"), { + fetchedAt: 0, + ttlSeconds: 1, + models: [openRouterEntry] + }); + + // Response has entries, but none have a string `id` — looks like a + // schema mismatch (e.g. provider changed its catalog format). Plugin + // should keep the previous snapshot rather than overwriting with []. + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ data: [{ name: "no-id" }, { id: 42 }] }), { status: 200 }) + ); + const logger = silentLogger(); + const config = withProvider("custom", { + options: { meta: { modelsInfoUrl: "https://x.test/m" } }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: memoryCache(seed), + logger, + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => 1_000_000 + }); + + expect(getModel(config, "custom", "model-a").cost).toEqual({ input: 3, output: 15 }); + expect(logger.warn).toHaveBeenCalledWith( + "models_info_fetch_failed_using_stale", + expect.objectContaining({ providerId: "custom" }) + ); + }); + + it("logs a warning but still enriches when cache.put fails", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ data: [openRouterEntry] }), { status: 200 }) + ); + const logger = silentLogger(); + const flakyCache: CacheStore = { + get: async () => undefined, + put: async () => { + throw new Error("read-only filesystem"); + } + }; + + const config = withProvider("custom", { + options: { meta: { modelsInfoUrl: "https://x.test/m" } }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: flakyCache, + logger, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + expect(logger.warn).toHaveBeenCalledWith( + "models_info_cache_write_failed", + expect.objectContaining({ providerId: "custom", error: "read-only filesystem" }) + ); + // Crucially, the model was enriched even though the disk write failed. + expect(getModel(config, "custom", "model-a").cost).toEqual({ input: 3, output: 15 }); + }); + + it("applies the current TTL when refreshing a 304 response", async () => { + const seed = new Map(); + const { cacheKey } = await import("../src/cache.js"); + const key = cacheKey("custom", "https://x.test/m"); + seed.set(key, { + fetchedAt: 0, + ttlSeconds: 1, // old TTL stored on disk + etag: "v1", + models: [openRouterEntry] + }); + + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 304 })); + const config = withProvider("custom", { + options: { + meta: { + modelsInfoUrl: "https://x.test/m", + modelsInfoTtlSeconds: 7200 // bumped in config + } + }, + models: { "model-a": {} } + }); + + await enrichConfig(config, { + cache: memoryCache(seed), + logger: silentLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => 1_000_000 + }); + + expect(seed.get(key)?.ttlSeconds).toBe(7200); + }); }); From 0532895dd12da6779f8cf4d1578872213d8a4264 Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Wed, 27 May 2026 11:56:35 +0200 Subject: [PATCH 5/5] feat(oauth2): propagate cached bearer into provider headers at config time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the end of the config hook, stamp the cached access token onto config.provider[id].options.headers.Authorization for every managed provider. This makes the token visible to subsequent config-time consumers — most notably @vymalo/opencode-models-info, which can now fetch from an OAuth2-protected `meta.modelsInfoUrl` by inheriting options.headers (no coupling between the two plugins). Safety: - Never overwrites a user-set Authorization header (case-insensitive check covers both `Authorization` and `authorization`). - Only propagates when the cached token is still valid (30s skew before declared expiry). Avoids handing a known-stale token to a sibling plugin's config-time fetch. - The chat.headers hook continues to overwrite per-request with a freshly-ensured token, so any drift between config-time and chat-time only ever affects other config-time consumers — never the actual inference call. Logs `oauth2_bearer_propagated_to_provider_headers` (debug) on propagation and `oauth2_bearer_propagation_skipped_user_set` when a user-supplied header wins. Adds two regression tests: - propagates the cached bearer into provider.options.headers - does not overwrite a user-set Authorization header Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opencode-oauth2/src/opencode.ts | 60 ++++++++++- .../opencode-oauth2/test/opencode.test.ts | 100 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/packages/opencode-oauth2/src/opencode.ts b/packages/opencode-oauth2/src/opencode.ts index cc95dfd..c28de59 100644 --- a/packages/opencode-oauth2/src/opencode.ts +++ b/packages/opencode-oauth2/src/opencode.ts @@ -15,6 +15,7 @@ import { type LogLevel } from "./logging.js"; import { OAuth2ModelSyncPlugin } from "./plugin.js"; +import type { TokenSet } from "./types.js"; /** * Map OpenCode's host-level `config.logLevel` (uppercase `"DEBUG" | "INFO" | @@ -386,6 +387,52 @@ function mergeDiscoveredModels( providerConfig.models = merged; } +/** Skew (ms) used when deciding whether a cached token is "still usable" for + * config-time propagation. Smaller than the OAuth client's own skew because a + * stale config-time Authorization header has limited blast radius — at worst, + * a sibling plugin's metadata fetch returns 401 and falls back to no + * enrichment. The chat-time path is unaffected. + */ +const CONFIG_TIME_TOKEN_SKEW_MS = 30_000; + +function isCachedTokenUsableForConfigTime(token: TokenSet | undefined): token is TokenSet { + if (!token?.accessToken) { + return false; + } + if (!token.expiresAt) { + // No declared lifetime → optimistically usable; this matches the + // user-flow assumption in `OAuthClient.isTokenValid`. + return true; + } + return token.expiresAt > Date.now() + CONFIG_TIME_TOKEN_SKEW_MS; +} + +function propagateCachedBearer( + providerConfig: OpenCodeProviderConfig, + providerId: string, + runtime: OAuth2ModelSyncPlugin, + logger: Logger +): void { + const cached = runtime.getCachedToken(providerId); + if (!isCachedTokenUsableForConfigTime(cached)) { + return; + } + + const options = (providerConfig.options ??= {} as NonNullable); + const headers = ((options as { headers?: Record }).headers ??= {}); + // Case-insensitive scan so a user-set `authorization:` lowercase entry + // also wins — HTTP header names are case-insensitive but most plugins use + // PascalCase. + const hasUserAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization"); + if (hasUserAuth) { + logger.debug("oauth2_bearer_propagation_skipped_user_set", { providerId }); + return; + } + + headers.Authorization = `${cached.tokenType || "Bearer"} ${cached.accessToken}`; + logger.debug("oauth2_bearer_propagated_to_provider_headers", { providerId }); +} + function createOpenCodeLogger(client: PluginInput["client"], getMinLevel: () => LogLevel): Logger { // Bypass createJsonConsoleLogger's own filter so the gate stays driven by // the current value of getMinLevel() — the level can change once the plugin @@ -495,11 +542,18 @@ export function createOpencodeOauth2Plugin( } const models = state.runtime.getServerModels(providerId); - if (models.length === 0) { - continue; + if (models.length > 0) { + mergeDiscoveredModels(providerConfig, models); } - mergeDiscoveredModels(providerConfig, models); + // Stamp the cached bearer onto `options.headers.Authorization` so + // subsequent `config` hooks (e.g. @vymalo/opencode-models-info + // fetching a metadata endpoint) can inherit it without depending + // on this plugin. `chat.headers` still overwrites per-request with + // a freshly-ensured token, so a stale value here can only ever + // affect other config-time consumers — never the actual inference + // call. We never clobber a user-set Authorization header. + propagateCachedBearer(providerConfig, providerId, state.runtime, logger); } }, "chat.headers": async (input, output) => { diff --git a/packages/opencode-oauth2/test/opencode.test.ts b/packages/opencode-oauth2/test/opencode.test.ts index c60aacd..879ee68 100644 --- a/packages/opencode-oauth2/test/opencode.test.ts +++ b/packages/opencode-oauth2/test/opencode.test.ts @@ -151,6 +151,106 @@ describe("OpenCode plugin hooks", () => { expect(output.headers.Authorization).toBe("Bearer cached-access"); }); + it("propagates the cached bearer into provider.options.headers for downstream config-time consumers", async () => { + // The composition contract for @vymalo/opencode-models-info: by the time + // a later `config` hook walks `config.provider[*]`, every managed + // provider already carries a usable `Authorization` header so an + // OAuth2-protected `meta.modelsInfoUrl` fetch inherits the token without + // depending on this plugin. + const cacheDir = await mkdtemp(join(tmpdir(), "opencode-hook-propagate-")); + const cache = new FileCacheStore(cacheDir); + await cache.ensureReady(); + + await cache.saveServerState({ + serverId: "example-ai", + updatedAt: Date.now(), + lastSyncAt: Date.now(), + token: { + accessToken: "cached-access", + tokenType: "Bearer", + refreshToken: "cached-refresh", + expiresAt: Date.now() + 60_000 + }, + rawModels: [{ id: "glm-5" }], + models: [{ id: "glm-5", displayName: "GLM 5" }] + }); + + const hooks = await createHooks(cacheDir); + + const config: Record = { + provider: { + "example-ai": { + options: { + baseURL: "https://api.example.com/v1", + oauth2: { + issuer: "https://auth.example.com", + clientId: "opencode-client", + scopes: ["openid", "offline_access"] + } + } + } + } + }; + + await hooks.config?.(config as never); + + const providerOptions = (config.provider as Record>)[ + "example-ai" + ].options as Record; + const headers = providerOptions.headers as Record | undefined; + expect(headers?.Authorization).toBe("Bearer cached-access"); + }); + + it("does not overwrite a user-set Authorization header during propagation", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "opencode-hook-userauth-")); + const cache = new FileCacheStore(cacheDir); + await cache.ensureReady(); + + await cache.saveServerState({ + serverId: "example-ai", + updatedAt: Date.now(), + lastSyncAt: Date.now(), + token: { + accessToken: "cached-access", + tokenType: "Bearer", + refreshToken: "cached-refresh", + expiresAt: Date.now() + 60_000 + }, + rawModels: [{ id: "glm-5" }], + models: [{ id: "glm-5", displayName: "GLM 5" }] + }); + + const hooks = await createHooks(cacheDir); + + const config: Record = { + provider: { + "example-ai": { + options: { + baseURL: "https://api.example.com/v1", + // Lowercase to also exercise the case-insensitive guard. + headers: { authorization: "Bearer user-override" }, + oauth2: { + issuer: "https://auth.example.com", + clientId: "opencode-client", + scopes: ["openid", "offline_access"] + } + } + } + } + }; + + await hooks.config?.(config as never); + + const headers = ( + (config.provider as Record>)["example-ai"].options as Record< + string, + unknown + > + ).headers as Record; + expect(headers.authorization).toBe("Bearer user-override"); + expect(headers.Authorization).toBeUndefined(); + }); + it("rejects invalid redirectPort in provider.options.oauth2", async () => { const cacheDir = await mkdtemp(join(tmpdir(), "opencode-hook-badport-")); const hooks = await createHooks(cacheDir);