Plugin providers are registered from a plugin's `config` hook and credentialed from its `auth` loader, both inside the running OpenCode process. Nothing about them reaches `opencode.json` or `auth.json`, so resolution that only reads files could not see them: selecting such a model failed with "has no known API base URL" while the same model worked in chat (#2666). `GET /provider` is where that state is visible. A new `runtime-providers` module keeps one cached snapshot of it and reports, per provider, the credential and endpoint OpenCode itself resolved. Credential resolution becomes config -> runtime -> auth.json, and endpoint resolution config -> openai default -> runtime -> models.dev catalog. Providers with a dedicated wire format (Copilot, ChatGPT-plan OpenAI, Anthropic, Google) are excluded from the runtime credential: for them OpenCode reports an OAuth access token that their real transport does not accept. opencode zen is excluded when the user has no zen login. OpenCode then reports the sentinel `apiKey: "public"` and trims its catalog to free models that run on its own infrastructure; the sentinel is never read as a credential. Claude Code stays refused for background actions even when a plugin publishes an OpenAI-compatible endpoint for it, because that endpoint is a facade over the Claude Agent SDK and spawns the CLI per request. No capability probe. Asking `GET /models` does identify a plugin whose protocol lives in its own `fetch`, but measured across the 166 providers with an `api` URL in the models.dev catalog it also denies six that work and simply have no `/models` route. A provider that vanishes from the picker explains nothing, while one that fails on use says why, so availability stops at credential and endpoint. The same list drives the Small Model and Changes Walkthrough pickers. Validated against a real OpenCode with four plugin providers loaded: offered providers went from 3 to 7, zen and Claude Code stayed out, and a generation through a plugin-backed model that previously failed now returns.
113 lines
4.0 KiB
JavaScript
113 lines
4.0 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
ZEN_ANONYMOUS_API_KEY,
|
|
configureOpenCodeRuntimeProviders,
|
|
getRuntimeProvider,
|
|
getRuntimeProviderSnapshot,
|
|
resetOpenCodeRuntimeProviders,
|
|
} from './runtime-providers.js';
|
|
|
|
const providerPayload = (overrides = {}) => ({
|
|
all: [
|
|
{
|
|
id: 'llmapi',
|
|
source: 'config',
|
|
options: { apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1/' },
|
|
models: { 'claude-opus-4-8': { api: { id: 'claude-opus-4-8', url: '', npm: '@ai-sdk/anthropic' } } },
|
|
},
|
|
{
|
|
id: 'opencode',
|
|
source: 'custom',
|
|
options: { apiKey: ZEN_ANONYMOUS_API_KEY },
|
|
models: { 'free-model': { api: { id: 'free-model', url: 'https://opencode.ai/zen/v1', npm: '@ai-sdk/openai-compatible' } } },
|
|
},
|
|
{
|
|
id: 'zai-coding-plan',
|
|
source: 'api',
|
|
key: 'auth-json-key',
|
|
options: {},
|
|
models: { 'glm-5': { api: { id: 'glm-5', url: 'https://api.z.ai/api/coding/paas/v4', npm: '@ai-sdk/openai-compatible' } } },
|
|
},
|
|
],
|
|
connected: ['llmapi', 'opencode', 'zai-coding-plan'],
|
|
...overrides,
|
|
});
|
|
|
|
describe('OpenCode runtime provider snapshot', () => {
|
|
let fetchMock;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn(async () => new Response(JSON.stringify(providerPayload()), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
configureOpenCodeRuntimeProviders({
|
|
buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
|
|
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
configureOpenCodeRuntimeProviders(null);
|
|
resetOpenCodeRuntimeProviders();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('reports the credential and endpoint a plugin registered at runtime', async () => {
|
|
const provider = await getRuntimeProvider('llmapi');
|
|
|
|
expect(provider).toMatchObject({ apiKey: 'plugin-key', baseURL: 'https://api.llmapi.ai/v1' });
|
|
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:4096/provider');
|
|
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Basic test' });
|
|
});
|
|
|
|
it('refuses the zen sentinel as a credential', async () => {
|
|
const provider = await getRuntimeProvider('opencode');
|
|
|
|
expect(provider.apiKey).toBeNull();
|
|
expect(provider.anonymousZen).toBe(true);
|
|
// The endpoint is still reported; only the credential is withheld.
|
|
expect(provider.baseURL).toBe('https://opencode.ai/zen/v1');
|
|
});
|
|
|
|
it('falls back to the model endpoint when the provider carries no baseURL', async () => {
|
|
expect((await getRuntimeProvider('zai-coding-plan')).baseURL).toBe('https://api.z.ai/api/coding/paas/v4');
|
|
});
|
|
|
|
it('serves one snapshot to concurrent callers instead of refetching', async () => {
|
|
await Promise.all([getRuntimeProvider('llmapi'), getRuntimeProvider('opencode'), getRuntimeProvider('zai-coding-plan')]);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('answers "unknown" rather than "no providers" when OpenCode is unreachable', async () => {
|
|
resetOpenCodeRuntimeProviders();
|
|
fetchMock.mockRejectedValue(new Error('connection refused'));
|
|
|
|
expect(await getRuntimeProviderSnapshot()).toBeNull();
|
|
});
|
|
|
|
it('keeps the previous snapshot when a later refresh fails', async () => {
|
|
await getRuntimeProviderSnapshot();
|
|
fetchMock.mockRejectedValue(new Error('connection refused'));
|
|
|
|
// Past the snapshot TTL, so the next read genuinely attempts a refresh.
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(Date.now() + 60_000);
|
|
const refreshed = await getRuntimeProviderSnapshot();
|
|
vi.useRealTimers();
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(refreshed.providers.has('llmapi')).toBe(true);
|
|
});
|
|
|
|
it('stays on file-based resolution until it is configured', async () => {
|
|
configureOpenCodeRuntimeProviders(null);
|
|
|
|
expect(await getRuntimeProvider('llmapi')).toBeNull();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|