Before this, the OpenCode class could only use providers already authenticated
via its own CLI (opencode auth login) — this app could pick a MODEL, never add
a PROVIDER. That is the one thing standing between "OpenCode integration" and
the actual ask: any LLM it supports, added from here.
New GET/PUT/DELETE /api/ai/opencode/providers, backed by GET /provider (every
provider OpenCode knows — 180 on a real run) and GET /provider/auth (which
auth method each accepts). New "Manage providers" panel in the OpenCode
settings section: search, add a key, remove one.
Scoped to API-key auth only, deliberately — recorded in lib/ai/opencode.ts's
module comment. `PUT /auth/{id}` with `{type:'api', key}` is one HTTP call
with a schema-verified shape. OAuth entries in /provider/auth need a browser
redirect + callback this app has no page for, and some carry interactive
prompts beyond a single form (GitHub Copilot's deployment-type picker) — real
scope for later, not something to half-build. OAuth-only providers are still
LISTED, just marked "Browser sign-in only" rather than hidden, so the picker
stays honest about what it can't do here.
A real finding from testing this against opencode's actual behaviour rather
than trusting a 200: NOT EVERY PROVIDER BECOMES CONNECTED FROM A BARE API KEY.
Snowflake Cortex needs SNOWFLAKE_ACCOUNT alongside its token; a single key
field silently leaves it stored-but-unconnected with no error from the PUT
itself. Worse, the provider's own `env` array length does not predict this —
Azure also needs two env vars and DOES connect from one key. There is no
reliable way to know in advance, so the route now VERIFIES by re-listing
providers after the write and reports plainly when a key was accepted but the
provider still isn't connected, rather than reporting the PUT's own success.
Verified live end-to-end, twice: once confirming a simple single-field
provider connects and can be removed cleanly, once confirming the honest
"stored but not connected" case is real and detected, not theoretical.
Cleaned up every throwaway credential from this machine's real opencode
config afterwards (checked auth.json directly, not just this app's view of it).
17 new/updated unit tests. Gate: tsc clean, eslint clean, 2527/2527 tests, build clean.
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
import { findOpencodeServer, parseModelRef, opencodeBaseUrls, opencodePrompt } from '../opencode';
|
|
|
|
/**
|
|
* The single most important behaviour under test is the SPA-catch-all trap:
|
|
* `opencode serve` answers 200 with the web UI's index.html for ANY unknown
|
|
* path, so a probe that trusts `res.ok` "verifies" endpoints that do not
|
|
* exist. That is not hypothetical — it is exactly how this integration was
|
|
* first built wrong (against an assumed OpenAI-compatible `/v1/models` that
|
|
* only ever returned HTML).
|
|
*/
|
|
|
|
const HTML_CATCHALL = {
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
|
|
json: async () => {
|
|
throw new Error('not json');
|
|
},
|
|
};
|
|
|
|
function jsonResponse(body: unknown) {
|
|
return {
|
|
ok: true,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
json: async () => body,
|
|
};
|
|
}
|
|
|
|
describe('parseModelRef', () => {
|
|
it('splits providerID/modelID, keeping slashes inside the model id', () => {
|
|
expect(parseModelRef('opencode/deepseek-v4-flash-free')).toEqual({
|
|
providerID: 'opencode',
|
|
modelID: 'deepseek-v4-flash-free',
|
|
});
|
|
// Real provider ids do contain slashes (e.g. openrouter's
|
|
// "anthropic/claude-..."), so only the FIRST slash separates.
|
|
expect(parseModelRef('openrouter/anthropic/claude-sonnet-4.5')).toEqual({
|
|
providerID: 'openrouter',
|
|
modelID: 'anthropic/claude-sonnet-4.5',
|
|
});
|
|
});
|
|
|
|
it('rejects malformed refs rather than guessing', () => {
|
|
expect(parseModelRef('noslash')).toBeNull();
|
|
expect(parseModelRef('/leading')).toBeNull();
|
|
expect(parseModelRef('trailing/')).toBeNull();
|
|
expect(parseModelRef('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('opencodeBaseUrls', () => {
|
|
const original = process.env.OPENCODE_BASE_URL;
|
|
afterEach(() => {
|
|
if (original === undefined) delete process.env.OPENCODE_BASE_URL;
|
|
else process.env.OPENCODE_BASE_URL = original;
|
|
});
|
|
|
|
it('refuses a non-loopback override — this class must never reach off-machine', () => {
|
|
process.env.OPENCODE_BASE_URL = 'https://evil.example.com';
|
|
const urls = opencodeBaseUrls();
|
|
expect(urls.some((u) => u.includes('evil.example.com'))).toBe(false);
|
|
expect(urls[0]).toMatch(/127\.0\.0\.1|localhost/);
|
|
});
|
|
|
|
it('honours a loopback override, trying it first', () => {
|
|
process.env.OPENCODE_BASE_URL = 'http://127.0.0.1:9999/';
|
|
expect(opencodeBaseUrls()[0]).toBe('http://127.0.0.1:9999');
|
|
});
|
|
});
|
|
|
|
describe('auth', () => {
|
|
const originalFetch = global.fetch;
|
|
const originalPw = process.env.OPENCODE_SERVER_PASSWORD;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
if (originalPw === undefined) delete process.env.OPENCODE_SERVER_PASSWORD;
|
|
else process.env.OPENCODE_SERVER_PASSWORD = originalPw;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('sends HTTP Basic with the username EXACTLY "opencode"', async () => {
|
|
// Verified against 1.18.14: an empty or arbitrary username 401s even with
|
|
// the right password, and no bearer/custom-header form works. Its OpenAPI
|
|
// spec declares no securitySchemes, so this is only knowable by trying it
|
|
// - which makes it exactly the kind of thing to pin with a test.
|
|
process.env.OPENCODE_SERVER_PASSWORD = 'hunter2';
|
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [{ id: 'm', providerID: 'p' }] }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await findOpencodeServer();
|
|
const sentHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
|
const decoded = Buffer.from(sentHeaders.Authorization.replace('Basic ', ''), 'base64').toString();
|
|
expect(decoded).toBe('opencode:hunter2');
|
|
});
|
|
|
|
it('sends no auth header at all when no password is configured', async () => {
|
|
delete process.env.OPENCODE_SERVER_PASSWORD;
|
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [{ id: 'm', providerID: 'p' }] }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
await findOpencodeServer();
|
|
const sentHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
|
expect(sentHeaders.Authorization).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('findOpencodeServer', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('does NOT accept the web UI catch-all as a working API (200 + HTML)', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
|
expect(await findOpencodeServer()).toBeNull();
|
|
});
|
|
|
|
it('parses the real /api/model shape into providerID/modelID refs', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
jsonResponse({
|
|
data: [
|
|
{ id: 'deepseek-v4-flash-free', providerID: 'opencode', name: 'DeepSeek V4 Flash Free' },
|
|
{ id: 'deepseek-chat', providerID: 'deepseek' },
|
|
{ id: '', providerID: 'broken' },
|
|
],
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const found = await findOpencodeServer();
|
|
expect(found?.models.map((m) => m.ref)).toEqual([
|
|
'opencode/deepseek-v4-flash-free',
|
|
'deepseek/deepseek-chat',
|
|
]);
|
|
expect(found?.models[0].label).toBe('DeepSeek V4 Flash Free (opencode)');
|
|
});
|
|
|
|
it('returns null when the server answers JSON with no models', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: [] })) as unknown as typeof fetch;
|
|
expect(await findOpencodeServer()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('opencodePrompt', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('returns only the text parts — never the model\'s private reasoning', async () => {
|
|
global.fetch = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
parts: [
|
|
{ type: 'step-start' },
|
|
{ type: 'reasoning', text: 'SECRET chain of thought that must not be shown' },
|
|
{ type: 'text', text: 'The visible answer.' },
|
|
{ type: 'step-finish' },
|
|
],
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'opencode', modelID: 'm' }, 'sys', 'q');
|
|
expect(result).toEqual({ ok: true, answer: 'The visible answer.' });
|
|
if (result.ok) expect(result.answer).not.toContain('SECRET');
|
|
});
|
|
|
|
it('fails cleanly when no session can be created', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('fails cleanly when the reply carries no text part', async () => {
|
|
global.fetch = vi.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ id: 'ses_abc' }))
|
|
.mockResolvedValueOnce(jsonResponse({ parts: [{ type: 'step-start' }, { type: 'reasoning', text: 'only thinking' }] })) as unknown as typeof fetch;
|
|
const result = await opencodePrompt('http://127.0.0.1:4096', { providerID: 'p', modelID: 'm' }, undefined, 'q');
|
|
expect(result).toEqual({ ok: false, error: 'OpenCode returned no message content' });
|
|
});
|
|
});
|
|
|
|
describe('listOpencodeProviders', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('merges /provider and /provider/auth into one list, connected first', async () => {
|
|
const fetchMock = vi.fn((url: string) => {
|
|
if (url.endsWith('/provider')) {
|
|
return Promise.resolve(jsonResponse({
|
|
all: [{ id: 'anthropic', name: 'Anthropic' }, { id: 'deepseek', name: 'DeepSeek' }, { id: 'github-copilot', name: 'GitHub Copilot' }],
|
|
connected: ['deepseek'],
|
|
}));
|
|
}
|
|
if (url.endsWith('/provider/auth')) {
|
|
return Promise.resolve(jsonResponse({
|
|
anthropic: [{ type: 'api' }],
|
|
deepseek: [{ type: 'api' }],
|
|
'github-copilot': [{ type: 'oauth' }],
|
|
}));
|
|
}
|
|
return Promise.resolve(HTML_CATCHALL);
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const { listOpencodeProviders } = await import('../opencode');
|
|
const result = await listOpencodeProviders('http://127.0.0.1:4096');
|
|
|
|
expect(result).toHaveLength(3);
|
|
// Connected providers sort first regardless of name.
|
|
expect(result[0]).toMatchObject({ id: 'deepseek', connected: true, supportsApiKey: true });
|
|
const anthropic = result.find((p) => p.id === 'anthropic');
|
|
expect(anthropic).toMatchObject({ connected: false, supportsApiKey: true });
|
|
const copilot = result.find((p) => p.id === 'github-copilot');
|
|
// OAuth-only provider: listed, but honestly marked as not addable here.
|
|
expect(copilot).toMatchObject({ connected: false, supportsApiKey: false });
|
|
});
|
|
|
|
it('returns an empty list rather than throwing when /provider is unreachable', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
|
|
const { listOpencodeProviders } = await import('../opencode');
|
|
expect(await listOpencodeProviders('http://127.0.0.1:4096')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('setOpencodeProviderKey / removeOpencodeProvider', () => {
|
|
const originalFetch = global.fetch;
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('PUTs the exact schema OpenCode requires: {type:"api", key}', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
const { setOpencodeProviderKey } = await import('../opencode');
|
|
|
|
await setOpencodeProviderKey('http://127.0.0.1:4096', 'anthropic', 'sk-real-key');
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/auth/anthropic',
|
|
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ type: 'api', key: 'sk-real-key' }) }),
|
|
);
|
|
});
|
|
|
|
it('throws with the upstream status when OpenCode rejects the credential', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 }) as unknown as typeof fetch;
|
|
const { setOpencodeProviderKey } = await import('../opencode');
|
|
await expect(setOpencodeProviderKey('http://127.0.0.1:4096', 'anthropic', 'bad')).rejects.toThrow(/400/);
|
|
});
|
|
|
|
it('DELETEs by provider id and encodes it in the path', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
const { removeOpencodeProvider } = await import('../opencode');
|
|
|
|
await removeOpencodeProvider('http://127.0.0.1:4096', 'weird id/with slash');
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4096/auth/weird%20id%2Fwith%20slash',
|
|
expect.objectContaining({ method: 'DELETE' }),
|
|
);
|
|
});
|
|
});
|