Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 85 additions & 12 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']>;
Expand All @@ -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);

Copy link
Copy Markdown
Contributor

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

const rawUserModelMetadata = getRawUserModelMetadata(existingProvider?.options);

// Eagerly fetch models for OpenCode <=1.14.48 (which read models from config hook).
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic: when shouldRefreshModels is false (user-provided explicit models), provider.npm is updated but the preserved model objects may still carry a stale api.npm. This creates inconsistency if the user changes apiMode while keeping explicit models. Please reconcile explicit model api.npm values with the resolved providerNpm or document that explicit models are user-owned.


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,
Expand All @@ -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) {
Expand All @@ -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.
Expand All @@ -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(),
Expand Down Expand Up @@ -187,7 +193,11 @@ async function loadProviderOptions(
models,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: the replaceProviderModels call is at/over the 100-char limit. Please format across multiple lines.

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}`);
}
Expand Down Expand Up @@ -256,6 +266,36 @@ function resolveProviderApi(api: unknown, apiMode: OmniRouteApiMode): OmniRouteA
return apiMode;
}

function resolveProviderNpm(npm: unknown, apiMode: OmniRouteApiMode): string {
const expected = getProviderNpm(apiMode);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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;
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -767,15 +835,20 @@ function isValidModelMetadata(value: unknown): { valid: boolean; field?: string
function toProviderModels(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge conflict risk: PR #34 also modifies toProviderModels / toProviderModel signatures and call sites. These PRs will conflict; please coordinate/rebase after #34 lands so the final signature combines both features (e.g. toProviderModel(model, baseUrl, providerNpm, modelNameDisplay)).

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
Expand All @@ -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,
Expand Down
180 changes: 179 additions & 1 deletion test/plugin.test.mjs
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';
Expand Down Expand Up @@ -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: {
Expand All @@ -74,6 +86,172 @@ test('config hook applies defaults and normalized apiMode', async () => {
assert.equal(config.provider.omniroute.options.baseURL, 'http://localhost:20128/v1');
});

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage gap: no test verifies that plugin.auth.loader propagates the resolved provider package into model api.npm. Please add one.

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');
});

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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 options.apiMode. Please stub the logger's warn function and assert the warning.

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');
});

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage gap: no test covers the updated isGeneratedOmniRouteProviderModel behavior with the responses npm value (@ai-sdk/openai). Please add a config-hook test with legacy-generated models whose api.npm is @ai-sdk/openai and apiMode is responses, asserting they are refreshed.

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 = [];
Expand Down
Loading