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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 20 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(),
};
};
Expand Down
48 changes: 48 additions & 0 deletions src/types/opencode-plugin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
Alph4d0g marked this conversation as resolved.
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 };
Comment thread
Alph4d0g marked this conversation as resolved.
interleaved: boolean;
};
cost: { input: number; output: number; cache: { read: number; write: number } };
limit: { context: number; output: number };
status: 'active';
options: Record<string, unknown>;
headers: Record<string, string>;
Comment thread
Alph4d0g marked this conversation as resolved.
variants: Record<string, unknown>;
}

export interface ProviderV2 {
id: string;
name: string;
source: string;
env: string[];
key?: string;
options: Record<string, unknown>;
models: Record<string, ModelV2>;
}

export interface ProviderHook {
id: string;
models?: (
provider: ProviderV2,
ctx: ProviderHookContext,
) => Promise<Record<string, ModelV2>>;
}

export interface Hooks {
config?: (input: Config) => Promise<void>;
auth?: AuthHook;
provider?: ProviderHook;
[key: string]: unknown;
}

Expand Down
98 changes: 98 additions & 0 deletions test/plugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading