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).
This commit is contained in:
399
apps/coder/src/services/__tests__/local-gateway.test.ts
Normal file
399
apps/coder/src/services/__tests__/local-gateway.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user