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:
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
98
apps/coder/src/services/__tests__/arena-local-models.test.ts
Normal file
98
apps/coder/src/services/__tests__/arena-local-models.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
|
||||
124
apps/coder/src/services/__tests__/local-gateway-routing.test.ts
Normal file
124
apps/coder/src/services/__tests__/local-gateway-routing.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
61
apps/coder/src/services/__tests__/pi-config-sync.test.ts
Normal file
61
apps/coder/src/services/__tests__/pi-config-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
83
apps/coder/src/services/arena-local-models.ts
Normal file
83
apps/coder/src/services/arena-local-models.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
102
apps/coder/src/services/llama-providers.ts
Normal file
102
apps/coder/src/services/llama-providers.ts
Normal 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);
|
||||
}
|
||||
145
apps/coder/src/services/local-gateway.ts
Normal file
145
apps/coder/src/services/local-gateway.ts
Normal 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);
|
||||
}
|
||||
105
apps/coder/src/services/opencode-config-sync.ts
Normal file
105
apps/coder/src/services/opencode-config-sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
119
apps/coder/src/services/pi-config-sync.ts
Normal file
119
apps/coder/src/services/pi-config-sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user