diff --git a/.gitignore b/.gitignore index 97eea28..d5dce37 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ coverage/ tmp/ temp/ *.tmp + +# Worktrees +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebe5f4..51f0de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project are documented in this file. +## [1.1.2] - 2026-05-13 + +### Fixed + +- Fixed model picker only showing fallback models on OpenCode >=1.14.47 by eagerly fetching live models in the `config` hook before OpenCode reads `provider.models`. (@jms830) +- Refactored `createRuntimeConfig` to accept `options` directly for reuse across both `config` and `loader` hooks. + +### Added + +- Added `readAuthFromStore()` helper to read the stored OmniRoute API key from `~/.local/share/opencode/auth.json` during the `config` hook. +- Added regression test for eager model fetching in the config hook. + ## [1.1.1] - 2026-05-12 ### Fixed diff --git a/README.md b/README.md index 009030a..7266ac3 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ If models aren't loading: ### Plugin Not Loading Outside This Repo -If the plugin loads only through a local shim (for example from `.opencode/plugins` or `.opencode/plugi`) but not from npm in `opencode.json`: +If the plugin loads only through a local shim (for example from `.opencode/plugins`) but not from npm in `opencode.json`: 1. Ensure you are using `opencode-omniroute-auth@1.0.1` or newer 2. Confirm your config includes `"plugin": ["opencode-omniroute-auth"]` diff --git a/package.json b/package.json index 61c16bb..a703155 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-omniroute-auth", - "version": "1.1.1", + "version": "1.1.2", "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 22cb167..e16e483 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,4 +1,7 @@ import type { Plugin, Hooks } from '@opencode-ai/plugin'; +import { homedir } from 'os'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; import type { OmniRouteApiMode, OmniRouteConfig, @@ -32,6 +35,17 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { const apiMode = getApiMode(existingProvider?.options); const providerApi = resolveProviderApi(existingProvider?.api, apiMode); + let models: OmniRouteModel[] = OMNIROUTE_DEFAULT_MODELS; + try { + const auth = await readAuthFromStore(OMNIROUTE_PROVIDER_ID); + if (auth?.key) { + const runtimeConfig = createRuntimeConfig(existingProvider?.options ?? {}, auth.key); + models = await fetchModels(runtimeConfig, auth.key, false); + } + } catch (error) { + console.warn('[OmniRoute] Eager model fetch failed, using defaults:', error); + } + providers[OMNIROUTE_PROVIDER_ID] = { ...existingProvider, name: existingProvider?.name ?? OMNIROUTE_PROVIDER_NAME, @@ -46,7 +60,7 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { models: existingProvider?.models && Object.keys(existingProvider.models).length > 0 ? existingProvider.models - : toProviderModels(OMNIROUTE_DEFAULT_MODELS, baseUrl), + : toProviderModels(models, baseUrl), }; config.provider = providers; @@ -79,7 +93,7 @@ async function loadProviderOptions( ); } - const config = createRuntimeConfig(provider, auth.key); + const config = createRuntimeConfig(provider.options, auth.key); let models: OmniRouteModel[] = []; try { @@ -103,17 +117,20 @@ async function loadProviderOptions( }; } -function createRuntimeConfig(provider: ProviderDefinition, apiKey: string): OmniRouteConfig { - const baseUrl = getBaseUrl(provider.options); - const modelCacheTtl = getPositiveNumber(provider.options, 'modelCacheTtl'); - const refreshOnList = getBoolean(provider.options, 'refreshOnList'); - const modelsDev = getModelsDevConfig(provider.options); - const modelMetadata = getModelMetadataConfig(provider.options); +function createRuntimeConfig( + options: Record | undefined, + apiKey: string, +): OmniRouteConfig { + const baseUrl = getBaseUrl(options); + const modelCacheTtl = getPositiveNumber(options, 'modelCacheTtl'); + const refreshOnList = getBoolean(options, 'refreshOnList'); + const modelsDev = getModelsDevConfig(options); + const modelMetadata = getModelMetadataConfig(options); return { baseUrl, apiKey, - apiMode: getApiMode(provider.options), + apiMode: getApiMode(options), modelCacheTtl, refreshOnList, modelsDev, @@ -121,6 +138,27 @@ function createRuntimeConfig(provider: ProviderDefinition, apiKey: string): Omni }; } +async function readAuthFromStore( + providerId: string, +): Promise<{ key?: string; type?: string } | null> { + try { + const dataHome = process.env.XDG_DATA_HOME || join(process.env.HOME || homedir(), '.local', 'share'); + const authPath = join(dataHome, 'opencode', 'auth.json'); + const content = await readFile(authPath, 'utf-8'); + const data = JSON.parse(content); + if (!isRecord(data)) return null; + const auth = data[providerId]; + if (!isRecord(auth)) return null; + return auth as { key?: string; type?: string }; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null; + } + console.warn('[OmniRoute] Unexpected error reading auth store:', error); + return null; + } +} + function resolveProviderApi(api: unknown, apiMode: OmniRouteApiMode): OmniRouteApiMode { if (isApiMode(api)) { if (api !== apiMode) { diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 85af736..9b6ce07 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -1,12 +1,17 @@ import { afterEach, test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdir, writeFile, rm } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; import OmniRouteAuthPlugin from '../dist/index.js'; const ORIGINAL_FETCH = global.fetch; +const ORIGINAL_HOME = process.env.HOME; afterEach(() => { global.fetch = ORIGINAL_FETCH; + process.env.HOME = ORIGINAL_HOME; }); function createModelsResponse() { @@ -280,3 +285,53 @@ test('gemini schema sanitization applies to responses endpoint request objects', assert.equal(forwardedBody.tools[0].input_schema.additionalProperties, undefined); assert.equal(forwardedBody.tools[0].input_schema.properties.query.items.additionalProperties, undefined); }); + +test('config hook eagerly fetches models when auth is available', async () => { + const tempHome = join(tmpdir(), `opencode-test-${Date.now()}`); + 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); + if (url.endsWith('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: 'custom-model', name: 'Custom Model' }], + }), + { 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: 'http://localhost:20128/v1', + apiMode: 'chat', + }, + }, + }, + }; + + await plugin.config(config); + + assert.ok(config.provider.omniroute.models['custom-model']); + assert.equal(config.provider.omniroute.models['custom-model'].name, 'Custom Model'); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +});