diff --git a/src/plugin.ts b/src/plugin.ts index dc90dd4..0903877 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -19,7 +19,7 @@ import { DEFAULT_CONTEXT_LIMIT, DEFAULT_OUTPUT_LIMIT, } from './constants.js'; -import { fetchModels, resolveProviderAliasForMetadata, isProviderAlias } from './models.js'; +import { fetchModels, resolveProviderAliasForMetadata } from './models.js'; import { warn, debug } from './logger.js'; import { sanitizeForLog } from './omniroute-combos.js'; @@ -31,6 +31,14 @@ type AuthHook = NonNullable; type AuthLoader = NonNullable; type AuthAccessor = Parameters[0]; type ProviderDefinition = Parameters[1]; +const RAW_MODEL_METADATA = Symbol('omniroute.rawModelMetadata'); +const RAW_MODEL_METADATA_OPTION = '__omnirouteRawModelMetadata'; +const MODELS_GENERATED_BY_PLUGIN = Symbol('omniroute.modelsGeneratedByPlugin'); +const MODELS_GENERATED_BY_PLUGIN_OPTION = '__omnirouteModelsGeneratedByPlugin'; +type OptionsWithRawModelMetadata = Record & { + [RAW_MODEL_METADATA]?: unknown; + [MODELS_GENERATED_BY_PLUGIN]?: unknown; +}; export const OmniRouteAuthPlugin: Plugin = async (_input) => { return { @@ -40,6 +48,7 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { const baseUrl = getBaseUrl(existingProvider?.options); const apiMode = getApiMode(existingProvider?.options); const providerApi = resolveProviderApi(existingProvider?.api, apiMode); + const rawUserModelMetadata = getRawUserModelMetadata(existingProvider?.options); // Eagerly fetch models for OpenCode <=1.14.48 (which read models from config hook). // OpenCode >=1.14.49 uses the provider hook below instead. @@ -55,6 +64,11 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { warn(`Eager model fetch failed, using defaults: ${error}`); } + const effectiveModels = applyModelMetadataOverrides( + models, + rawUserModelMetadata, + ); + const generatedModelMetadata: Record = {}; for (const model of models) { // Use canonical ID for metadata keys to match user config @@ -73,26 +87,32 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { } const modelMetadata = mergeModelMetadata( - existingProvider?.options?.modelMetadata, + rawUserModelMetadata, generatedModelMetadata, ); + const providerOptions: Record = { + ...(existingProvider?.options ?? {}), + baseURL: baseUrl, + apiMode, + modelMetadata, + }; + setRawUserModelMetadata(providerOptions, rawUserModelMetadata); + + const shouldRefreshModels = shouldRefreshProviderModels(existingProvider); + const providerModels = shouldRefreshModels + ? toProviderModels(effectiveModels, baseUrl) + : existingProvider?.models; + setModelsGeneratedByPlugin(providerOptions, shouldRefreshModels); + providers[OMNIROUTE_PROVIDER_ID] = { ...existingProvider, name: existingProvider?.name ?? OMNIROUTE_PROVIDER_NAME, api: providerApi, npm: existingProvider?.npm ?? OMNIROUTE_PROVIDER_NPM, env: existingProvider?.env ?? OMNIROUTE_PROVIDER_ENV, - options: { - ...(existingProvider?.options ?? {}), - baseURL: baseUrl, - apiMode, - modelMetadata, - }, - models: - existingProvider?.models && Object.keys(existingProvider.models).length > 0 - ? existingProvider.models - : toProviderModels(models, baseUrl), + options: providerOptions, + models: providerModels, }; config.provider = providers; @@ -107,12 +127,20 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { if (ctx.auth?.type === 'api' && ctx.auth.key) { const runtimeConfig = createRuntimeConfig(provider.options, ctx.auth.key); const models = await fetchModels(runtimeConfig, ctx.auth.key, false); - return toProviderModels(models, baseUrl); + const effectiveModels = applyModelMetadataOverrides( + models, + getRawUserModelMetadata(provider.options), + ); + return toProviderModels(effectiveModels, baseUrl); } // No auth yet (user hasn't /connect'd): return built-in defaults. // This ensures models have the correct metadata (like api.url) to work with the plugin. - return toProviderModels(OMNIROUTE_DEFAULT_MODELS, baseUrl); + const effectiveModels = applyModelMetadataOverrides( + OMNIROUTE_DEFAULT_MODELS, + getRawUserModelMetadata(provider.options), + ); + return toProviderModels(effectiveModels, baseUrl); }, }, auth: createAuthHook(), @@ -155,7 +183,11 @@ async function loadProviderOptions( models = OMNIROUTE_DEFAULT_MODELS; } - replaceProviderModels(provider, toProviderModels(models, config.baseUrl)); + const effectiveModels = applyModelMetadataOverrides( + models, + getRawUserModelMetadata(provider.options), + ); + replaceProviderModels(provider, toProviderModels(effectiveModels, config.baseUrl)); if (isRecord(provider.models)) { debug(`Provider models hydrated: ${Object.keys(provider.models).length}`); } @@ -340,6 +372,96 @@ function getModelMetadataConfig( return undefined; } +function getRawUserModelMetadata(options: Record | undefined): unknown { + if (!options) return undefined; + const optionsWithRaw = options as OptionsWithRawModelMetadata; + // Preserve raw user-authored modelMetadata separately from generated compatibility + // metadata. The non-enumerable Symbol is the in-memory fast path; if OpenCode + // clones/serializes options between lifecycle hooks, the internal option field + // survives and distinguishes "no raw metadata" (null) from generated metadata. + if (RAW_MODEL_METADATA in optionsWithRaw) { + return optionsWithRaw[RAW_MODEL_METADATA]; + } + if (RAW_MODEL_METADATA_OPTION in options) { + return options[RAW_MODEL_METADATA_OPTION] === null + ? undefined + : options[RAW_MODEL_METADATA_OPTION]; + } + return options.modelMetadata; +} + +function setRawUserModelMetadata(options: Record, rawUserConfig: unknown): void { + options[RAW_MODEL_METADATA_OPTION] = serializeRawModelMetadataForOption(rawUserConfig) ?? null; + Object.defineProperty(options, RAW_MODEL_METADATA, { + value: rawUserConfig, + enumerable: false, + configurable: true, + writable: true, + }); +} + +function serializeRawModelMetadataForOption(raw: unknown): unknown { + if (!Array.isArray(raw)) return raw; + + return raw.map((block) => { + if (!isRecord(block) || !isRegExp(block.match)) return block; + + return { + ...block, + match: { + source: block.match.source, + flags: block.match.flags, + }, + }; + }); +} + +function getModelsGeneratedByPlugin(options: Record | undefined): boolean { + if (!options) return false; + const optionsWithMarker = options as OptionsWithRawModelMetadata; + if (MODELS_GENERATED_BY_PLUGIN in optionsWithMarker) { + return optionsWithMarker[MODELS_GENERATED_BY_PLUGIN] === true; + } + return options[MODELS_GENERATED_BY_PLUGIN_OPTION] === true; +} + +function setModelsGeneratedByPlugin( + options: Record, + generatedByPlugin: boolean, +): void { + options[MODELS_GENERATED_BY_PLUGIN_OPTION] = generatedByPlugin ? true : null; + Object.defineProperty(options, MODELS_GENERATED_BY_PLUGIN, { + value: generatedByPlugin, + enumerable: false, + configurable: true, + writable: true, + }); +} + +function hasProviderModels(provider: ProviderDefinition | undefined): boolean { + return Boolean(provider?.models && Object.keys(provider.models).length > 0); +} + +function shouldRefreshProviderModels(provider: ProviderDefinition | undefined): boolean { + if (!hasProviderModels(provider)) return true; + if (getModelsGeneratedByPlugin(provider?.options)) return true; + return hasLegacyGeneratedProviderModels(provider?.models); +} + +function hasLegacyGeneratedProviderModels(models: Record | undefined): boolean { + if (!isRecord(models)) return false; + const values = Object.values(models); + if (values.length === 0) return false; + return values.every(isGeneratedOmniRouteProviderModel); +} + +function isGeneratedOmniRouteProviderModel(value: unknown): boolean { + if (!isRecord(value)) return false; + if (value.providerID !== OMNIROUTE_PROVIDER_ID) return false; + if (!isRecord(value.api)) return false; + return value.api.npm === OMNIROUTE_PROVIDER_NPM; +} + function getStringRecord(value: unknown): Record | undefined { if (!isRecord(value)) return undefined; @@ -402,6 +524,130 @@ function mergeModelMetadata( return generated; } +function applyModelMetadataOverrides( + models: OmniRouteModel[], + rawUserConfig: unknown, +): OmniRouteModel[] { + const userConfig = getModelMetadataConfig({ modelMetadata: rawUserConfig }); + if (!userConfig) return models; + + if (Array.isArray(userConfig)) { + const validBlocks = userConfig.filter((block) => { + const validation = isValidModelMetadata(block); + if (!validation.valid) { + warn(`Invalid metadata block for match "${sanitizeForLog(String(block.match))}" (field: ${sanitizeForLog(validation.field ?? '')}), skipping`); + return false; + } + return true; + }); + + const modelsWithOverrides = models.map((model) => { + const canonicalId = resolveProviderAliasForMetadata(model.id); + const block = validBlocks.find((candidate) => + metadataBlockMatches(candidate.match, model.id, canonicalId), + ); + if (!block) return model; + + return { + ...model, + ...extractModelMetadata(block), + }; + }); + + const existingModels = modelsWithOverrides.map((model) => ({ + id: model.id, + canonicalId: resolveProviderAliasForMetadata(model.id), + })); + const missingModels: OmniRouteModel[] = []; + for (const block of validBlocks) { + if (block.addIfMissing !== true || typeof block.match !== 'string') continue; + + const id = resolveProviderAliasForMetadata(block.match); + const alreadyExists = existingModels.some((model) => + metadataBlockMatches(block.match, model.id, model.canonicalId), + ) || missingModels.some((model) => model.id === id); + if (alreadyExists) continue; + + missingModels.push({ + id, + name: block.name ?? id, + ...extractModelMetadata(block), + }); + } + + return [...modelsWithOverrides, ...missingModels]; + } + + const overrides: Record = {}; + for (const [id, metadata] of Object.entries(userConfig)) { + const validation = isValidModelMetadata(metadata); + if (!validation.valid) { + warn(`Invalid metadata for model "${sanitizeForLog(id)}" (field: ${sanitizeForLog(validation.field ?? '')}), skipping`); + continue; + } + + const canonicalId = resolveProviderAliasForMetadata(id); + overrides[canonicalId] = { + ...(overrides[canonicalId] ?? {}), + ...extractModelMetadata(metadata), + }; + } + + return models.map((model) => { + const canonicalId = resolveProviderAliasForMetadata(model.id); + const metadata = overrides[canonicalId]; + if (!metadata) return model; + + return { + ...model, + ...metadata, + }; + }); +} + +function metadataBlockMatches(match: unknown, modelId: string, canonicalId: string): boolean { + if (typeof match === 'string') { + const canonicalMatch = resolveProviderAliasForMetadata(match); + return ( + match === modelId || + match === canonicalId || + canonicalMatch === modelId || + canonicalMatch === canonicalId + ); + } + + return metadataMatcherMatches(match, modelId) || metadataMatcherMatches(match, canonicalId); +} + +function metadataMatcherMatches(match: unknown, modelId: string): boolean { + const regexp = coerceRegExp(match); + if (!regexp) return false; + regexp.lastIndex = 0; + return regexp.test(modelId); +} + +const MODEL_METADATA_KEYS = [ + 'name', + 'description', + 'contextWindow', + 'maxTokens', + 'supportsStreaming', + 'supportsVision', + 'supportsTools', + 'supportsTemperature', + 'supportsReasoning', + 'supportsAttachment', + 'pricing', +] as const satisfies readonly (keyof OmniRouteModelMetadata)[]; + +function extractModelMetadata(value: OmniRouteModelMetadata): OmniRouteModelMetadata { + return Object.fromEntries( + MODEL_METADATA_KEYS + .filter((key) => Object.prototype.hasOwnProperty.call(value, key)) + .map((key) => [key, value[key]]), + ) as OmniRouteModelMetadata; +} + function isRegExp(value: unknown): value is RegExp { return Object.prototype.toString.call(value) === '[object RegExp]'; } diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index a15190a..908c797 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -10,14 +10,24 @@ import { clearModelsDevCache } from '../dist/src/models-dev.js'; const ORIGINAL_FETCH = global.fetch; const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_XDG_DATA_HOME = process.env.XDG_DATA_HOME; afterEach(() => { global.fetch = ORIGINAL_FETCH; - process.env.HOME = ORIGINAL_HOME; + restoreEnv('HOME', ORIGINAL_HOME); + restoreEnv('XDG_DATA_HOME', ORIGINAL_XDG_DATA_HOME); clearModelCache(); clearModelsDevCache(); }); +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + function getDummyBaseUrl(port = 20128) { return `http://localhost:${port}/v1`; } @@ -34,6 +44,16 @@ function createModelsResponse() { }; } +async function createTempAuthHome(auth = { omniroute: { type: 'api', key: 'test-key' } }) { + const tempHome = join(tmpdir(), `opencode-test-${Date.now()}-${Math.random()}`); + const dataHome = join(tempHome, '.local', 'share'); + await mkdir(join(dataHome, 'opencode'), { recursive: true }); + await writeFile(join(dataHome, 'opencode', 'auth.json'), JSON.stringify(auth)); + process.env.HOME = tempHome; + process.env.XDG_DATA_HOME = dataHome; + return tempHome; +} + test('config hook applies defaults and normalized apiMode', async () => { const plugin = await OmniRouteAuthPlugin({}); const config = { @@ -110,6 +130,45 @@ test('loader injects auth headers only for OmniRoute URLs', async () => { assert.equal(externalHeaders.get('Authorization'), null); }); +test('auth loader applies user modelMetadata override to provider models', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: 1050000 }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const provider = { + options: { + baseURL: getDummyBaseUrl(20133), + apiMode: 'chat', + modelMetadata: { + 'codex/gpt-5.5': { + contextWindow: 512000, + }, + }, + }, + models: {}, + }; + + await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + + assert.equal(provider.models['codex/gpt-5.5'].limit.context, 512000); +}); + test('gemini tool schema payload is sanitized before forwarding', async () => { const plugin = await OmniRouteAuthPlugin({}); let forwardedBody; @@ -331,6 +390,420 @@ test('provider hook fetches models when auth is available via context', async () assert.equal(result['live-model'].providerID, 'omniroute'); }); +test('provider hook applies modelMetadata overrides before converting models', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: 1050000 }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { + baseURL: getDummyBaseUrl(20132), + apiMode: 'chat', + modelMetadata: { + 'codex/gpt-5.5': { + contextWindow: 512000, + }, + }, + }, + models: {}, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.equal(result['codex/gpt-5.5'].limit.context, 512000); +}); + +test('provider hook applies array literal alias block to canonical fetched model', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: 1050000 }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { + baseURL: getDummyBaseUrl(20137), + apiMode: 'chat', + modelMetadata: [{ match: 'cx/gpt-5.5', contextWindow: 512000 }], + }, + models: {}, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.equal(result['codex/gpt-5.5'].limit.context, 512000); +}); + +test('provider hook treats string metadata match as a literal model id', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', contextWindow: 8192 }, + { id: 'gpt-4x1-mini', name: 'GPT-4x1 Mini', contextWindow: 4096 }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { + baseURL: getDummyBaseUrl(20142), + apiMode: 'chat', + modelMetadata: [{ match: 'gpt-4.1-mini', contextWindow: 12345 }], + }, + models: {}, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.equal(result['gpt-4.1-mini'].limit.context, 12345); + assert.equal(result['gpt-4x1-mini'].limit.context, 4096); +}); + +test('provider hook addIfMissing array block creates canonical missing model', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'other-model', name: 'Other Model', contextWindow: 4096 }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { + baseURL: getDummyBaseUrl(20138), + apiMode: 'chat', + modelMetadata: [ + { + match: 'cx/gpt-5.5', + addIfMissing: true, + name: 'GPT-5.5 Virtual', + contextWindow: 512000, + }, + ], + }, + models: {}, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.equal(result['codex/gpt-5.5'].name, 'GPT-5.5 Virtual'); + assert.equal(result['codex/gpt-5.5'].limit.context, 512000); + assert.equal(result['cx/gpt-5.5'], undefined); +}); + +test('provider hook ignores generated modelMetadata from config hook', async () => { + const tempHome = await createTempAuthHome(); + try { + + let modelContextWindow = 1050000; + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: modelContextWindow }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + options: { + baseURL: getDummyBaseUrl(20134), + apiMode: 'chat', + }, + }, + }, + }; + + await plugin.config(config); + assert.equal( + config.provider.omniroute.options.modelMetadata['codex/gpt-5.5'].contextWindow, + 1050000, + ); + + modelContextWindow = 512000; + const clonedOptions = JSON.parse(JSON.stringify(config.provider.omniroute.options)); + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: clonedOptions, + models: config.provider.omniroute.models, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.equal(result['codex/gpt-5.5'].limit.context, 512000); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +}); + +test('provider hook uses raw user modelMetadata after config hook generated metadata', async () => { + const tempHome = await createTempAuthHome(); + try { + + let modelContextWindow = 1050000; + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: modelContextWindow }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + options: { + baseURL: getDummyBaseUrl(20135), + apiMode: 'chat', + modelMetadata: { + 'codex/gpt-5.5': { + contextWindow: 258000, + }, + }, + }, + }, + }, + }; + + await plugin.config(config); + assert.equal( + config.provider.omniroute.options.modelMetadata['codex/gpt-5.5'].contextWindow, + 258000, + ); + + config.provider.omniroute.options.modelMetadata['codex/gpt-5.5'].contextWindow = 999000; + modelContextWindow = 512000; + + const clonedOptions = JSON.parse(JSON.stringify(config.provider.omniroute.options)); + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: clonedOptions, + models: config.provider.omniroute.models, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.equal(result['codex/gpt-5.5'].limit.context, 258000); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +}); + +test('provider hook uses RegExp raw modelMetadata after config hook JSON clone', async () => { + const tempHome = await createTempAuthHome(); + try { + + let modelContextWindow = 1050000; + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: modelContextWindow }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + options: { + baseURL: getDummyBaseUrl(20139), + apiMode: 'chat', + modelMetadata: [{ match: /gpt-5\.5$/, contextWindow: 258000 }], + }, + }, + }, + }; + + await plugin.config(config); + modelContextWindow = 512000; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: JSON.parse(JSON.stringify(config.provider.omniroute.options)), + models: config.provider.omniroute.models, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.equal(result['codex/gpt-5.5'].limit.context, 258000); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +}); + +test('auth loader uses raw user modelMetadata after config hook generated metadata', async () => { + const tempHome = await createTempAuthHome(); + try { + + let modelContextWindow = 1050000; + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: modelContextWindow }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + options: { + baseURL: getDummyBaseUrl(20136), + apiMode: 'chat', + modelMetadata: { + 'codex/gpt-5.5': { + contextWindow: 258000, + }, + }, + }, + }, + }, + }; + + await plugin.config(config); + assert.equal(config.provider.omniroute.models['codex/gpt-5.5'].limit.context, 258000); + + config.provider.omniroute.options.modelMetadata['codex/gpt-5.5'].contextWindow = 999000; + modelContextWindow = 512000; + + config.provider.omniroute.options = JSON.parse(JSON.stringify(config.provider.omniroute.options)); + + await plugin.auth.loader( + async () => ({ type: 'api', key: 'live-key' }), + config.provider.omniroute, + ); + + assert.equal(config.provider.omniroute.models['codex/gpt-5.5'].limit.context, 258000); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +}); + test('provider hook ignores stale provider.models and returns defaults when no auth available', async () => { const plugin = await OmniRouteAuthPlugin({}); @@ -393,16 +866,8 @@ test('provider hook returns defaults when fetch fails (fetchModels handles error }); test('config hook eagerly fetches models when auth is available', async () => { - const tempHome = join(tmpdir(), `opencode-test-${Date.now()}`); + const tempHome = await createTempAuthHome(); try { - await mkdir(join(tempHome, '.local', 'share', 'opencode'), { recursive: true }); - await writeFile( - join(tempHome, '.local', 'share', 'opencode', 'auth.json'), - JSON.stringify({ - omniroute: { type: 'api', key: 'test-key' }, - }), - ); - process.env.HOME = tempHome; global.fetch = async (input) => { const url = input instanceof Request ? input.url : String(input); @@ -442,15 +907,163 @@ test('config hook eagerly fetches models when auth is available', async () => { } }); +test('config hook refreshes plugin-generated models on second run', async () => { + const tempHome = await createTempAuthHome(); + try { + + let modelContextWindow = 1050000; + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'cx/gpt-5.5', name: 'GPT-5.5', contextWindow: modelContextWindow }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + options: { + baseURL: getDummyBaseUrl(20140), + apiMode: 'chat', + modelCacheTtl: 1, + }, + }, + }, + }; + + await plugin.config(config); + assert.equal(config.provider.omniroute.models['codex/gpt-5.5'].limit.context, 1050000); + + modelContextWindow = 512000; + await new Promise((resolve) => setTimeout(resolve, 5)); + config.provider.omniroute.options = JSON.parse(JSON.stringify(config.provider.omniroute.options)); + await plugin.config(config); + + assert.equal(config.provider.omniroute.models['codex/gpt-5.5'].limit.context, 512000); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +}); + +test('config hook refreshes legacy generated provider models without marker', async () => { + const tempHome = await createTempAuthHome(); + try { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'fresh-model', name: 'Fresh Model', contextWindow: 512000 }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + api: 'chat', + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: getDummyBaseUrl(20143), + apiMode: 'chat', + }, + models: { + 'stale-model': { + id: 'stale-model', + name: 'Stale Model', + providerID: 'omniroute', + api: { + id: 'stale-model', + url: getDummyBaseUrl(20143), + npm: '@ai-sdk/openai-compatible', + }, + }, + }, + }, + }, + }; + + await plugin.config(config); + + assert.equal(config.provider.omniroute.models['stale-model'], undefined); + assert.ok(config.provider.omniroute.models['fresh-model']); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +}); + +test('config hook preserves explicit user provider models', async () => { + const tempHome = await createTempAuthHome(); + try { + + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'fetched-model', name: 'Fetched Model', contextWindow: 512000 }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const explicitModel = { + id: 'explicit-model', + name: 'Explicit Model', + providerID: 'omniroute', + }; + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + options: { + baseURL: getDummyBaseUrl(20141), + apiMode: 'chat', + }, + models: { + 'explicit-model': explicitModel, + }, + }, + }, + }; + + await plugin.config(config); + + assert.equal(config.provider.omniroute.models['explicit-model'], explicitModel); + assert.equal(config.provider.omniroute.models['fetched-model'], undefined); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +}); + test('config hook preserves user modelMetadata object overrides', async () => { - const tempHome = join(tmpdir(), `opencode-test-${Date.now()}`); + const tempHome = await createTempAuthHome(); try { - await mkdir(join(tempHome, '.local', 'share', 'opencode'), { recursive: true }); - await writeFile( - join(tempHome, '.local', 'share', 'opencode', 'auth.json'), - JSON.stringify({ omniroute: { type: 'api', key: 'test-key' } }), - ); - process.env.HOME = tempHome; global.fetch = async (input) => { const url = input instanceof Request ? input.url : String(input); @@ -498,20 +1111,17 @@ test('config hook preserves user modelMetadata object overrides', async () => { const metadata = config.provider.omniroute.options.modelMetadata['codex/gpt-5.5']; assert.equal(metadata.contextWindow, 258000); assert.equal(metadata.supportsReasoning, true); + const model = config.provider.omniroute.models['codex/gpt-5.5']; + assert.equal(model.limit.context, 258000); + assert.equal(model.reasoning, true); } finally { await rm(tempHome, { recursive: true, force: true }); } }); test('config hook preserves user modelMetadata match blocks', async () => { - const tempHome = join(tmpdir(), `opencode-test-${Date.now()}`); + const tempHome = await createTempAuthHome(); try { - await mkdir(join(tempHome, '.local', 'share', 'opencode'), { recursive: true }); - await writeFile( - join(tempHome, '.local', 'share', 'opencode', 'auth.json'), - JSON.stringify({ omniroute: { type: 'api', key: 'test-key' } }), - ); - process.env.HOME = tempHome; global.fetch = async (input) => { const url = input instanceof Request ? input.url : String(input); @@ -531,7 +1141,7 @@ test('config hook preserves user modelMetadata match blocks', async () => { }; const userBlock = { - match: '^(codex|cx)/.*gpt-5', + match: /^(codex|cx)\/.*gpt-5/, contextWindow: 258000, }; const plugin = await OmniRouteAuthPlugin({}); @@ -551,24 +1161,20 @@ test('config hook preserves user modelMetadata match blocks', async () => { const metadata = config.provider.omniroute.options.modelMetadata; assert.ok(Array.isArray(metadata)); // User config comes first in first-match-wins systems - assert.deepEqual(metadata[0], userBlock); + assert.equal(metadata[0].match, userBlock.match); + assert.equal(metadata[0].contextWindow, 258000); // Generated metadata follows user config assert.equal(metadata[1].match, 'codex/gpt-5.5'); assert.equal(metadata[1].contextWindow, 1050000); + assert.equal(config.provider.omniroute.models['codex/gpt-5.5'].limit.context, 258000); } finally { await rm(tempHome, { recursive: true, force: true }); } }); test('config hook respects explicit attachment false for vision models', async () => { - const tempHome = join(tmpdir(), `opencode-test-${Date.now()}`); + const tempHome = await createTempAuthHome(); try { - await mkdir(join(tempHome, '.local', 'share', 'opencode'), { recursive: true }); - await writeFile( - join(tempHome, '.local', 'share', 'opencode', 'auth.json'), - JSON.stringify({ omniroute: { type: 'api', key: 'test-key' } }), - ); - process.env.HOME = tempHome; global.fetch = async (input) => { const url = input instanceof Request ? input.url : String(input);