diff --git a/src/plugin.ts b/src/plugin.ts index 2183fbc..4071315 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -24,7 +24,8 @@ import { warn, debug } from './logger.js'; import { sanitizeForLog } from './omniroute-combos.js'; const OMNIROUTE_PROVIDER_NAME = 'OmniRoute'; -const OMNIROUTE_PROVIDER_NPM = '@ai-sdk/openai-compatible'; +const OMNIROUTE_CHAT_PROVIDER_NPM = '@ai-sdk/openai-compatible'; +const OMNIROUTE_RESPONSES_PROVIDER_NPM = '@ai-sdk/openai'; const OMNIROUTE_PROVIDER_ENV = ['OMNIROUTE_API_KEY']; type AuthHook = NonNullable; @@ -48,6 +49,7 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { const baseUrl = getBaseUrl(existingProvider?.options); const apiMode = getApiMode(existingProvider?.options); const providerApi = resolveProviderApi(existingProvider?.api, apiMode); + const providerNpm = resolveProviderNpm(existingProvider?.npm, apiMode); const rawUserModelMetadata = getRawUserModelMetadata(existingProvider?.options); // Eagerly fetch models for OpenCode <=1.14.48 (which read models from config hook). @@ -101,15 +103,15 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { const shouldRefreshModels = shouldRefreshProviderModels(existingProvider); const providerModels = shouldRefreshModels - ? toProviderModels(effectiveModels, baseUrl) - : existingProvider?.models; + ? toProviderModels(effectiveModels, baseUrl, providerNpm) + : reconcileExplicitModelsNpm(existingProvider?.models, providerNpm); setModelsGeneratedByPlugin(providerOptions, shouldRefreshModels); providers[OMNIROUTE_PROVIDER_ID] = { ...existingProvider, name: existingProvider?.name ?? OMNIROUTE_PROVIDER_NAME, api: providerApi, - npm: existingProvider?.npm ?? OMNIROUTE_PROVIDER_NPM, + npm: providerNpm, env: existingProvider?.env ?? OMNIROUTE_PROVIDER_ENV, options: providerOptions, models: providerModels, @@ -121,7 +123,11 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { provider: { id: OMNIROUTE_PROVIDER_ID, models: async (provider, ctx) => { - const baseUrl = getBaseUrl(provider.options); + const baseUrl = getBaseUrl(provider.options); + const providerNpm = resolveProviderNpm( + isRecord(provider) ? provider.npm : undefined, + isRecord(provider) ? getApiMode(provider.options) : 'chat', + ); // Auth available — fetch /v1/models (fetchModels falls back to defaults on error) if (ctx.auth?.type === 'api' && ctx.auth.key) { @@ -131,7 +137,7 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { models, getRawUserModelMetadata(provider.options), ); - return toProviderModels(effectiveModels, baseUrl); + return toProviderModels(effectiveModels, baseUrl, providerNpm); } // No auth yet (user hasn't /connect'd): return built-in defaults. @@ -140,7 +146,7 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { OMNIROUTE_DEFAULT_MODELS, getRawUserModelMetadata(provider.options), ); - return toProviderModels(effectiveModels, baseUrl); + return toProviderModels(effectiveModels, baseUrl, providerNpm); }, }, auth: createAuthHook(), @@ -187,7 +193,11 @@ async function loadProviderOptions( models, getRawUserModelMetadata(provider.options), ); - replaceProviderModels(provider, toProviderModels(effectiveModels, config.baseUrl)); + const providerNpm = resolveProviderNpm(provider.npm, config.apiMode); + replaceProviderModels( + provider, + toProviderModels(effectiveModels, config.baseUrl, providerNpm), + ); if (isRecord(provider.models)) { debug(`Provider models hydrated: ${Object.keys(provider.models).length}`); } @@ -256,6 +266,36 @@ function resolveProviderApi(api: unknown, apiMode: OmniRouteApiMode): OmniRouteA return apiMode; } +function resolveProviderNpm(npm: unknown, apiMode: OmniRouteApiMode): string { + const expected = getProviderNpm(apiMode); + if (typeof npm !== 'string' || !npm.trim()) { + return expected; + } + + const current = npm.trim(); + if (!isOmniRouteProviderNpm(current)) { + return current; + } + + if (current !== expected) { + warn( + `provider.npm (${sanitizeForLog(current)}) and options.apiMode (${sanitizeForLog(apiMode)}) ` + + `differ; using ${sanitizeForLog(expected)}.`, + ); + } + return expected; +} + +function getProviderNpm(apiMode: OmniRouteApiMode): string { + return apiMode === 'responses' + ? OMNIROUTE_RESPONSES_PROVIDER_NPM + : OMNIROUTE_CHAT_PROVIDER_NPM; +} + +function isOmniRouteProviderNpm(value: string): boolean { + return value === OMNIROUTE_CHAT_PROVIDER_NPM || value === OMNIROUTE_RESPONSES_PROVIDER_NPM; +} + function getApiMode(options?: Record): OmniRouteApiMode { const value = options?.apiMode; if (value === undefined) { @@ -459,7 +499,35 @@ 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; + return typeof value.api.npm === 'string' && isOmniRouteProviderNpm(value.api.npm); +} + +function reconcileExplicitModelsNpm( + models: Record | undefined, + providerNpm: string, +): Record | undefined { + if (!isRecord(models)) return models; + let changed = false; + const next: Record = {}; + for (const [id, model] of Object.entries(models)) { + if (!isRecord(model) || !isRecord(model.api)) { + next[id] = model; + continue; + } + if (model.api.npm === providerNpm) { + next[id] = model; + continue; + } + changed = true; + next[id] = { + ...model, + api: { + ...model.api, + npm: providerNpm, + }, + }; + } + return changed ? next : models; } function getStringRecord(value: unknown): Record | undefined { @@ -767,15 +835,20 @@ function isValidModelMetadata(value: unknown): { valid: boolean; field?: string function toProviderModels( models: OmniRouteModel[], baseUrl: string, + providerNpm: string, ): Record { const entries: Array<[string, OmniRouteProviderModel]> = models.map((model) => [ model.id, - toProviderModel(model, baseUrl), + toProviderModel(model, baseUrl, providerNpm), ]); return Object.fromEntries(entries); } -function toProviderModel(model: OmniRouteModel, baseUrl: string): OmniRouteProviderModel { +function toProviderModel( + model: OmniRouteModel, + baseUrl: string, + providerNpm: string, +): OmniRouteProviderModel { const supportsVision = model.supportsVision === true; // Default to true: if API doesn't explicitly say no tools, assume capability exists // This aligns with OpenAI-compatible behavior where most models support tools @@ -801,7 +874,7 @@ function toProviderModel(model: OmniRouteModel, baseUrl: string): OmniRouteProvi api: { id: model.id, url: baseUrl, - npm: OMNIROUTE_PROVIDER_NPM, + npm: providerNpm, }, capabilities: { temperature: supportsTemperature, diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index ba40ace..37804ce 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -1,9 +1,20 @@ +import { mkdirSync, writeFileSync, utimesSync } from 'fs'; import { afterEach, test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdir, writeFile, rm } from 'fs/promises'; +import { mkdir, writeFile, readFile, rm } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; +// Isolate logger output for plugin tests so warning assertions can read the log file. +const PLUGIN_TEST_DATA_HOME = join(tmpdir(), `opencode-plugin-tests-${Date.now()}`); +process.env.XDG_DATA_HOME = PLUGIN_TEST_DATA_HOME; +const PLUGIN_LOG_DIR = join(PLUGIN_TEST_DATA_HOME, 'opencode', 'log'); +const PLUGIN_LOG_FILE = join(PLUGIN_LOG_DIR, 'omniroute.log'); +mkdirSync(PLUGIN_LOG_DIR, { recursive: true }); +writeFileSync(PLUGIN_LOG_FILE, ''); +// Ensure this file wins mtime races against any previously-created test logs. +utimesSync(PLUGIN_LOG_FILE, Date.now() / 1000, (Date.now() / 1000) + 1000); + import OmniRouteAuthPlugin from '../dist/index.js'; import { clearModelCache } from '../dist/runtime.js'; import { clearModelsDevCache } from '../dist/src/models-dev.js'; @@ -56,6 +67,7 @@ async function createTempAuthHome(auth = { omniroute: { type: 'api', key: 'test- test('config hook applies defaults and normalized apiMode', async () => { const plugin = await OmniRouteAuthPlugin({}); + process.env.XDG_DATA_HOME = join(tmpdir(), `opencode-test-no-auth-${Date.now()}`); const config = { provider: { omniroute: { @@ -74,6 +86,172 @@ test('config hook applies defaults and normalized apiMode', async () => { assert.equal(config.provider.omniroute.options.baseURL, 'http://localhost:20128/v1'); }); +test('config hook selects provider package for chat apiMode', async () => { + const plugin = await OmniRouteAuthPlugin({}); + process.env.XDG_DATA_HOME = join(tmpdir(), `opencode-test-no-auth-${Date.now()}`); + const config = { + provider: { + omniroute: { + options: { + baseURL: getDummyBaseUrl(), + apiMode: 'chat', + }, + }, + }, + }; + + await plugin.config(config); + + assert.equal(config.provider.omniroute.api, 'chat'); + assert.equal(config.provider.omniroute.npm, '@ai-sdk/openai-compatible'); + assert.equal(config.provider.omniroute.models['gpt-4o'].api.npm, '@ai-sdk/openai-compatible'); +}); + +test('config hook selects provider package for responses apiMode', async () => { + const plugin = await OmniRouteAuthPlugin({}); + process.env.XDG_DATA_HOME = join(tmpdir(), `opencode-test-no-auth-${Date.now()}`); + const config = { + provider: { + omniroute: { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: getDummyBaseUrl(), + apiMode: 'responses', + }, + }, + }, + }; + + await plugin.config(config); + + assert.equal(config.provider.omniroute.api, 'responses'); + assert.equal(config.provider.omniroute.npm, '@ai-sdk/openai'); + assert.equal(config.provider.omniroute.models['gpt-4o'].api.npm, '@ai-sdk/openai'); +}); + +test('provider hook selects model package for responses apiMode', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + npm: '@ai-sdk/openai-compatible', + options: { baseURL: getDummyBaseUrl(), apiMode: 'responses' }, + models: {}, + }, + { auth: undefined }, + ); + + assert.equal(result['gpt-4o'].api.npm, '@ai-sdk/openai'); +}); + +test('provider hook preserves custom provider package', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + npm: 'custom-ai-sdk-provider', + options: { baseURL: getDummyBaseUrl(), apiMode: 'responses' }, + models: {}, + }, + { auth: undefined }, + ); + + assert.equal(result['gpt-4o'].api.npm, 'custom-ai-sdk-provider'); +}); + +test('config hook reconciles explicit model npm when provider package changes', async () => { + const plugin = await OmniRouteAuthPlugin({}); + process.env.XDG_DATA_HOME = join(tmpdir(), `opencode-test-no-auth-${Date.now()}`); + const config = { + provider: { + omniroute: { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: getDummyBaseUrl(), + apiMode: 'responses', + }, + models: { + 'gpt-4o': { + id: 'gpt-4o', + name: 'GPT-4o', + providerID: 'omniroute', + api: { id: 'gpt-4o', url: getDummyBaseUrl(), npm: '@ai-sdk/openai-compatible' }, + }, + }, + }, + }, + }; + + await plugin.config(config); + + assert.equal(config.provider.omniroute.npm, '@ai-sdk/openai'); + assert.equal(config.provider.omniroute.models['gpt-4o'].api.npm, '@ai-sdk/openai'); +}); + +test('auth loader selects provider package for responses apiMode', 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(createModelsResponse()), { + 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(), apiMode: 'responses' }, + models: {}, + }; + + await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); + + assert.equal(provider.models['gpt-4.1-mini'].api.npm, '@ai-sdk/openai'); +}); + +test('config hook refreshes legacy-generated models with responses npm', async () => { + const plugin = await OmniRouteAuthPlugin({}); + process.env.XDG_DATA_HOME = join(tmpdir(), `opencode-test-no-auth-${Date.now()}`); + const config = { + provider: { + omniroute: { + api: 'responses', + npm: '@ai-sdk/openai', + options: { + baseURL: getDummyBaseUrl(), + apiMode: 'responses', + }, + models: { + 'gpt-4o': { + id: 'gpt-4o', + name: 'GPT-4o', + providerID: 'omniroute', + api: { id: 'gpt-4o', url: getDummyBaseUrl(), npm: '@ai-sdk/openai' }, + }, + }, + }, + }, + }; + + await plugin.config(config); + + assert.equal(config.provider.omniroute.models['gpt-4o'].api.npm, '@ai-sdk/openai'); +}); + test('loader injects auth headers only for OmniRoute URLs', async () => { const plugin = await OmniRouteAuthPlugin({}); const calls = []; diff --git a/test/provider-npm-warning.test.mjs b/test/provider-npm-warning.test.mjs new file mode 100644 index 0000000..ecac1ff --- /dev/null +++ b/test/provider-npm-warning.test.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync, utimesSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +function getDummyBaseUrl(port = 20128) { + return `http://localhost:${port}/v1`; +} + +test('config hook warns when provider npm conflicts with apiMode', async () => { + const dataHome = join(tmpdir(), `opencode-npm-warn-${Date.now()}`); + process.env.XDG_DATA_HOME = dataHome; + const logDir = join(dataHome, 'opencode', 'log'); + const logFile = join(logDir, 'omniroute.log'); + mkdirSync(logDir, { recursive: true }); + writeFileSync(logFile, ''); + // Ensure this file is selected by the logger's mtime-based picker. + utimesSync(logFile, Date.now() / 1000, (Date.now() / 1000) + 1000); + + const { default: OmniRouteAuthPlugin } = await import('../dist/index.js'); + const plugin = await OmniRouteAuthPlugin({}); + const config = { + provider: { + omniroute: { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: getDummyBaseUrl(), + apiMode: 'responses', + }, + }, + }, + }; + + await plugin.config(config); + + await new Promise((resolve) => setTimeout(resolve, 50)); + const logContent = readFileSync(logFile, 'utf-8'); + assert.ok( + logContent.includes('provider.npm') && logContent.includes('@ai-sdk/openai-compatible'), + 'expected warning about npm/apiMode conflict in log file', + ); +});