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:
2026-06-14 12:48:47 +00:00
parent 0ed506f1da
commit b18de2a331
204 changed files with 25344 additions and 867 deletions

View File

@@ -55,6 +55,9 @@ const ConfigSchema = z.object({
// v2.9.x: flow step timeout (default 5 min). When a 'running' step exceeds
// this duration, it is marked 'timed_out' and may be retried.
FLOW_STEP_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000),
// vMultiProvider: path to the local providers config JSON file. Missing file
// = legacy synthesis from LLAMA_SWAP_URL.
LLAMA_PROVIDERS_PATH: z.string().optional(),
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -31,6 +31,9 @@ import { registerLifecycleRoutes } from './routes/lifecycle.js';
import { registerAnalyticsRoutes } from './routes/analytics.js';
import { registerPlanRoutes } from './routes/plans.js';
import { registerWebSocket } from './routes/ws.js';
import { registerLocalGatewayRoutes } from './services/local-gateway.js';
import { syncOpencodeConfig } from './services/opencode-config-sync.js';
import { syncPiConfig } from './services/pi-config-sync.js';
import { updatePlanFromRun } from './services/plan-store.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
@@ -43,7 +46,9 @@ import { createAnalyzer } from './services/arena-analyzer.js';
import { agentPool } from './services/agent-pool.js';
import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js';
import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels, fetchLlamaSwapModels } from './services/provider-snapshot.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { loadLlamaProviders } from './services/llama-providers.js';
import { createLocalModelSet } from './services/arena-local-models.js';
import { setPermissionHooks } from './services/permission-waiter.js';
import { publishAgentStatus } from './services/agent-status-publish.js';
import { homedir } from 'node:os';
@@ -83,6 +88,17 @@ async function main() {
await applySchema(sql);
app.log.info('database schema applied');
// Wire the shared local-provider registry at startup so provider-snapshot
// can build composite provider/model ids from the registry (W5).
const llamaProviders = loadLlamaProviders(
config.LLAMA_PROVIDERS_PATH,
config.LLAMA_SWAP_URL,
);
app.log.info(
{ providers: llamaProviders.providers.length, default: llamaProviders.defaultProvider },
'llama-providers: loaded',
);
// Broker: in-memory pub/sub for session + user channel streaming.
const broker = createBroker(app.log);
@@ -242,15 +258,15 @@ async function main() {
},
});
// Arena SEAM (a): build the local-model set from the live llama-swap model list.
// Both bare IDs ('qwen3.6-35b') and prefixed IDs ('llama-swap/qwen3.6-35b') are
// included so opencode-style prefixed contestants and native-style bare contestants
// both classify correctly as local.
const localModelsList = await fetchLlamaSwapModels(config).catch(() => []);
const localModels = new Set([
...localModelsList.map((m) => m.id),
...localModelsList.map((m) => `llama-swap/${m.id}`),
]);
// Arena SEAM (a): self-refreshing local-model set from every provider in
// the shared registry. Composite "provider/model" ids from every provider;
// bare wire ids only from the default provider (bare ids resolve there).
// Refreshes every 5 min so a provider that was down at startup reclassifies
// as local once it recovers — no boocoder restart needed.
const localModelSet = createLocalModelSet(app.log);
await localModelSet.refresh();
localModelSet.start(5 * 60_000);
const localModels = localModelSet.set;
// Arena dispatch function — Phase 4 SEAM (b).
// Coding: insert a tasks row with agent=identity (null for native/boocode);
@@ -376,6 +392,7 @@ async function main() {
// drain the pool (kills opencode server + warm ACP children).
await dispatcher.stop();
orphanReaper.stop();
localModelSet.stop();
await agentPool.dispose();
});
@@ -397,6 +414,28 @@ async function main() {
registerPlanRoutes(app, sql);
registerWebSocket(app, sql, broker);
// W7: Local-model gateway — OpenAI-compatible proxy for opencode.
registerLocalGatewayRoutes(app);
// W7: Sync boocode-local provider into opencode's config file so it
// accepts composite local model ids. Derives the gateway URL from the
// coder's own HOST/PORT config. Fire-and-forget — a config write failure
// is non-fatal (the gateway still works; opencode just won't list models).
const gatewayUrl = `http://127.0.0.1:${config.PORT}`;
void syncOpencodeConfig(gatewayUrl, app.log).catch((err) => {
app.log.warn(
{ err: err instanceof Error ? err.message : String(err) },
'opencode-config-sync: startup sync failed (non-fatal)',
);
});
// Same story for Pi (~/.pi/agent/models.json) — the other external agent.
void syncPiConfig(gatewayUrl, app.log).catch((err) => {
app.log.warn(
{ err: err instanceof Error ? err.message : String(err) },
'pi-config-sync: startup sync failed (non-fatal)',
);
});
// Graceful shutdown
const shutdown = async () => {
app.log.info('shutting down');

View File

@@ -83,7 +83,6 @@ export function registerArenaRoutes(
try {
const prompt = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system: [
'You are a battle-prompt writer for an AI Arena.',

View File

@@ -51,6 +51,55 @@ describe('classifyLane', () => {
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', new Set())).toBe('cloud');
expect(classifyLane('coding', 'native', 'any-local-model', new Set())).toBe('cloud');
});
it('classifies composite provider/model ids as local when present', () => {
const multiProvider = new Set([
'sam-desktop/qwen3.6-35b-a3b-mxfp4',
'embedding/qwen2.5-coder-7b',
'qwen3.6-35b-a3b-mxfp4', // bare fallback
]);
expect(classifyLane('coding', 'boocode', 'sam-desktop/qwen3.6-35b-a3b-mxfp4', multiProvider)).toBe('local');
expect(classifyLane('coding', 'opencode', 'embedding/qwen2.5-coder-7b', multiProvider)).toBe('local');
});
it('classifies composite ids as cloud when provider is not in localModels', () => {
const multiProvider = new Set([
'sam-desktop/qwen3.6-35b-a3b-mxfp4',
]);
expect(classifyLane('coding', 'boocode', 'other-machine/qwen3.6-35b-a3b-mxfp4', multiProvider)).toBe('cloud');
});
it('classifies bare legacy ids as local when present', () => {
const mixed = new Set([
'sam-desktop/qwen3.6-35b-a3b-mxfp4',
'qwen3.6-35b-a3b-mxfp4', // bare fallback for default provider
]);
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', mixed)).toBe('local');
});
it('classifies deepseek as cloud even when local providers exist', () => {
const multiProvider = new Set([
'sam-desktop/qwen3.6-35b-a3b-mxfp4',
'embedding/qwen2.5-coder-7b',
]);
expect(classifyLane('coding', 'opencode', 'deepseek-chat', multiProvider)).toBe('cloud');
expect(classifyLane('coding', 'opencode', 'deepseek/deepseek-r1', multiProvider)).toBe('cloud');
});
it('handles duplicate wire names across two providers routing to different baseUrls', () => {
const multiProvider = new Set([
'sam-desktop/qwen3.6-35b-a3b-mxfp4',
'laptop/qwen3.6-35b-a3b-mxfp4',
'qwen3.6-35b-a3b-mxfp4', // bare fallback
]);
// Composite IDs classify correctly per provider
expect(classifyLane('coding', 'boocode', 'sam-desktop/qwen3.6-35b-a3b-mxfp4', multiProvider)).toBe('local');
expect(classifyLane('coding', 'boocode', 'laptop/qwen3.6-35b-a3b-mxfp4', multiProvider)).toBe('local');
// Bare id also classifies as local (backward compat)
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', multiProvider)).toBe('local');
// Unknown provider does not
expect(classifyLane('coding', 'boocode', 'unknown-provider/qwen3.6-35b-a3b-mxfp4', multiProvider)).toBe('cloud');
});
});
// ─── nextLocalContestant ─────────────────────────────────────────────────────

View File

@@ -0,0 +1,98 @@
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 { createLocalModelSet } from '../arena-local-models.js';
import { loadLlamaProviders } from '../llama-providers.js';
const log = { warn: vi.fn() };
function loadFixture(providers: Array<{ id: string; label: string; baseUrl: string }>): void {
const file = {
defaultProvider: providers[0]!.id,
providers: providers.map((p) => ({ ...p, kind: 'llama-swap' })),
};
const path = join(tmpdir(), `llama-providers-alm-${Math.random().toString(36).slice(2)}.json`);
writeFileSync(path, JSON.stringify(file), 'utf8');
loadLlamaProviders(path, 'http://legacy.test:8080');
}
function modelsResponse(ids: string[]): Response {
return new Response(JSON.stringify({ data: ids.map((id) => ({ id })) }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
describe('createLocalModelSet', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
log.warn.mockReset();
loadFixture([
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://a.test:8401' },
{ id: 'embedding', label: 'Embedding', baseUrl: 'http://b.test:8411' },
]);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('adds composite ids from every provider, bare ids only from the default', async () => {
fetchMock.mockImplementation((url: string) =>
url.startsWith('http://a.test')
? Promise.resolve(modelsResponse(['qwen3.6-35b']))
: Promise.resolve(modelsResponse(['gemma-4-12b'])),
);
const handle = createLocalModelSet(log);
await handle.refresh();
expect(handle.set.has('sam-desktop/qwen3.6-35b')).toBe(true);
expect(handle.set.has('embedding/gemma-4-12b')).toBe(true);
expect(handle.set.has('qwen3.6-35b')).toBe(true); // bare from default
expect(handle.set.has('gemma-4-12b')).toBe(false); // bare NOT from non-default
});
it('keeps last-known contribution when a provider goes unreachable, drops removed models when reachable', async () => {
fetchMock.mockImplementation((url: string) =>
url.startsWith('http://a.test')
? Promise.resolve(modelsResponse(['qwen3.6-35b', 'old-model']))
: Promise.resolve(modelsResponse(['gemma-4-12b'])),
);
const handle = createLocalModelSet(log);
await handle.refresh();
expect(handle.set.has('sam-desktop/old-model')).toBe(true);
// Second refresh: provider A drops a model, provider B is down.
fetchMock.mockImplementation((url: string) =>
url.startsWith('http://a.test')
? Promise.resolve(modelsResponse(['qwen3.6-35b']))
: Promise.reject(new Error('ECONNREFUSED')),
);
await handle.refresh();
expect(handle.set.has('sam-desktop/old-model')).toBe(false); // removed on reachable provider
expect(handle.set.has('embedding/gemma-4-12b')).toBe(true); // kept for unreachable provider
expect(log.warn).toHaveBeenCalled();
});
it('recovers a provider that was down at first refresh', async () => {
fetchMock.mockImplementation((url: string) =>
url.startsWith('http://a.test')
? Promise.resolve(modelsResponse(['qwen3.6-35b']))
: Promise.reject(new Error('ECONNREFUSED')),
);
const handle = createLocalModelSet(log);
await handle.refresh();
expect(handle.set.has('embedding/gemma-4-12b')).toBe(false);
fetchMock.mockImplementation((url: string) =>
url.startsWith('http://a.test')
? Promise.resolve(modelsResponse(['qwen3.6-35b']))
: Promise.resolve(modelsResponse(['gemma-4-12b'])),
);
await handle.refresh();
expect(handle.set.has('embedding/gemma-4-12b')).toBe(true);
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('P4: arena-model-call X-Boo-Source header', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
new Response(
JSON.stringify({
choices: [{ message: { content: 'analysis result' } }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('sets X-Boo-Source: arena on model calls', async () => {
const fetchMock = vi.fn(() =>
new Response(
JSON.stringify({
choices: [{ message: { content: 'result' } }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
// Load providers fixture
const { writeFileSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const providerFile = {
defaultProvider: 'sam-desktop',
providers: [
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://test:8401', kind: 'llama-swap' },
],
};
const path = join(tmpdir(), `test-providers-${Date.now()}.json`);
writeFileSync(path, JSON.stringify(providerFile), 'utf8');
const { loadLlamaProviders } = await import('../llama-providers.js');
loadLlamaProviders(path, 'http://localhost:8080');
const { arenaModelCall } = await import('../arena-model-call.js');
const result = await arenaModelCall({
model: 'sam-desktop/test-model',
system: 'You are a judge.',
user: 'Evaluate this response.',
temperature: 0,
});
expect(result).toBe('result');
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');
});
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { resolveModelEndpoint } from '../arena-model-call.js';
// Mock the llama-providers module so resolveModelEndpoint resolves against
// our test registry instead of the startup-time cached config.
const mockProviders = {
defaultProvider: 'sam-desktop',
providers: [
{
id: 'sam-desktop',
label: 'Sam Desktop',
baseUrl: 'http://100.101.41.16:8080',
kind: 'llama-swap',
},
{
id: 'embedding',
label: 'Embedding Box',
baseUrl: 'http://100.101.41.17:8080',
kind: 'llama-swap',
},
],
};
vi.mock('../llama-providers.js', () => ({
getLlamaProviders: () => mockProviders,
parseModelRef: (ref: string) => {
const slashIdx = ref.indexOf('/');
if (slashIdx <= 0) {
return { providerId: mockProviders.defaultProvider, wireModelId: ref, isLegacyBareId: true };
}
return {
providerId: ref.slice(0, slashIdx),
wireModelId: ref.slice(slashIdx + 1),
isLegacyBareId: false,
};
},
}));
// ─── resolveModelEndpoint ───────────────────────────────────────────────────
describe('resolveModelEndpoint', () => {
it('resolves a composite provider/model id to the correct baseUrl', () => {
const result = resolveModelEndpoint('sam-desktop/qwen3.6-35b-a3b-mxfp4');
expect(result.baseUrl).toBe('http://100.101.41.16:8080');
expect(result.wireModelId).toBe('qwen3.6-35b-a3b-mxfp4');
});
it('routes duplicate wire names to different baseUrls by provider', () => {
// Same wire model on two providers
const r1 = resolveModelEndpoint('sam-desktop/qwen3.6-35b-a3b-mxfp4');
const r2 = resolveModelEndpoint('embedding/qwen3.6-35b-a3b-mxfp4');
expect(r1.baseUrl).toBe('http://100.101.41.16:8080');
expect(r1.wireModelId).toBe('qwen3.6-35b-a3b-mxfp4');
expect(r2.baseUrl).toBe('http://100.101.41.17:8080');
expect(r2.wireModelId).toBe('qwen3.6-35b-a3b-mxfp4');
});
it('resolves bare legacy ids to the default provider', () => {
const result = resolveModelEndpoint('qwen3.6-35b-a3b-mxfp4');
expect(result.baseUrl).toBe('http://100.101.41.16:8080');
expect(result.wireModelId).toBe('qwen3.6-35b-a3b-mxfp4');
});
it('throws for an unknown provider prefix', () => {
expect(() => resolveModelEndpoint('nonexistent/model')).toThrow('unknown provider: nonexistent');
});
it('handles models with slashes in the wire id', () => {
const result = resolveModelEndpoint('sam-desktop/models/qwen3.6-35b');
expect(result.baseUrl).toBe('http://100.101.41.16:8080');
expect(result.wireModelId).toBe('models/qwen3.6-35b');
});
});

View File

@@ -14,7 +14,7 @@ import {
shouldFailOnMissingAgent,
type SchedulerState,
} from '../flow-runner-decisions.js';
import type { StepContext } from '../../conductor/types.js';
import type { TriggerRule } from '../../conductor/types.js';
/**
* The DB-driven flow-runner replaces the Phase-1 in-memory wave scheduler
@@ -58,6 +58,7 @@ const emptyState = (over: Partial<SchedulerState> = {}): SchedulerState => ({
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
...over,
});
@@ -371,6 +372,7 @@ describe('readySteps with switch-excluded steps', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
loopIterations: new Map(),
};
const ready = readySteps(flow, state).map((s) => s.id);
// branch-a is ready (dep switch is done), branch-b is excluded
@@ -390,6 +392,7 @@ describe('readySteps with switch-excluded steps', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
loopIterations: new Map(),
};
const ready = readySteps(flow, state).map((s) => s.id);
// fold's deps: branch-a done, branch-b excluded (via switch) → satisfied
@@ -408,6 +411,7 @@ describe('readySteps with switch-excluded steps', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
loopIterations: new Map(),
};
const ready = readySteps(flow, state).map((s) => s.id);
// branch-a in flight, branch-b excluded — only branch-a offered
@@ -427,6 +431,7 @@ describe('readySteps with switch-excluded steps', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: switchResult,
loopIterations: new Map(),
};
expect(isRunComplete(flow, state)).toBe(true);
expect(isStuck(flow, state)).toBe(false);
@@ -445,6 +450,7 @@ describe('readySteps with switch-excluded steps', () => {
excluded: new Set(['branch-b']),
timedOut: new Set(),
switchResults: switchResult,
loopIterations: new Map(),
};
// branch-b excluded both ways; fold sees branch-a done, branch-b excluded
const ready = readySteps(flow, state).map((s) => s.id);
@@ -554,6 +560,7 @@ describe('getReadyInBatch', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
batchState: makeBatchState(),
};
const result = getReadyInBatch(steps, state, {} as Flow);
@@ -574,6 +581,7 @@ describe('getReadyInBatch', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
batchState,
};
const result = getReadyInBatch(steps, state, {} as Flow);
@@ -596,6 +604,7 @@ describe('getReadyInBatch', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
batchState,
};
// All 0 running, maxConcurrent=2 → all 3 pass through (readySteps would return them,
@@ -620,6 +629,7 @@ describe('getReadyInBatch', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
batchState,
};
// Both batches at capacity → everything filtered out
@@ -642,6 +652,7 @@ describe('getReadyInBatch', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
batchState,
};
expect(getReadyInBatch(steps, state, {} as Flow).map((s) => s.id)).toEqual(['c', 'd']);
@@ -660,6 +671,7 @@ describe('getReadyInBatch', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
batchState,
};
expect(getReadyInBatch(steps, state, {} as Flow).map((s) => s.id)).toEqual(['first']);
@@ -673,6 +685,7 @@ describe('getReadyInBatch', () => {
excluded: new Set(),
timedOut: new Set(),
switchResults: new Map(),
loopIterations: new Map(),
batchState: makeBatchState(),
};
expect(getReadyInBatch([], state, {} as Flow)).toEqual([]);

View File

@@ -0,0 +1,124 @@
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 Fastify from 'fastify';
import { resolveGatewayModel, registerLocalGatewayRoutes } from '../local-gateway.js';
import { loadLlamaProviders } from '../llama-providers.js';
// P0 duplicate-name routing smoke (multi-llama-swap-providers-model-favorites,
// P8): five wire model ids exist on BOTH llama-swap hosts in production
// (deepseek-r1-qwen3-8b et al). Opencode dispatches through the boocode-local
// gateway, so the gateway is the layer that must preserve provider identity —
// the same bare wire name prefixed with different provider ids must reach
// DIFFERENT baseUrls, and an unknown provider must be an error, never a
// silent fallback to whichever host the bare name happens to resolve on.
const DUP = 'deepseek-r1-qwen3-8b';
const SAM_URL = 'http://a.test:8401';
const EMB_URL = 'http://b.test:8411';
function loadFixture(): void {
const file = {
defaultProvider: 'sam-desktop',
providers: [
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: SAM_URL, kind: 'llama-swap' },
{ id: 'embedding', label: 'Embedding', baseUrl: EMB_URL, kind: 'llama-swap' },
],
};
const path = join(tmpdir(), `llama-providers-lgr-${Math.random().toString(36).slice(2)}.json`);
writeFileSync(path, JSON.stringify(file), 'utf8');
loadLlamaProviders(path, 'http://legacy.test:8080');
}
describe('local-gateway duplicate-name routing (P0 P8 smoke)', () => {
beforeEach(() => {
loadFixture();
});
it('routes the same wire name to the intended provider per composite prefix', () => {
expect(resolveGatewayModel(`sam-desktop/${DUP}`)).toEqual({
baseUrl: SAM_URL,
wireModelId: DUP,
});
expect(resolveGatewayModel(`embedding/${DUP}`)).toEqual({
baseUrl: EMB_URL,
wireModelId: DUP,
});
});
it('resolves a bare id to the default provider, deterministically', () => {
expect(resolveGatewayModel(DUP)).toEqual({ baseUrl: SAM_URL, wireModelId: DUP });
});
it('rejects an unknown provider instead of silently falling back', () => {
const resolved = resolveGatewayModel(`no-such-host/${DUP}`);
expect(resolved).toHaveProperty('error');
});
describe('through the HTTP route', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify({ id: 'resp', choices: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('proxies each composite id to its own host with the bare wire id', async () => {
const app = Fastify();
registerLocalGatewayRoutes(app);
await app.ready();
try {
for (const composite of [`sam-desktop/${DUP}`, `embedding/${DUP}`]) {
const res = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: composite, stream: false, messages: [] },
});
expect(res.statusCode).toBe(200);
}
const urls = fetchMock.mock.calls.map((c) => String(c[0]));
expect(urls).toEqual([
`${SAM_URL}/v1/chat/completions`,
`${EMB_URL}/v1/chat/completions`,
]);
// The upstream body must carry the BARE wire id — llama-swap knows
// nothing about composite prefixes.
const upstreamModels = fetchMock.mock.calls.map(
(c) => (JSON.parse((c[1] as RequestInit).body as string) as { model: string }).model,
);
expect(upstreamModels).toEqual([DUP, DUP]);
} finally {
await app.close();
}
});
it('returns 400 for an unknown provider without touching any upstream', async () => {
const app = Fastify();
registerLocalGatewayRoutes(app);
await app.ready();
try {
const res = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: { model: `no-such-host/${DUP}`, stream: false, messages: [] },
});
expect(res.statusCode).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
});
});

View 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();
}
});
});

View File

@@ -0,0 +1,61 @@
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 { buildPiProviderEntry } from '../pi-config-sync.js';
import { loadLlamaProviders } from '../llama-providers.js';
describe('buildPiProviderEntry', () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'qwen3.6-35b' }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const file = {
defaultProvider: 'sam-desktop',
providers: [
{ id: 'sam-desktop', label: 'Sam Desktop', baseUrl: 'http://a.test:8401', kind: 'llama-swap' },
],
};
const path = join(tmpdir(), `llama-providers-pi-${Math.random().toString(36).slice(2)}.json`);
writeFileSync(path, JSON.stringify(file), 'utf8');
loadLlamaProviders(path, 'http://legacy.test:8080');
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('emits a Pi-routable provider with gateway baseUrl and composite model ids', async () => {
const entry = await buildPiProviderEntry('http://127.0.0.1:9502');
expect(entry.baseUrl).toBe('http://127.0.0.1:9502/v1');
expect(entry.api).toBe('openai-completions');
expect(entry.models?.map((m) => m.id)).toEqual(['sam-desktop/qwen3.6-35b']);
expect(entry.models?.[0]?.contextWindow).toBeGreaterThan(0);
expect(entry.models?.[0]?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
});
it('preserves hand-tuned per-model overrides on re-sync', async () => {
const existing = {
baseUrl: 'http://stale:1/v1',
models: [
{
id: 'sam-desktop/qwen3.6-35b',
name: 'Old Name',
contextWindow: 262_144,
maxTokens: 65_536,
},
],
};
const entry = await buildPiProviderEntry('http://127.0.0.1:9502', existing);
expect(entry.baseUrl).toBe('http://127.0.0.1:9502/v1'); // ours wins
const m = entry.models?.[0];
expect(m?.contextWindow).toBe(262_144); // hand-tuned values preserved
expect(m?.maxTokens).toBe(65_536);
});
});

View File

@@ -90,13 +90,13 @@ describe('getProviderSnapshot', () => {
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: 'local-model' }, { id: 'llama-swap/existing' }],
data: [{ id: 'local-model' }, { id: 'existing' }],
}),
}),
);
});
it('merges opencode ACP models with prefixed llama-swap models', async () => {
it('merges opencode ACP models with boocode-local prefixed registry models', async () => {
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'opencode/big-pickle', label: 'Big Pickle', isDefault: true }],
@@ -119,10 +119,11 @@ describe('getProviderSnapshot', () => {
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const opencode = entries.find((e) => e.name === 'opencode');
// W7: registry models are prefixed with boocode-local/ (D-6), not llama-swap/.
expect(opencode?.models.map((m) => m.id)).toEqual([
'opencode/big-pickle',
'llama-swap/local-model',
'llama-swap/existing',
'boocode-local/llama-swap/local-model',
'boocode-local/llama-swap/existing',
]);
expect(opencode?.commands.some((c) => c.name === 'help')).toBe(true);
expect(opencode?.commands.some((c) => c.name === 'custom')).toBe(true);

View File

@@ -4,7 +4,7 @@ import { exec as execCb, execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
import { resolveAcpProbeBinaries } from './acp-spawn.js';
import { clearProviderSnapshotCache, fetchLlamaSwapModels, prefixLlamaSwapModels } from './provider-snapshot.js';
import { clearProviderSnapshotCache, fetchRegistryModels, prefixBoocodeLocalModels } from './provider-snapshot.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { loadConfig } from '../config.js';
import { loadProviderConfig } from './provider-config-registry.js';
@@ -119,11 +119,12 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
}
if (providerDef?.mergeLlamaSwap) {
try {
const config = loadConfig();
const llamaModels = prefixLlamaSwapModels(await fetchLlamaSwapModels(config));
models = [...models, ...llamaModels];
// W7: use composite registry models with boocode-local prefix (D-6)
// instead of llama-swap-prefixed ids.
const registryModels = await fetchRegistryModels();
models = [...models, ...prefixBoocodeLocalModels(registryModels)];
} catch (err) {
log.warn({ agent: agentName, err: err instanceof Error ? err.message : String(err) }, 'agent-probe: llama-swap model fetch failed (non-fatal)');
log.warn({ agent: agentName, err: err instanceof Error ? err.message : String(err) }, 'agent-probe: registry model fetch failed (non-fatal)');
}
}
}

View File

@@ -87,8 +87,8 @@ interface AnalyzerDeps {
sql: Sql;
broker: Broker;
log: FastifyBaseLogger;
config: Pick<Config, 'LLAMA_SWAP_URL' | 'DEFAULT_MODEL'>;
/** Model IDs served by local llama-swap — cross-exam routing uses this. */
config: Pick<Config, 'DEFAULT_MODEL'>;
/** Model IDs served by local providers — cross-exam routing uses this. */
localModels: ReadonlySet<string>;
}
@@ -270,7 +270,7 @@ export function createAnalyzer(deps: AnalyzerDeps): Analyzer {
// ─── Model call routing ───────────────────────────────────────────────────
/**
* Route a one-shot model call to llama-swap (local) or the task dispatcher
* Route a one-shot model call to a local provider or the task dispatcher
* (cloud). Cloud dispatch inserts a tasks row and polls for completion.
*/
async function executeModelCall(opts: {
@@ -281,11 +281,12 @@ export function createAnalyzer(deps: AnalyzerDeps): Analyzer {
system: string;
user: string;
}): Promise<string> {
const isLocal = localModels.has(opts.model) || localModels.has(`llama-swap/${opts.model}`);
const isLocal =
localModels.has(opts.model) ||
localModels.has(`llama-swap/${opts.model}`);
if (isLocal) {
return arenaModelCall({
config,
model: opts.model,
system: opts.system,
user: opts.user,
@@ -374,7 +375,6 @@ export function createAnalyzer(deps: AnalyzerDeps): Analyzer {
let digest: string;
try {
digest = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system,
user,
@@ -404,7 +404,6 @@ export function createAnalyzer(deps: AnalyzerDeps): Analyzer {
let judgeOutput = '';
try {
judgeOutput = await arenaModelCall({
config,
model: config.DEFAULT_MODEL,
system,
user,

View File

@@ -0,0 +1,83 @@
/**
* Self-refreshing arena local-model set.
*
* The set's contents are rebuilt from the provider registry on an interval so
* a provider that was unreachable at coder startup is reclassified as local
* once it comes back — without a boocoder restart. The Set instance is stable
* (consumers hold a ReadonlySet reference); only its contents change.
*
* Merge semantics per refresh: a reachable provider replaces its own
* contribution; an unreachable provider keeps its last-known contribution
* (stale-but-local classification is safer than flipping to the cloud lane).
* Bare wire ids are contributed only by the default provider — bare ids
* resolve through defaultProvider at call time, so advertising another
* machine's models as bare would route them to the wrong host.
*/
import { getLlamaProviders, formatModelRef } from './llama-providers.js';
interface LogLike {
warn: (obj: unknown, msg: string) => void;
}
export interface LocalModelSetHandle {
/** Stable Set instance — pass this to analyzer/battle-runner deps. */
set: ReadonlySet<string>;
/** Fetch every provider's live model list and rebuild the set contents. */
refresh: () => Promise<void>;
/** Start periodic refresh. */
start: (intervalMs: number) => void;
/** Stop periodic refresh. */
stop: () => void;
}
export function createLocalModelSet(log: LogLike): LocalModelSetHandle {
const set = new Set<string>();
const contributions = new Map<string, Set<string>>();
let timer: NodeJS.Timeout | null = null;
async function refresh(): Promise<void> {
const { providers, defaultProvider } = getLlamaProviders();
await Promise.all(
providers.map(async (p) => {
try {
const res = await fetch(`${p.baseUrl}/v1/models`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return;
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
const contrib = new Set<string>();
for (const m of parsed.data ?? []) {
contrib.add(formatModelRef(p.id, m.id));
// Bare ids resolve via defaultProvider — only it contributes them.
if (p.id === defaultProvider) contrib.add(m.id);
}
contributions.set(p.id, contrib);
} catch (err) {
// Unreachable — keep the last-known contribution.
log.warn(
{ provider: p.id, err: err instanceof Error ? err.message : String(err) },
'arena-local-models: provider unreachable; keeping last-known model set',
);
}
}),
);
set.clear();
for (const contrib of contributions.values()) {
for (const id of contrib) set.add(id);
}
}
return {
set,
refresh,
start(intervalMs: number) {
if (timer) return;
timer = setInterval(() => void refresh(), intervalMs);
timer.unref?.();
},
stop() {
if (timer) clearInterval(timer);
timer = null;
},
};
}

View File

@@ -1,35 +1,56 @@
/**
* One-shot model completion for the Arena analyzer.
*
* Calls the local llama-swap server directly for a single non-streaming
* completion. Used for the digest and judge stages (always DEFAULT_MODEL)
* and for local-model cross-examinations (any local model).
* Resolves a model id (composite "provider/model" or bare) against the
* provider registry, then calls the correct provider's baseUrl directly.
* Used for the digest and judge stages (always DEFAULT_MODEL) and for
* local-model cross-examinations (any local model).
*
* Mirrors apps/server/src/services/task-model.ts but targets the coder's
* config shape and uses a longer timeout appropriate for analysis calls.
*/
import type { Config } from '../config.js';
import {
parseModelRef as parseModelRefBase,
getLlamaProviders,
} from './llama-providers.js';
const TIMEOUT_MS = 120_000;
/**
* Resolve a model id to { baseUrl, wireModelId } against the provider registry.
* Composite "provider/model" is parsed; bare ids resolve to the default provider.
*/
export function resolveModelEndpoint(
model: string,
): { baseUrl: string; wireModelId: string } {
const ref = parseModelRefBase(model);
const providers = getLlamaProviders();
const provider = providers.providers.find((p) => p.id === ref.providerId);
if (!provider) {
throw new Error(`unknown provider: ${ref.providerId} (model: ${model})`);
}
return { baseUrl: provider.baseUrl, wireModelId: ref.wireModelId };
}
export async function arenaModelCall(opts: {
config: Pick<Config, 'LLAMA_SWAP_URL'>;
model: string;
system: string;
user: string;
maxTokens?: number;
temperature?: number;
}): Promise<string> {
const { config, model, system, user } = opts;
const { model, system, user } = opts;
const maxTokens = opts.maxTokens ?? 2_000;
const temperature = opts.temperature ?? 0.3;
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/chat/completions`, {
const { baseUrl, wireModelId } = resolveModelEndpoint(model);
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Boo-Source': 'arena' },
body: JSON.stringify({
model,
model: wireModelId,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
@@ -44,7 +65,7 @@ export async function arenaModelCall(opts: {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`llama-swap responded ${res.status}: ${text.slice(0, 200)}`);
throw new Error(`model endpoint responded ${res.status}: ${text.slice(0, 200)}`);
}
const data = (await res.json()) as {

View File

@@ -593,9 +593,9 @@ function parseModel(model: string | undefined): { providerID: string; modelID: s
if (idx > 0 && idx < trimmed.length - 1) {
return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) };
}
// No slash but non-empty → infer llama-swap (the only configured provider).
// No slash but non-empty → infer boocode-local (W7: the gateway namespace).
if (idx < 0 && trimmed.length > 0) {
return { providerID: 'llama-swap', modelID: trimmed };
return { providerID: 'boocode-local', modelID: trimmed };
}
return undefined;
}

View File

@@ -31,6 +31,7 @@ import {
} from './finalize-message.js';
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
import { emitHook } from '../plugins/host.js';
import { parseModelRef } from './llama-providers.js';
interface InferenceRunner {
enqueue: (
@@ -1003,12 +1004,26 @@ export function createDispatcher(deps: Deps): {
}
};
// opencode expects provider-prefixed model ids (e.g. 'llama-swap/qwen3.6-35b…').
// DEFAULT_MODEL is bare (no prefix) because native inference uses it directly
// against llama-swap. Coalesce empty string (frontend sends '' when no models
// listed) and prefix bare ids so parseModel always succeeds.
// W7: opencode now uses the boocode-local gateway (D-6). The model string
// is "boocode-local/<provider>/<wire-model>" — parseModel splits only on
// the FIRST "/" so the inner composite survives. Coalesce empty string
// (frontend sends '' when no models listed) and wrap bare ids with the
// default provider composite so parseModel always succeeds.
const rawModel = (task.model && task.model.trim()) || config.DEFAULT_MODEL;
const model = rawModel.includes('/') ? rawModel : `llama-swap/${rawModel}`;
let model: string;
if (rawModel.includes('/')) {
// Already composite (e.g. "sam-desktop/qwen3.6-35b" from the frontend
// or "boocode-local/sam-desktop/qwen3.6-35b" from the snapshot).
// If it already has the boocode-local prefix, use as-is.
// If it's a bare composite (provider/model), wrap in boocode-local/.
model = rawModel.startsWith('boocode-local/')
? rawModel
: `boocode-local/${rawModel}`;
} else {
// Bare model id — wrap with default provider composite.
const ref = parseModelRef(rawModel);
model = `boocode-local/${ref.providerId}/${ref.wireModelId}`;
}
const backend = getOpenCodeBackend(installPath);
const handle = await backend.ensureSession(sessionId, {
agent,

View File

@@ -0,0 +1,102 @@
/**
* vMultiProvider local provider registry loader (coder-side).
*
* Reads the shared `/data/llama-providers.json` (or `LLAMA_PROVIDERS_PATH`) at
* startup and caches the parsed result. When the file is absent or invalid,
* synthesizes a single legacy provider from `LLAMA_SWAP_URL` so both apps
* start with only legacy env vars (D-1).
*
* Schema and pure helpers live in @boocode/contracts/llama-providers.
* File I/O stays app-local per D-1.
*/
import { readFileSync } from 'node:fs';
import {
LlamaProvidersFileSchema,
type LlamaProvidersFile,
type LlamaProvider,
type ParsedModelRef,
parseModelRef as parseModelRefBase,
formatModelRef,
} from '@boocode/contracts/llama-providers';
export type { LlamaProvidersFile, LlamaProvider, ParsedModelRef };
export { formatModelRef };
/** Synthesize a single legacy provider from env vars. */
function buildLegacyProvider(llamaSwapUrl: string): LlamaProvidersFile {
return {
defaultProvider: 'llama-swap',
providers: [
{
id: 'llama-swap',
label: 'llama-swap',
baseUrl: llamaSwapUrl,
kind: 'llama-swap',
},
],
};
}
let cached: LlamaProvidersFile | null = null;
/**
* Load (or re-load) the local provider config. Never throws on bad input —
* falls back to the legacy single-provider shape.
*/
export function loadLlamaProviders(
providersPath: string | undefined,
llamaSwapUrl: string,
): LlamaProvidersFile {
if (!providersPath) {
cached = buildLegacyProvider(llamaSwapUrl);
return cached;
}
let raw: string;
try {
raw = readFileSync(providersPath, 'utf8');
} catch {
console.warn(
`llama-providers: file not found at ${providersPath} — falling back to legacy single-provider`,
);
cached = buildLegacyProvider(llamaSwapUrl);
return cached;
}
let json: unknown;
try {
json = JSON.parse(raw);
} catch (err) {
console.error(
`llama-providers: invalid JSON in ${providersPath} — falling back to legacy single-provider`,
err,
);
cached = buildLegacyProvider(llamaSwapUrl);
return cached;
}
const parsed = LlamaProvidersFileSchema.safeParse(json);
if (!parsed.success) {
console.error(
`llama-providers: schema validation failed for ${providersPath} — falling back to legacy single-provider`,
parsed.error.flatten(),
);
cached = buildLegacyProvider(llamaSwapUrl);
return cached;
}
cached = parsed.data;
return cached;
}
/** The cached provider config. Returns legacy fallback if nothing loaded yet. */
export function getLlamaProviders(): LlamaProvidersFile {
return cached ?? buildLegacyProvider('http://localhost:8080');
}
/**
* Convenience: parse a model ref against the cached default provider.
*/
export function parseModelRef(ref: string): ParsedModelRef {
return parseModelRefBase(ref, getLlamaProviders().defaultProvider);
}

View File

@@ -0,0 +1,145 @@
/**
* W7: BooCoder-hosted OpenAI-compatible local-model gateway.
*
* Accepts composite local model ids ("sam-desktop/qwen3.6-35b"), parses them
* via the provider registry, and proxies the request to the correct provider's
* baseUrl with the bare wire model id. Unknown provider → 400.
*
* Presented to opencode as ONE stable provider namespace "boocode-local".
* The inner modelID carries the composite local identity so duplicate wire
* names across providers remain unambiguous end-to-end (D-6).
*/
import { once } from 'node:events';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { parseModelRef, getLlamaProviders } from './llama-providers.js';
import { fetchRegistryModels } from './provider-snapshot.js';
import type { ProviderModel } from './provider-types.js';
/**
* Resolve a composite model id to the upstream provider's baseUrl + wire model id.
*/
export function resolveGatewayModel(
model: string,
): { baseUrl: string; wireModelId: string } | { error: string } {
const ref = parseModelRef(model);
const providers = getLlamaProviders();
const provider = providers.providers.find((p) => p.id === ref.providerId);
if (!provider) {
return { error: `unknown provider: ${ref.providerId} (model: ${model})` };
}
return { baseUrl: provider.baseUrl, wireModelId: ref.wireModelId };
}
/**
* Handle POST /v1/chat/completions — proxy to the correct local provider.
*/
async function handleChatCompletions(
req: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body.model !== 'string') {
return reply.code(400).send({ error: 'missing or invalid "model" field' });
}
const modelStr = body.model;
const resolved = resolveGatewayModel(modelStr);
if ('error' in resolved) {
return reply.code(400).send({ error: resolved.error });
}
const { baseUrl, wireModelId } = resolved;
// Build upstream request body with the bare wire model id.
const upstreamBody = { ...body, model: wireModelId };
// Abort the upstream call if the client disconnects, so a cancelled turn
// doesn't keep the GPU generating to completion.
const clientGone = new AbortController();
reply.raw.once('close', () => clientGone.abort());
// Forward the client's Authorization header when present (future-proofing
// for authed upstreams; llama-swap ignores it today).
const auth = req.headers.authorization;
// Forward inbound X-Boo-Source header for per-consumer attribution (P4).
// Default to 'boocoder' when not present (opencode dispatch path).
const booSource = (req.headers['x-boo-source'] as string | undefined) ?? 'boocoder';
let upstreamRes: Response;
try {
upstreamRes = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(auth ? { Authorization: auth } : {}),
'X-Boo-Source': booSource,
},
body: JSON.stringify(upstreamBody),
signal: AbortSignal.any([AbortSignal.timeout(300_000), clientGone.signal]),
});
} catch (err) {
if (clientGone.signal.aborted) return; // client went away; nothing to answer
req.log.error({ err, baseUrl, model: modelStr }, 'local-gateway: upstream fetch failed');
return reply.code(502).send({
error: `upstream provider unreachable: ${err instanceof Error ? err.message : String(err)}`,
});
}
// Pipe the upstream response status + headers + body to the client.
const status = upstreamRes.status;
const contentType = upstreamRes.headers.get('content-type') ?? 'application/json';
if (body.stream) {
// Streaming: pipe the response body with backpressure — pause reading the
// upstream when the client socket's buffer is full.
reply.raw.writeHead(status, { 'content-type': contentType });
if (upstreamRes.body) {
const reader = upstreamRes.body.getReader();
try {
while (!clientGone.signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
if (!reply.raw.write(value)) await once(reply.raw, 'drain');
}
} catch (err) {
if (!clientGone.signal.aborted) {
req.log.error({ err, baseUrl, model: modelStr }, 'local-gateway: stream relay failed');
}
} finally {
reply.raw.end();
}
} else {
reply.raw.end();
}
} else {
// Non-streaming: relay the full JSON response.
const data = await upstreamRes.json().catch(() => null);
if (data === null) {
return reply.code(status === 200 ? 502 : status).send({
error: { message: 'upstream returned a non-JSON response', code: status },
});
}
reply.code(status).header('content-type', contentType).send(data);
}
}
/**
* Handle GET /v1/models — live composite model list fetched from every
* provider in the registry (same source as the provider snapshot).
*/
async function handleModels(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const models: ProviderModel[] = await fetchRegistryModels();
reply.send({
object: 'list',
data: models.map((m) => ({ id: m.id, object: 'model', owned_by: 'boocode-local' })),
});
}
/**
* Register the local-model gateway routes on the coder's Fastify instance.
*/
export function registerLocalGatewayRoutes(app: FastifyInstance): void {
app.post('/v1/chat/completions', handleChatCompletions);
app.get('/v1/models', handleModels);
}

View File

@@ -0,0 +1,105 @@
/**
* W7: Sync the boocode-local provider into opencode's config file.
*
* opencode validates model strings against its own config at
* `~/.config/opencode/opencode.json` — the model must be a key in the
* provider's `models` object map (Record<modelID, ModelConfig>), and a custom
* provider needs `npm` (the AI-SDK package) plus `options.baseURL` to be
* routable. This module writes/updates the boocode-local provider entry so
* opencode accepts composite local model ids and routes them to the gateway.
*
* The gateway URL derives from the coder's own HOST/PORT config.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { fetchRegistryModels } from './provider-snapshot.js';
const OPENCODE_CONFIG_DIR = join(homedir(), '.config', 'opencode');
const OPENCODE_CONFIG_FILE = join(OPENCODE_CONFIG_DIR, 'opencode.json');
export interface OpencodeProviderConfig {
enabled?: boolean;
npm?: string;
name?: string;
options?: { baseURL?: string; [key: string]: unknown };
models?: Record<string, { name?: string }>;
}
export interface OpencodeConfig {
provider?: Record<string, OpencodeProviderConfig>;
[key: string]: unknown;
}
/**
* Build the boocode-local provider config for opencode.
*
* `gatewayUrl` is the URL where the local gateway listens (e.g.
* "http://127.0.0.1:9502"). The provider models are composite local ids
* like "sam-desktop/qwen3.6-35b".
*/
export async function buildBoocodeLocalProviderConfig(
gatewayUrl: string,
): Promise<OpencodeProviderConfig> {
// Fetch live model lists from every provider in the registry.
const registryModels = await fetchRegistryModels();
return {
enabled: true,
npm: '@ai-sdk/openai-compatible',
name: 'BooCode Local',
options: { baseURL: `${gatewayUrl}/v1` },
models: Object.fromEntries(registryModels.map((m) => [m.id, { name: m.label }])),
};
}
/**
* Read the current opencode config, merge the boocode-local provider, and
* write it back. Idempotent — re-running with the same gatewayUrl is safe.
*
* Returns the updated config or null on read/write errors (logged, not thrown).
*/
export async function syncOpencodeConfig(
gatewayUrl: string,
log: { warn: (obj: unknown, msg: string) => void; info: (obj: unknown, msg: string) => void },
): Promise<OpencodeConfig | null> {
// Read existing config (or start fresh).
let config: OpencodeConfig = {};
try {
const raw = readFileSync(OPENCODE_CONFIG_FILE, 'utf8');
config = JSON.parse(raw) as OpencodeConfig;
} catch {
// File missing or invalid JSON — start with empty config.
}
// Ensure provider object exists.
if (!config.provider) config.provider = {};
// Build the boocode-local provider config.
const providerConfig = await buildBoocodeLocalProviderConfig(gatewayUrl);
// Merge per-field: preserve any hand-added fields/options on the existing
// entry; ours win for the fields we own (npm, baseURL, models).
const existing = config.provider['boocode-local'] ?? {};
config.provider['boocode-local'] = {
...existing,
...providerConfig,
options: { ...existing.options, ...providerConfig.options },
};
// Write back.
try {
mkdirSync(dirname(OPENCODE_CONFIG_FILE), { recursive: true });
writeFileSync(OPENCODE_CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf8');
log.info(
{ path: OPENCODE_CONFIG_FILE, modelCount: Object.keys(providerConfig.models ?? {}).length },
'opencode-config-sync: wrote boocode-local provider',
);
return config;
} catch (err) {
log.warn(
{ err: err instanceof Error ? err.message : String(err), path: OPENCODE_CONFIG_FILE },
'opencode-config-sync: failed to write config',
);
return null;
}
}

View File

@@ -0,0 +1,119 @@
/**
* Sync the boocode-local provider into Pi's config file.
*
* Pi (~/.pi/agent/models.json) defines custom OpenAI-compatible providers as
* `providers.<id> = { baseUrl, api, apiKey, models: [{ id, name, ... }] }`.
* This writes/updates a `boocode-local` entry pointing at the BooCoder local
* gateway with the composite local model ids, so Pi can target every machine
* in the llama-providers registry (same identity story as opencode, D-6).
*
* Merge semantics: other providers are untouched; within boocode-local,
* per-model contextWindow/maxTokens/cost overrides on existing entries are
* preserved (we only own id/name and the provider-level routing fields).
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { fetchRegistryModels } from './provider-snapshot.js';
const PI_MODELS_FILE = join(homedir(), '.pi', 'agent', 'models.json');
interface PiModelEntry {
id: string;
name: string;
contextWindow?: number;
maxTokens?: number;
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
[key: string]: unknown;
}
export interface PiProviderConfig {
baseUrl?: string;
api?: string;
apiKey?: string;
compat?: Record<string, unknown>;
models?: PiModelEntry[];
[key: string]: unknown;
}
export interface PiModelsConfig {
providers?: Record<string, PiProviderConfig>;
[key: string]: unknown;
}
// Conservative defaults for llama-swap models; Pi treats these as caps, and a
// model whose real window differs can be hand-tuned — the merge preserves it.
const DEFAULT_CONTEXT_WINDOW = 131_072;
const DEFAULT_MAX_TOKENS = 32_768;
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
/** Build the boocode-local provider entry for Pi. */
export async function buildPiProviderEntry(
gatewayUrl: string,
existing?: PiProviderConfig,
): Promise<PiProviderConfig> {
const registryModels = await fetchRegistryModels();
const prior = new Map((existing?.models ?? []).map((m) => [m.id, m]));
return {
...existing,
baseUrl: `${gatewayUrl}/v1`,
api: 'openai-completions',
apiKey: 'dummy',
compat: existing?.compat ?? {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
},
models: registryModels.map((m) => {
const old = prior.get(m.id);
return {
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
cost: ZERO_COST,
...old,
id: m.id,
name: m.label,
};
}),
};
}
/**
* Read Pi's models.json, merge the boocode-local provider, write it back.
* Never throws — returns null on failure (logged).
*/
export async function syncPiConfig(
gatewayUrl: string,
log: { warn: (obj: unknown, msg: string) => void; info: (obj: unknown, msg: string) => void },
): Promise<PiModelsConfig | null> {
let config: PiModelsConfig = {};
try {
config = JSON.parse(readFileSync(PI_MODELS_FILE, 'utf8')) as PiModelsConfig;
} catch {
// Missing or invalid — start fresh (Pi tolerates a providers-only file).
}
if (!config.providers) config.providers = {};
try {
config.providers['boocode-local'] = await buildPiProviderEntry(
gatewayUrl,
config.providers['boocode-local'],
);
mkdirSync(dirname(PI_MODELS_FILE), { recursive: true });
writeFileSync(PI_MODELS_FILE, JSON.stringify(config, null, 2) + '\n', 'utf8');
log.info(
{
path: PI_MODELS_FILE,
modelCount: config.providers['boocode-local'].models?.length ?? 0,
},
'pi-config-sync: wrote boocode-local provider',
);
return config;
} catch (err) {
log.warn(
{ err: err instanceof Error ? err.message : String(err), path: PI_MODELS_FILE },
'pi-config-sync: failed to write config',
);
return null;
}
}

View File

@@ -17,6 +17,7 @@ import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js';
import { discoverClaudeCommands } from './claude-command-discovery.js';
import { getLlamaProviders, formatModelRef } from './llama-providers.js';
interface AgentRow {
name: string;
@@ -63,6 +64,50 @@ export async function fetchLlamaSwapModels(config: Config): Promise<ProviderMode
}
}
/** Fetch the /v1/models list from an arbitrary baseUrl. */
async function fetchModelsFromUrl(baseUrl: string): Promise<ProviderModel[]> {
try {
const res = await fetch(`${baseUrl}/v1/models`);
if (!res.ok) return [];
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
} catch {
return [];
}
}
/**
* Fetch models from every provider in the shared registry, returning composite
* `provider/model` ids. Used by the native boocode provider to expose the full
* multi-provider local model set (W5).
*/
export async function fetchRegistryModels(defaultModel?: string): Promise<ProviderModel[]> {
const providers = getLlamaProviders();
const results = await Promise.allSettled(
providers.providers.map(async (p) => {
const models = await fetchModelsFromUrl(p.baseUrl);
return models.map((m) => ({
id: formatModelRef(p.id, m.id),
label: m.label,
}));
}),
);
const all: ProviderModel[] = [];
for (const r of results) {
if (r.status === 'fulfilled') all.push(...r.value);
}
// Hoist the default model to the front for the picker default selection.
if (defaultModel) {
const i = all.findIndex((m) => {
// Match by wire id suffix (e.g. "sam-desktop/qwen3.6-35b" ends with "/qwen3.6-35b")
// or exact match for bare ids that slipped through.
return m.id === defaultModel || m.id.endsWith(`/${defaultModel}`);
});
if (i > 0) all.unshift(all.splice(i, 1)[0]!);
}
return all;
}
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
return models.map((m) => ({
@@ -71,6 +116,20 @@ export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[]
}));
}
/**
* W7: Wrap registry composite model ids with the boocode-local provider
* namespace for opencode. Input ids are already composite "provider/model"
* (e.g. "sam-desktop/qwen3.6-35b"); this wraps them as
* "boocode-local/sam-desktop/qwen3.6-35b" so opencode routes through the
* local gateway (D-6).
*/
export function prefixBoocodeLocalModels(models: ProviderModel[]): ProviderModel[] {
return models.map((m) => ({
...m,
id: m.id.startsWith('boocode-local/') ? m.id : `boocode-local/${m.id}`,
}));
}
function attachClaudeThinking(models: ProviderModel[]): ProviderModel[] {
const thinking = PROVIDER_MANIFEST.claude?.thinkingOptions;
if (!thinking?.length) return models;
@@ -98,6 +157,7 @@ async function buildProviderEntry(
resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined,
llamaModels: ProviderModel[],
registryModels: ProviderModel[],
cwd: string,
ttlMs: number,
force: boolean,
@@ -138,13 +198,13 @@ async function buildProviderEntry(
};
}
// 2. Native boocode → always ready (llama-swap models). Exposes the unified
// permission modes (plan/ask/bypass) so the composer's permission picker works
// for native BooCode too; `bypass` auto-applies staged edits (dispatcher.ts).
// 2. Native boocode → always ready (multi-provider local models from the
// shared registry). Exposes composite provider/model ids so the UI can group
// by provider and dispatch routes to the correct upstream.
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels),
enabled: true, installed: true, models: withConfigModels(registryModels),
modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
}
@@ -201,7 +261,9 @@ async function buildProviderEntry(
if (!runTier2) {
let skipModels = agentRow?.models ?? [];
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
// W7: use composite registry models with boocode-local prefix (D-6)
// instead of llama-swap-prefixed ids.
skipModels = mergeModels(skipModels, prefixBoocodeLocalModels(registryModels));
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
skipModels = llamaModels;
}
@@ -223,7 +285,8 @@ async function buildProviderEntry(
}
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
// W7: use composite registry models with boocode-local prefix (D-6).
probeModels = mergeModels(nativeModels, prefixBoocodeLocalModels(registryModels));
}
return {
@@ -272,9 +335,10 @@ export async function getProviderSnapshot(
}
const build = async (): Promise<ProviderSnapshotEntry[]> => {
const [llamaModels, deepseekModels] = await Promise.all([
const [llamaModels, deepseekModels, registryModels] = await Promise.all([
fetchLlamaSwapModels(config),
fetchDeepSeekModels(config),
fetchRegistryModels(config.DEFAULT_MODEL),
]);
// Merge DeepSeek models into the llama-swap model pool so the boocode
// provider (which sources from llama-swap) also includes DeepSeek models.
@@ -287,7 +351,7 @@ export async function getProviderSnapshot(
const entries = await Promise.all(
[...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(resolved, agentMap.get(resolved.id), mergedModels, resolvedCwd, ttlMs, force),
buildProviderEntry(resolved, agentMap.get(resolved.id), mergedModels, registryModels, resolvedCwd, ttlMs, force),
),
);