Files
boocode/apps/coder/src/services/__tests__/local-gateway.test.ts
indifferentketchup b18de2a331 chore: snapshot working tree - pty_exited notifications + in-flight inference WIP
feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean).

wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes.

openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
2026-06-14 12:48:47 +00:00

400 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveGatewayModel } from '../local-gateway.js';
import { prefixBoocodeLocalModels, clearProviderSnapshotCache, getProviderSnapshot } from '../provider-snapshot.js';
import { loadLlamaProviders } from '../llama-providers.js';
import { loadProviderConfig } from '../provider-config-registry.js';
vi.mock('../acp-probe.js', () => ({
probeAcpProvider: vi.fn(),
}));
import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider);
/** Load a providers fixture into the in-memory registry. */
function loadProvidersFixture(providers: Array<{ id: string; label: string; baseUrl: string; kind?: string }>): void {
const file = {
defaultProvider: providers[0]?.id ?? 'llama-swap',
providers,
};
const path = join(tmpdir(), `llama-providers-w7-${Date.now()}.json`);
writeFileSync(path, JSON.stringify(file), 'utf8');
loadLlamaProviders(path, 'http://localhost:8080');
}
function mockSql(agents: Array<{
name: string;
install_path: string | null;
supports_acp: boolean;
models: Array<{ id: string; label: string }> | null;
label: string | null;
transport: string | null;
last_probed_at?: string | null;
}>) {
return vi.fn((strings: TemplateStringsArray) => {
const query = strings.join('');
if (query.includes('FROM available_agents')) {
return Promise.resolve(agents);
}
if (query.includes('UPDATE available_agents')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
}) as unknown as import('../db.js').Sql;
}
// --- Gateway model-id parsing tests ---
describe('resolveGatewayModel', () => {
beforeEach(() => {
loadProvidersFixture([
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
{ id: 'embedding', label: 'Embedding', baseUrl: 'http://100.90.172.55:8411' },
]);
});
it('resolves composite "provider/model" to the correct baseUrl', () => {
const result = resolveGatewayModel('sam-desktop/qwen3.6-35b');
expect(result).toEqual({
baseUrl: 'http://100.101.41.16:8401',
wireModelId: 'qwen3.6-35b',
});
});
it('resolves a different provider to its own baseUrl', () => {
const result = resolveGatewayModel('embedding/gemma-4-12b');
expect(result).toEqual({
baseUrl: 'http://100.90.172.55:8411',
wireModelId: 'gemma-4-12b',
});
});
it('returns error for unknown provider', () => {
const result = resolveGatewayModel('nonexistent/model');
expect(result).toHaveProperty('error');
expect((result as { error: string }).error).toContain('unknown provider');
});
it('bare model resolves to default provider', () => {
const result = resolveGatewayModel('qwen3.6-35b');
expect(result).toEqual({
baseUrl: 'http://100.101.41.16:8401',
wireModelId: 'qwen3.6-35b',
});
});
it('two providers serving the SAME wire model name hit different baseUrls', () => {
const r1 = resolveGatewayModel('sam-desktop/qwen3.6-35b');
const r2 = resolveGatewayModel('embedding/qwen3.6-35b');
expect(r1).toHaveProperty('baseUrl', 'http://100.101.41.16:8401');
expect(r2).toHaveProperty('baseUrl', 'http://100.90.172.55:8411');
expect((r1 as { wireModelId: string }).wireModelId).toBe('qwen3.6-35b');
expect((r2 as { wireModelId: string }).wireModelId).toBe('qwen3.6-35b');
});
});
// --- prefixBoocodeLocalModels ---
describe('prefixBoocodeLocalModels', () => {
it('wraps composite ids with boocode-local prefix', () => {
const result = prefixBoocodeLocalModels([
{ id: 'sam-desktop/qwen3.6-35b', label: 'Qwen' },
{ id: 'embedding/gemma-4-12b', label: 'Gemma' },
]);
expect(result.map((m) => m.id)).toEqual([
'boocode-local/sam-desktop/qwen3.6-35b',
'boocode-local/embedding/gemma-4-12b',
]);
});
it('leaves already-prefixed ids unchanged', () => {
const result = prefixBoocodeLocalModels([
{ id: 'boocode-local/sam-desktop/qwen3.6-35b', label: 'Qwen' },
]);
expect(result[0].id).toBe('boocode-local/sam-desktop/qwen3.6-35b');
});
it('preserves label and other fields', () => {
const result = prefixBoocodeLocalModels([
{ id: 'sam-desktop/qwen3.6-35b', label: 'Qwen 3.6 35B', isDefault: true },
]);
expect(result[0]).toEqual({
id: 'boocode-local/sam-desktop/qwen3.6-35b',
label: 'Qwen 3.6 35B',
isDefault: true,
});
});
});
// --- parseModel inner-slash preservation ---
describe('gateway model id parsing preserves inner slashes', () => {
beforeEach(() => {
loadProvidersFixture([
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
]);
});
it('parses "sam-desktop/qwen3.6-35b-a3b-mxfp4" preserving the full wire id', () => {
const result = resolveGatewayModel('sam-desktop/qwen3.6-35b-a3b-mxfp4');
expect(result).toHaveProperty('wireModelId', 'qwen3.6-35b-a3b-mxfp4');
});
it('parses model ids with dots and hyphens', () => {
const result = resolveGatewayModel('sam-desktop/deepseek-r1-0528');
expect(result).toHaveProperty('wireModelId', 'deepseek-r1-0528');
});
});
// --- Snapshot advertising shape (integration) ---
describe('provider snapshot opencode entry uses boocode-local prefix', () => {
beforeEach(() => {
clearProviderSnapshotCache();
loadProviderConfig('/nonexistent-coder-providers.json');
vi.restoreAllMocks();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'local-model' }, { id: 'qwen3.6-35b' }],
}),
}),
);
mockProbe.mockResolvedValue({
ok: true,
models: [],
modes: [],
defaultModeId: null,
commands: [],
});
});
it('opencode snapshot entry has boocode-local prefixed model ids', async () => {
loadProvidersFixture([
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://100.101.41.16:8401' },
]);
const sql = mockSql([
{
name: 'opencode',
install_path: '/usr/bin/opencode',
supports_acp: true,
models: null,
label: 'OpenCode',
transport: 'acp',
last_probed_at: null,
},
]);
const config = {
LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
DEFAULT_MODEL: 'qwen3.6-35b',
} as import('../config.js').Config;
const entries = await getProviderSnapshot(sql, config, '/tmp/test', true);
const opencode = entries.find((e) => e.name === 'opencode');
expect(opencode).toBeDefined();
// W7: all model ids start with "boocode-local/" and never "llama-swap/".
for (const m of opencode!.models) {
expect(m.id).toMatch(/^boocode-local\//);
expect(m.id).not.toMatch(/^llama-swap\//);
}
});
});
// --- Gateway HTTP proxy tests (W7 audit M3) ---
describe('local gateway HTTP proxy', () => {
let app: import('fastify').FastifyInstance;
const fetchMock = vi.fn();
beforeEach(async () => {
loadProvidersFixture([
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://machine-a.test:8401' },
{ id: 'laptop', label: 'Laptop', baseUrl: 'http://machine-b.test:8401' },
]);
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
const { default: Fastify } = await import('fastify');
const { registerLocalGatewayRoutes } = await import('../local-gateway.js');
app = Fastify({ logger: false });
registerLocalGatewayRoutes(app);
await app.ready();
});
afterEach(async () => {
vi.unstubAllGlobals();
await app.close();
});
it('proxies non-streaming requests to the right provider with the bare wire id', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ id: 'cmpl-1', model: 'qwen3.6-35b' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const res = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ id: 'cmpl-1' });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('http://machine-a.test:8401/v1/chat/completions');
expect(JSON.parse(init.body as string).model).toBe('qwen3.6-35b');
});
it('routes duplicate wire model names to different machines by provider prefix', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
});
await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'laptop/qwen3.6-35b', messages: [] },
});
const urls = fetchMock.mock.calls.map((c) => c[0] as string);
expect(urls).toEqual([
'http://machine-a.test:8401/v1/chat/completions',
'http://machine-b.test:8401/v1/chat/completions',
]);
});
it('returns 400 for an unknown provider without calling upstream', async () => {
const res = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'nonexistent/some-model', messages: [] },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('unknown provider');
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns 400 when the model field is missing', async () => {
const res = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { messages: [] },
});
expect(res.statusCode).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns an OpenAI-shaped 502 error when upstream replies non-JSON', async () => {
fetchMock.mockResolvedValue(
new Response('<html>gateway error</html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
);
const res = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
});
expect(res.statusCode).toBe(502);
expect(res.json().error.message).toContain('non-JSON');
});
it('relays streaming responses chunk-for-chunk with the upstream status', async () => {
const chunks = ['data: {"a":1}\n\n', 'data: {"a":2}\n\n', 'data: [DONE]\n\n'];
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(new TextEncoder().encode(c));
controller.close();
},
});
fetchMock.mockResolvedValue(
new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }),
);
const res = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'laptop/qwen3.6-35b', messages: [], stream: true },
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('text/event-stream');
expect(res.body).toBe(chunks.join(''));
});
it('forwards inbound X-Boo-Source header to upstream', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
headers: { 'x-boo-source': 'arena' },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const callHeaders = (fetchMock.mock.calls[0] as [string, RequestInit])[1]?.headers as Record<string, string>;
expect(callHeaders['X-Boo-Source']).toBe('arena');
});
it('defaults X-Boo-Source to boocoder when not present', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: 'sam-desktop/qwen3.6-35b', messages: [] },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const callHeaders = (fetchMock.mock.calls[0] as [string, RequestInit])[1]?.headers as Record<string, string>;
expect(callHeaders['X-Boo-Source']).toBe('boocoder');
});
});
// --- opencode config sync shape (W7 audit B1) ---
describe('buildBoocodeLocalProviderConfig', () => {
it('emits an opencode-routable provider: npm + options.baseURL + models as object map', async () => {
loadProvidersFixture([
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://machine-a.test:8401' },
]);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'qwen3.6-35b' }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
try {
const { buildBoocodeLocalProviderConfig } = await import('../opencode-config-sync.js');
const cfg = await buildBoocodeLocalProviderConfig('http://127.0.0.1:9502');
expect(cfg.npm).toBe('@ai-sdk/openai-compatible');
expect(cfg.options?.baseURL).toBe('http://127.0.0.1:9502/v1');
expect(Array.isArray(cfg.models)).toBe(false);
expect(cfg.models).toHaveProperty(['sam-desktop/qwen3.6-35b']);
} finally {
vi.unstubAllGlobals();
}
});
});