-
Notifications
You must be signed in to change notification settings - Fork 25
fix: use openai provider for responses mode #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Hooks['auth']>; | ||
|
|
@@ -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); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logic: when |
||
|
|
||
| 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, | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style: the |
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔥 The Roast: Three layers of npm resolution - in config hook, provider hook, AND loadProviderOptions. You really like your npm resolution, don't you? 🩹 The Fix: This appears to be necessary for the different code paths. Consistency is key! 📏 Severity: nitpick |
||
| if (typeof npm !== 'string' || !npm.trim()) { | ||
| return expected; | ||
| } | ||
|
|
||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style: the warning template literal exceeds the 100-char line limit. Please split across multiple lines. |
||
| 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<string, unknown>): 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<string, unknown> | undefined, | ||
| providerNpm: string, | ||
| ): Record<string, unknown> | undefined { | ||
| if (!isRecord(models)) return models; | ||
| let changed = false; | ||
| const next: Record<string, unknown> = {}; | ||
| 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<string, string> | undefined { | ||
|
|
@@ -767,15 +835,20 @@ function isValidModelMetadata(value: unknown): { valid: boolean; field?: string | |
| function toProviderModels( | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| models: OmniRouteModel[], | ||
| baseUrl: string, | ||
| providerNpm: string, | ||
| ): Record<string, OmniRouteProviderModel> { | ||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
|
|
||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test coverage gap: no test verifies that |
||
| 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'); | ||
| }); | ||
|
|
||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test coverage gap: no test captures the warning emitted when an OmniRoute provider npm value conflicts with |
||
| 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'); | ||
| }); | ||
|
|
||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test coverage gap: no test covers the updated |
||
| 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 = []; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔥 The Roast: You've successfully avoided the classic "one NPM package to rule them all" anti-pattern by actually differentiating between chat and responses modes. Novel concept!
🩹 The Fix: Keep up the good work - this is actually correct. No fixes needed here.
📏 Severity: nitpick