From 227082d9c5bb380dafb976473ba7f887761257cf Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Mon, 20 Jul 2026 16:27:37 +0200 Subject: [PATCH] fix: support exact dynamic CLIProxyAPI catalogs --- README.md | 17 ++-- src/model-fetcher.ts | 34 ++++--- src/normalizer.ts | 157 +++++++++++++++++++++++++-------- src/plugin.ts | 61 +++++++++---- src/types.ts | 21 +++-- src/types/opencode-plugin.d.ts | 9 +- test/models.test.mjs | 43 +++++++++ test/normalizer.test.mjs | 35 ++++++-- test/plugin.test.mjs | 34 ++++++- 9 files changed, 320 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 8d14a45..01aaf34 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ Connect OpenCode to a running CLIProxyAPI instance (local or remote), authentica ## Features - **`/connect cliproxy`** — interactive setup (base URL + optional API key) -- **Provider `cliproxy`** — auto-registered with full model list +- **Configurable provider ID** — defaults to `cliproxy` for backwards compatibility - **Dynamic models** — fetched from CLIProxyAPI `/v1/models` with TTL cache +- **Server-authoritative rich catalog** — optional `client_version` catalog with exact capabilities and reasoning levels - **models.json enrichment** — defaults to CLIProxyAPI registry URL; override with local path or custom URL - **models.dev enrichment** — fills missing metadata (graceful fallback) - **Auth-aware base URL** — `/connect cliproxy` base URL used when `opencode.json` omits `baseURL` @@ -110,15 +111,18 @@ Optional settings in `opencode.json`: ```json { - "plugin": ["opencode-cliproxiapi-auth"], + "plugin": [["opencode-cliproxiapi-auth", { "providerId": "cliproxyapi" }]], "provider": { - "cliproxy": { + "cliproxyapi": { "options": { "baseURL": "http://localhost:8317/v1", "apiKey": "your-key-from-config.yaml", "modelCacheTtl": 300000, "refreshOnList": true, - "modelsDev": { "enabled": true } + "modelsClientVersion": "0.144.1", + "fabricatedFallback": false, + "modelsJsonPath": "", + "modelsDev": { "enabled": false } } } } @@ -159,10 +163,13 @@ Enrichment mapping: | Option | Type | Default | Description | |--------|------|---------|-------------| -| `baseURL` | string | `http://localhost:8317/v1` | CLIProxyAPI base URL; falls back to `/connect cliproxy` stored URL | +| `providerId` | string | `cliproxy` | Provider/auth namespace configured in the plugin tuple | +| `baseURL` | string | `http://localhost:8317/v1` | CLIProxyAPI base URL; falls back to the stored `/connect ` URL | | `apiKey` | string | — | Key from `config.yaml` `api-keys` (optional) | | `modelCacheTtl` | number | `300000` | Model cache TTL (ms) | | `refreshOnList` | boolean | `true` | Refresh models when provider options reload | +| `modelsClientVersion` | string | — | Adds `client_version` to `/v1/models` and parses the server's rich `models` catalog | +| `fabricatedFallback` | boolean | `true` | Set `false` to return no invented built-in models when no live/stale catalog exists | | `modelsJsonPath` | string | CLIProxyAPI GitHub `models.json` | Local path or URL; `""` disables enrichment | | `modelsDev.enabled` | boolean | `true` | Enrich from models.dev | | `modelsDev.url` | string | `https://models.dev/api.json` | models.dev API URL | diff --git a/src/model-fetcher.ts b/src/model-fetcher.ts index 3d6891b..a7f9337 100644 --- a/src/model-fetcher.ts +++ b/src/model-fetcher.ts @@ -22,7 +22,7 @@ function getCacheKey(config: CliproxyConfig): string { providerAliases: config.modelsDev.providerAliases, }) : ''; - return `${config.baseUrl}:${config.apiKey}:${config.modelsJsonPath ?? ''}:${modelsDevHash}`; + return `${config.baseUrl}:${config.apiKey}:${config.modelsClientVersion ?? ''}:${config.modelsJsonPath ?? ''}:${config.fabricatedFallback ?? true}:${modelsDevHash}`; } function buildAuthHeaders(apiKey: string): HeadersInit { @@ -40,15 +40,18 @@ async function fetchApiModels( config: CliproxyConfig, ): Promise { const baseUrl = config.baseUrl || CLIPROXY_ENDPOINTS.BASE_URL; - const modelsUrl = `${baseUrl}${CLIPROXY_ENDPOINTS.MODELS}`; + const modelsUrl = new URL(`${baseUrl}${CLIPROXY_ENDPOINTS.MODELS}`); + if (config.modelsClientVersion) { + modelsUrl.searchParams.set('client_version', config.modelsClientVersion); + } - debug(`Fetching models from ${modelsUrl}`); + debug(`Fetching models from ${modelsUrl.toString()}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); try { - const response = await fetch(modelsUrl, { + const response = await fetch(modelsUrl.toString(), { method: 'GET', headers: buildAuthHeaders(config.apiKey), signal: controller.signal, @@ -60,18 +63,20 @@ async function fetchApiModels( } const rawData: unknown = await response.json(); - if ( - !rawData || - typeof rawData !== 'object' || - !Array.isArray((rawData as CliproxyModelsResponse).data) - ) { + if (!rawData || typeof rawData !== 'object') { throw new Error('Invalid models response structure'); } const data = rawData as CliproxyModelsResponse; - return data.data - .filter((m) => m && typeof m.id === 'string') - .map(normalizeApiModel); + const entries = Array.isArray(data.models) ? data.models : data.data; + if (!Array.isArray(entries)) { + throw new Error('Invalid models response structure'); + } + + return entries + .filter((model) => model && typeof model === 'object') + .map(normalizeApiModel) + .filter((model) => model.id.length > 0); } finally { clearTimeout(timeoutId); } @@ -113,6 +118,11 @@ async function fetchModelsUncached(config: CliproxyConfig): Promise = {}; for (const level of levels) { - const key = level.toLowerCase(); - if (REASONING_LEVELS.has(key)) { - // Variant key is the reasoning level; config schema allows only `disabled`. - variants[key] = {}; + const key = level.toLowerCase().trim(); + if (REASONING_LEVEL_PATTERN.test(key)) { + variants[key] = { reasoningEffort: key }; } } return Object.keys(variants).length > 0 ? variants : undefined; } -/** Normalize a /v1/models entry */ -export function normalizeApiModel(model: { - id: string; - owned_by?: string; - [key: string]: unknown; -}): CliproxyModel { +/** Normalize a minimal OpenAI or rich Codex-compatible /v1/models entry. */ +export function normalizeApiModel(model: Record): CliproxyModel { + const id = getString(model.id) ?? getString(model.slug) ?? ''; + const inputModalities = getStringArray(model.input_modalities); + const levels = getReasoningLevels(model.supported_reasoning_levels); + const directThinking = isRecord(model.thinking) ? normalizeThinking(model.thinking) : undefined; + const thinking = directThinking ?? (levels.length > 0 ? { levels } : undefined); + const variants = thinkingToVariants(thinking); + const supportsTools = getBoolean(model.supports_tool_calls) ?? + getBoolean(model.supports_parallel_tool_calls); + const serverAuthoritative = [ + model.display_name, + model.max_context_window, + model.input_modalities, + model.supported_reasoning_levels, + ].some((value) => value !== undefined); + return { - id: model.id, - name: typeof model.name === 'string' ? model.name : model.id, - ownedBy: typeof model.owned_by === 'string' ? model.owned_by : undefined, - description: `CLIProxyAPI model: ${model.id}`, - supportsStreaming: true, - supportsTools: true, - supportsTemperature: true, + id, + name: getString(model.display_name) ?? getString(model.name) ?? id, + ownedBy: getString(model.owned_by), + description: getString(model.description) ?? `CLIProxyAPI model: ${id}`, + contextWindow: getNumber(model.max_context_window) ?? + getNumber(model.context_window) ?? + getNumber(model.context_length) ?? + getNumber(model.inputTokenLimit), + maxTokens: getNumber(model.max_completion_tokens) ?? getNumber(model.outputTokenLimit), + supportsStreaming: getBoolean(model.supports_streaming) ?? true, + supportsVision: inputModalities.length > 0 + ? inputModalities.includes('image') + : getBoolean(model.supports_vision), + supportsTools, + supportsTemperature: getBoolean(model.supports_temperature), + supportsReasoning: thinking ? true : getBoolean(model.supports_reasoning), + supportsAttachment: inputModalities.length > 0 + ? inputModalities.includes('image') + : getBoolean(model.supports_attachment), + thinking, + variants, + serverAuthoritative, }; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function getNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function getBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(getString).filter((entry): entry is string => entry !== undefined); +} + +function getReasoningLevels(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => getString(entry) ?? (isRecord(entry) ? getString(entry.effort) : undefined)) + .filter((entry): entry is string => entry !== undefined); +} + +function normalizeThinking(value: Record): CliproxyThinking | undefined { + const thinking: CliproxyThinking = { + min: getNumber(value.min), + max: getNumber(value.max), + zero_allowed: getBoolean(value.zero_allowed), + dynamic_allowed: getBoolean(value.dynamic_allowed), + levels: getStringArray(value.levels), + }; + return Object.values(thinking).some((entry) => entry !== undefined) ? thinking : undefined; +} + /** Normalize a models.json registry entry with rich metadata */ export function normalizeRegistryModel(model: CliproxyRegistryModel): CliproxyModel { const contextWindow = @@ -87,29 +144,51 @@ export function normalizeRegistryModel(model: CliproxyRegistryModel): CliproxyMo }; } -/** Merge registry metadata into API model (registry wins for known fields) */ +/** Fill missing API metadata from an optional registry; live rich fields remain authoritative. */ export function mergeModelMetadata( apiModel: CliproxyModel, registry?: CliproxyModel, ): CliproxyModel { if (!registry) return apiModel; + if (!apiModel.serverAuthoritative) { + return { + ...apiModel, + ...registry, + id: apiModel.id, + name: registry.name || apiModel.name, + description: registry.description || apiModel.description, + supportsStreaming: registry.supportsStreaming ?? apiModel.supportsStreaming, + supportsTools: registry.supportsTools ?? apiModel.supportsTools, + supportsTemperature: registry.supportsTemperature ?? apiModel.supportsTemperature, + supportsReasoning: registry.supportsReasoning ?? apiModel.supportsReasoning, + supportsVision: registry.supportsVision ?? apiModel.supportsVision, + supportsAttachment: registry.supportsAttachment ?? apiModel.supportsAttachment, + contextWindow: registry.contextWindow ?? apiModel.contextWindow, + maxTokens: registry.maxTokens ?? apiModel.maxTokens, + thinking: registry.thinking ?? apiModel.thinking, + variants: registry.variants ?? apiModel.variants, + }; + } + return { - ...apiModel, ...registry, + ...apiModel, id: apiModel.id, - name: registry.name || apiModel.name, - description: registry.description || apiModel.description, - supportsStreaming: registry.supportsStreaming ?? apiModel.supportsStreaming, - supportsTools: registry.supportsTools ?? apiModel.supportsTools, - supportsTemperature: registry.supportsTemperature ?? apiModel.supportsTemperature, - supportsReasoning: registry.supportsReasoning ?? apiModel.supportsReasoning, - supportsVision: registry.supportsVision ?? apiModel.supportsVision, - supportsAttachment: registry.supportsAttachment ?? apiModel.supportsAttachment, - contextWindow: registry.contextWindow ?? apiModel.contextWindow, - maxTokens: registry.maxTokens ?? apiModel.maxTokens, - thinking: registry.thinking ?? apiModel.thinking, - variants: registry.variants ?? apiModel.variants, + name: apiModel.name && apiModel.name !== apiModel.id ? apiModel.name : registry.name || apiModel.name, + description: apiModel.description && apiModel.description !== `CLIProxyAPI model: ${apiModel.id}` + ? apiModel.description + : registry.description || apiModel.description, + supportsStreaming: apiModel.supportsStreaming ?? registry.supportsStreaming, + supportsTools: apiModel.supportsTools ?? registry.supportsTools, + supportsTemperature: apiModel.supportsTemperature ?? registry.supportsTemperature, + supportsReasoning: apiModel.supportsReasoning ?? registry.supportsReasoning, + supportsVision: apiModel.supportsVision ?? registry.supportsVision, + supportsAttachment: apiModel.supportsAttachment ?? registry.supportsAttachment, + contextWindow: apiModel.contextWindow ?? registry.contextWindow, + maxTokens: apiModel.maxTokens ?? registry.maxTokens, + thinking: apiModel.thinking ?? registry.thinking, + variants: apiModel.variants ?? registry.variants, }; } diff --git a/src/plugin.ts b/src/plugin.ts index d294271..b3e0478 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -34,16 +34,20 @@ interface ParsedAuth { baseUrl?: string; } -export const CliproxyAuthPlugin: Plugin = async (_input) => { +export const CliproxyAuthPlugin: Plugin = async (_input, pluginOptions) => { + const providerId = getProviderId(pluginOptions); + return { config: async (config) => { const providers = config.provider ?? {}; - const existing = providers[CLIPROXY_PROVIDER_ID]; - const auth = await readAuthFromStore(CLIPROXY_PROVIDER_ID); + const existing = providers[providerId]; + const auth = await readAuthFromStore(providerId); const parsed = parseAuthKey(auth?.key); const baseUrl = getBaseUrl(existing?.options, parsed.baseUrl); - let models: CliproxyModel[] = CLIPROXY_DEFAULT_MODELS; + let models: CliproxyModel[] = usesFabricatedFallback(existing?.options) + ? CLIPROXY_DEFAULT_MODELS + : []; try { const apiKey = resolveApiKey(existing?.options, parsed); const runtimeConfig = createRuntimeConfig( @@ -53,11 +57,11 @@ export const CliproxyAuthPlugin: Plugin = async (_input) => { ); models = await fetchModels(runtimeConfig, false); } catch (error) { - warn(`Eager model fetch failed, using defaults: ${formatErrorForLog(error)}`); + warn(`Eager model fetch failed: ${formatErrorForLog(error)}`); } const shouldRefresh = shouldRefreshProviderModels(existing); - providers[CLIPROXY_PROVIDER_ID] = { + providers[providerId] = { ...existing, name: existing?.name ?? CLIPROXY_PROVIDER_NAME, npm: existing?.npm ?? CLIPROXY_PROVIDER_NPM, @@ -73,7 +77,7 @@ export const CliproxyAuthPlugin: Plugin = async (_input) => { }, provider: { - id: CLIPROXY_PROVIDER_ID, + id: providerId, models: async (provider, ctx) => { const parsed = parseAuthKey(ctx.auth?.type === 'api' ? ctx.auth.key : undefined); const apiKey = resolveApiKey(provider.options, parsed); @@ -84,18 +88,19 @@ export const CliproxyAuthPlugin: Plugin = async (_input) => { const models = await fetchModels(runtimeConfig, false); return toProviderModels(models, effectiveBaseUrl); } catch { - return toProviderModels(CLIPROXY_DEFAULT_MODELS, effectiveBaseUrl); + const fallback = runtimeConfig.fabricatedFallback === false ? [] : CLIPROXY_DEFAULT_MODELS; + return toProviderModels(fallback, effectiveBaseUrl); } }, }, - auth: createAuthHook(), + auth: createAuthHook(providerId), }; }; -function createAuthHook(): AuthHook { +function createAuthHook(providerId: string): AuthHook { return { - provider: CLIPROXY_PROVIDER_ID, + provider: providerId, methods: [ { type: 'api', @@ -120,7 +125,7 @@ function createAuthHook(): AuthHook { return { type: 'success', key: JSON.stringify({ baseURL, apiKey }), - provider: CLIPROXY_PROVIDER_ID, + provider: providerId, }; }, }, @@ -144,8 +149,8 @@ async function loadProviderOptions( models = await fetchModels(config, forceRefresh); debug(`Available models: ${models.map((m) => sanitizeForLog(m.id)).join(', ')}`); } catch (error) { - warn(`Failed to fetch models, using defaults: ${formatErrorForLog(error)}`); - models = CLIPROXY_DEFAULT_MODELS; + warn(`Failed to fetch models: ${formatErrorForLog(error)}`); + models = config.fabricatedFallback === false ? [] : CLIPROXY_DEFAULT_MODELS; } replaceProviderModels(provider, toProviderModels(models, config.baseUrl)); @@ -168,6 +173,8 @@ function createRuntimeConfig( apiKey, modelCacheTtl: getPositiveNumber(options, 'modelCacheTtl'), refreshOnList: getBoolean(options, 'refreshOnList'), + modelsClientVersion: getStringOption(options, 'modelsClientVersion'), + fabricatedFallback: getBoolean(options, 'fabricatedFallback'), modelsDev: getModelsDevConfig(options), modelsJsonPath: resolveModelsJsonPath(options), }; @@ -275,6 +282,18 @@ export function getBaseUrl( return CLIPROXY_ENDPOINTS.BASE_URL; } +function getProviderId(options: Record | undefined): string { + const providerId = getStringOption(options, 'providerId') ?? CLIPROXY_PROVIDER_ID; + if (!/^[a-z0-9][a-z0-9-]*$/i.test(providerId)) { + throw new Error('providerId must be a non-empty slug'); + } + return providerId; +} + +function usesFabricatedFallback(options: Record | undefined): boolean { + return getBoolean(options, 'fabricatedFallback') !== false; +} + function getPositiveNumber( options: Record | undefined, key: string, @@ -411,13 +430,23 @@ export function toProviderModel( const defaultReasoningVariants: Record = supportsReasoning && !model.variants - ? { low: {}, medium: {}, high: {} } + ? { + low: { reasoningEffort: 'low' }, + medium: { reasoningEffort: 'medium' }, + high: { reasoningEffort: 'high' }, + } : {}; - const variants = + const sourceVariants = model.variants && Object.keys(model.variants).length > 0 ? model.variants : defaultReasoningVariants; + const variants = Object.fromEntries( + Object.entries(sourceVariants).map(([effort, options]) => [ + effort, + { reasoningEffort: effort, ...options }, + ]), + ); const providerModel: CliproxyProviderModel = { id: model.id, diff --git a/src/types.ts b/src/types.ts index 36e77fa..e19c422 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,8 @@ export interface CliproxyModel { thinking?: CliproxyThinking; variants?: Record; + /** Internal marker for metadata returned by CLIProxyAPI's rich catalog. */ + serverAuthoritative?: boolean; pricing?: { input?: number; @@ -63,23 +65,20 @@ export interface CliproxyModel { }; } -/** OpenCode config variant — only `disabled` is allowed by opencode.ai/config.json */ +/** OpenCode config variant options merged into the selected model request. */ export interface CliproxyModelVariant { disabled?: boolean; + reasoningEffort?: string; } /** * OpenAI-compatible /v1/models response */ export interface CliproxyModelsResponse { - object: 'list'; - data: Array<{ - id: string; - object?: string; - created?: number; - owned_by?: string; - [key: string]: unknown; - }>; + object?: 'list'; + data?: Array>; + /** Rich Codex-compatible catalog returned when client_version is requested. */ + models?: Array>; } export interface CliproxyModelsDevConfig { @@ -99,6 +98,10 @@ export interface CliproxyConfig { defaultModels?: CliproxyModel[]; modelCacheTtl?: number; refreshOnList?: boolean; + /** Request CLIProxyAPI's server-authoritative rich client catalog. */ + modelsClientVersion?: string; + /** Retain the legacy built-in fallback catalog when no live/stale catalog exists. */ + fabricatedFallback?: boolean; modelsDev?: CliproxyModelsDevConfig; /** Local path or URL to CLIProxyAPI models.json for metadata enrichment */ modelsJsonPath?: string; diff --git a/src/types/opencode-plugin.d.ts b/src/types/opencode-plugin.d.ts index a17c473..b998062 100644 --- a/src/types/opencode-plugin.d.ts +++ b/src/types/opencode-plugin.d.ts @@ -21,7 +21,7 @@ declare module '@opencode-ai/plugin' { export interface Config { provider?: Record; - plugin?: string[]; + plugin?: Array]>; [key: string]: unknown; } @@ -121,7 +121,7 @@ declare module '@opencode-ai/plugin' { options?: Record; headers?: Record; status?: 'alpha' | 'beta' | 'deprecated' | 'active'; - variants?: Record; + variants?: Record; } export interface ProviderV2 { @@ -149,5 +149,8 @@ declare module '@opencode-ai/plugin' { [key: string]: unknown; } - export type Plugin = (input: PluginInput) => Promise; + export type Plugin = ( + input: PluginInput, + options?: Record, + ) => Promise; } \ No newline at end of file diff --git a/test/models.test.mjs b/test/models.test.mjs index c743d8e..3e686b0 100644 --- a/test/models.test.mjs +++ b/test/models.test.mjs @@ -117,6 +117,49 @@ test('fetchModels falls back to defaults when response shape is invalid', async assert.equal(typeof models[0].id, 'string'); }); +test('fetchModels can disable fabricated fallback models', async () => { + global.fetch = async () => new Response(JSON.stringify({ data: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const models = await fetchModels({ ...CONFIG, fabricatedFallback: false }, true); + assert.deepEqual(models, []); +}); + +test('fetchModels requests and parses CLIProxyAPI rich client catalog', async () => { + let requestedUrl = ''; + global.fetch = async (input) => { + requestedUrl = input instanceof Request ? input.url : input.toString(); + return new Response(JSON.stringify({ + models: [{ + slug: 'gpt-5.6-sol', + display_name: 'GPT 5.6 Sol', + max_context_window: 372000, + input_modalities: ['text', 'image'], + supported_reasoning_levels: [ + { effort: 'low' }, + { effort: 'xhigh' }, + { effort: 'ultra' }, + ], + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + + const models = await fetchModels({ + ...CONFIG, + modelsClientVersion: '0.144.1', + modelsJsonPath: undefined, + modelsDev: { enabled: false }, + fabricatedFallback: false, + }, true); + + assert.equal(new URL(requestedUrl).searchParams.get('client_version'), '0.144.1'); + assert.equal(models[0].id, 'gpt-5.6-sol'); + assert.equal(models[0].contextWindow, 372000); + assert.deepEqual(models[0].variants.ultra, { reasoningEffort: 'ultra' }); +}); + test('fetchModels deduplicates concurrent requests', async () => { let calls = 0; global.fetch = async (input) => { diff --git a/test/normalizer.test.mjs b/test/normalizer.test.mjs index f4766ea..d23060d 100644 --- a/test/normalizer.test.mjs +++ b/test/normalizer.test.mjs @@ -8,14 +8,39 @@ import { normalizeApiModel, } from '../dist/runtime.js'; -test('thinkingToVariants maps CLIProxyAPI levels to reasoning variants', () => { +test('thinkingToVariants maps every server-advertised level to an effective effort', () => { const variants = thinkingToVariants({ - levels: ['low', 'medium', 'high', 'xhigh', 'max'], + levels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], }); - assert.deepEqual(variants.low, {}); - assert.deepEqual(variants.xhigh, {}); - assert.deepEqual(variants.max, {}); + assert.deepEqual(variants.low, { reasoningEffort: 'low' }); + assert.deepEqual(variants.xhigh, { reasoningEffort: 'xhigh' }); + assert.deepEqual(variants.max, { reasoningEffort: 'max' }); + assert.deepEqual(variants.ultra, { reasoningEffort: 'ultra' }); +}); + +test('normalizeApiModel consumes server-authoritative rich catalog metadata', () => { + const model = normalizeApiModel({ + id: 'gpt-5.6-sol', + display_name: 'GPT 5.6 Sol', + description: 'Latest frontier agentic coding model.', + max_context_window: 372000, + input_modalities: ['text', 'image'], + supports_parallel_tool_calls: true, + supported_reasoning_levels: [ + { effort: 'low' }, + { effort: 'xhigh' }, + { effort: 'ultra' }, + ], + }); + + assert.equal(model.name, 'GPT 5.6 Sol'); + assert.equal(model.description, 'Latest frontier agentic coding model.'); + assert.equal(model.contextWindow, 372000); + assert.equal(model.supportsVision, true); + assert.equal(model.supportsTools, true); + assert.equal(model.supportsReasoning, true); + assert.deepEqual(model.variants.ultra, { reasoningEffort: 'ultra' }); }); test('normalizeRegistryModel reads gemini token limits', () => { diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 039ca7b..25f816f 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -12,11 +12,13 @@ import { parseAuthKey } from '../dist/src/plugin.js'; const ORIGINAL_FETCH = global.fetch; const ORIGINAL_HOME = process.env.HOME; const ORIGINAL_XDG_DATA_HOME = process.env.XDG_DATA_HOME; +const ORIGINAL_CLIPROXY_API_KEY = process.env.CLIPROXY_API_KEY; afterEach(() => { global.fetch = ORIGINAL_FETCH; restoreEnv('HOME', ORIGINAL_HOME); restoreEnv('XDG_DATA_HOME', ORIGINAL_XDG_DATA_HOME); + restoreEnv('CLIPROXY_API_KEY', ORIGINAL_CLIPROXY_API_KEY); clearModelCache(); clearModelsDevCache(); }); @@ -54,6 +56,33 @@ test('config hook applies default baseURL', async () => { assert.equal(config.provider.cliproxy.options.baseURL, 'http://localhost:8317/v1'); }); +test('provider ID is configurable while retaining the legacy default', async () => { + global.fetch = async () => new Response( + JSON.stringify({ object: 'list', data: [{ id: 'live-model' }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + + const plugin = await CliproxyAuthPlugin({}, { providerId: 'cliproxyapi' }); + const config = { + provider: { + cliproxyapi: { + options: { + baseURL: baseUrl(), + modelsJsonPath: '', + modelsDev: { enabled: false }, + fabricatedFallback: false, + }, + }, + }, + }; + + await plugin.config(config); + assert.equal(plugin.provider.id, 'cliproxyapi'); + assert.equal(plugin.auth.provider, 'cliproxyapi'); + assert.ok(config.provider.cliproxyapi.models['live-model']); + assert.equal(config.provider.cliproxy, undefined); +}); + test('parseAuthKey supports JSON connect payload and plain keys', () => { const json = parseAuthKey(JSON.stringify({ baseURL: 'http://127.0.0.1:9000/v1', apiKey: 'secret' })); assert.equal(json.apiKey, 'secret'); @@ -100,6 +129,7 @@ test('loader injects auth header only for CLIProxyAPI URLs', async () => { }); test('loader works without api key when server has no auth', async () => { + delete process.env.CLIPROXY_API_KEY; const plugin = await CliproxyAuthPlugin({}); let authHeader = 'unset'; @@ -287,8 +317,8 @@ test('toProviderModel matches opencode.ai/config.json model shape', async () => assert.equal(model[key], undefined, `unexpected field ${key}`); } assert.equal(model.cost.cache, undefined); - assert.deepEqual(model.variants.low, {}); - assert.equal(model.variants.low.reasoningEffort, undefined); + assert.deepEqual(model.variants.low, { reasoningEffort: 'low' }); + assert.equal(model.variants.low.reasoningEffort, 'low'); }); test('fetch interceptor does not force Content-Type on GET requests', async () => {