diff --git a/CHANGELOG.md b/CHANGELOG.md index ab17057..0ccd634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project are documented in this file. +## [1.1.4] - 2026-05-15 + +### Added + +- Added `provider` hook for OpenCode >=1.14.49 model listing API (`ProviderHook`), enabling dynamic model fetching when authentication is available. (@kkMihai) +- Added `ProviderHook`, `ProviderHookContext`, `ProviderV2`, and `ModelV2` type definitions matching the `@opencode-ai/plugin` v1.14.50 API. (@kkMihai) +- Added 3 regression tests covering provider hook behavior: live model fetch with auth, fallback without auth, and error recovery. (@kkMihai) +- Added `createMockFetch()` test helper to isolate `/v1/models` call counting from enrichment endpoint mocks. (@kkMihai) + +### Fixed + +- Fixed model picker only showing 4 hardcoded fallback models on OpenCode >=1.14.49 by supplying models through the new `provider` hook `models` callback. (@kkMihai) +- Fixed stale `provider.models` fallback in no-auth branch of provider hook to always return plugin-native defaults with correct `api.url` and `providerID` metadata. (@Alph4d0g) +- Fixed misleading comment in fetch-failure recovery test to accurately describe the code path. (@Alph4d0g) + +### Changed + +- Improved test coverage for unauthenticated state to explicitly verify that stale host-provided models are ignored. (@Alph4d0g) +- Improved formatting consistency in type definitions. (@Alph4d0g) + ## [1.1.3] - 2026-05-15 ### Added diff --git a/package.json b/package.json index 525bd95..9946edf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-omniroute-auth", - "version": "1.1.3", + "version": "1.1.4", "description": "OpenCode authentication plugin for OmniRoute API with /connect command and dynamic model fetching", "type": "module", "main": "./dist/index.js", diff --git a/src/plugin.ts b/src/plugin.ts index e4d98b3..e695e61 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -36,6 +36,8 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { const apiMode = getApiMode(existingProvider?.options); const providerApi = resolveProviderApi(existingProvider?.api, apiMode); + // Eagerly fetch models for OpenCode <=1.14.48 (which read models from config hook). + // OpenCode >=1.14.49 uses the provider hook below instead. let models: OmniRouteModel[] = OMNIROUTE_DEFAULT_MODELS; try { const auth = await readAuthFromStore(OMNIROUTE_PROVIDER_ID); @@ -66,6 +68,24 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { config.provider = providers; }, + // Provider hook for OpenCode >=1.14.49 + provider: { + id: OMNIROUTE_PROVIDER_ID, + models: async (provider, ctx) => { + const baseUrl = getBaseUrl(provider.options); + + // Auth available — fetch /v1/models (fetchModels falls back to defaults on error) + 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); + } + + // 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); + }, + }, auth: createAuthHook(), }; }; diff --git a/src/types/opencode-plugin.d.ts b/src/types/opencode-plugin.d.ts index 5805506..872d24b 100644 --- a/src/types/opencode-plugin.d.ts +++ b/src/types/opencode-plugin.d.ts @@ -93,9 +93,57 @@ declare module '@opencode-ai/plugin' { methods: AuthMethod[]; } + // ProviderHook types (OpenCode >=1.14.49) + export interface ProviderHookContext { + auth?: Auth; + } + + export interface ModelV2 { + id: string; + providerID: string; + family: string; + release_date: string; + api: { id: string; url: string; npm: string }; + name: string; + capabilities: { + temperature: boolean; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; + input: { text: boolean; audio: boolean; image: boolean; video: boolean; pdf: boolean }; + output: { text: boolean; audio: boolean; image: boolean; video: boolean; pdf: boolean }; + interleaved: boolean; + }; + cost: { input: number; output: number; cache: { read: number; write: number } }; + limit: { context: number; output: number }; + status: 'active'; + options: Record; + headers: Record; + variants: Record; + } + + export interface ProviderV2 { + id: string; + name: string; + source: string; + env: string[]; + key?: string; + options: Record; + models: Record; + } + + export interface ProviderHook { + id: string; + models?: ( + provider: ProviderV2, + ctx: ProviderHookContext, + ) => Promise>; + } + export interface Hooks { config?: (input: Config) => Promise; auth?: AuthHook; + provider?: ProviderHook; [key: string]: unknown; } diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 9b6ce07..7c20a9e 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -286,6 +286,104 @@ test('gemini schema sanitization applies to responses endpoint request objects', assert.equal(forwardedBody.tools[0].input_schema.properties.query.items.additionalProperties, undefined); }); +test('provider hook fetches models when auth is available via context', 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: 'live-model', name: 'Live Model' }], + }), + { 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: 'http://localhost:20128/v1', apiMode: 'chat' }, + models: {}, + }, + { auth: { type: 'api', key: 'live-key' } }, + ); + + assert.ok(result['live-model']); + assert.equal(result['live-model'].name, 'Live Model'); + assert.equal(result['live-model'].providerID, 'omniroute'); +}); + +test('provider hook ignores stale provider.models and returns defaults when no auth available', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async () => { + throw new Error('should not fetch'); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + models: { + 'stale-model': { + id: 'stale-model', + name: 'Stale', + providerID: 'wrong-provider', + api: { id: 'stale-model', url: 'http://wrong-url', npm: 'wrong-npm' }, + }, + }, + }, + {}, // no auth + ); + + // Should return default models (gpt-4o, gpt-4o-mini, etc.), NOT stale provider.models + assert.ok(result['gpt-4o']); + assert.equal(result['gpt-4o'].providerID, 'omniroute'); + assert.equal(result['gpt-4o'].api.url, 'http://localhost:20128/v1'); + // Stale model must NOT be present + assert.equal(result['stale-model'], undefined); +}); + +test('provider hook returns defaults when fetch fails (fetchModels handles errors)', async () => { + const plugin = await OmniRouteAuthPlugin({}); + + global.fetch = async () => { + throw new Error('API unavailable'); + }; + + const result = await plugin.provider.models( + { + id: 'omniroute', + name: 'OmniRoute', + source: 'config', + env: [], + options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + models: { 'existing-model': { id: 'existing-model', name: 'Existing', providerID: 'omniroute' } }, + }, + { auth: { type: 'api', key: 'bad-key' } }, + ); + + // fetchModels catches errors and returns defaults, so we get default models + assert.ok(result['gpt-4o']); + assert.equal(result['gpt-4o'].providerID, 'omniroute'); + // When auth is present but fetch fails, fetchModels catches the error and + // returns default models. The provider.models fallback is NOT used. + assert.equal(result['existing-model'], undefined); +}); + test('config hook eagerly fetches models when auth is available', async () => { const tempHome = join(tmpdir(), `opencode-test-${Date.now()}`); try {