coder(providers): v2.3 provider-lifecycle phase 4 — config HTTP API (diagnostic returns JSON)

GET/PATCH /api/providers/config, subset POST /refresh, and
GET /api/providers/:id/diagnostic (JSON { diagnostic }, §6.4). PATCH order
is validate→save→reload→clear; a malformed body or invalid merged config
returns 422 without writing, and a save failure returns 500 without
reloading (no file/registry divergence). Web client + types extended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 17:46:56 +00:00
parent 2d997ecb6c
commit f302969c71
10 changed files with 678 additions and 5 deletions

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import {
mergeProviderConfigPatch,
ProviderConfigPatchSchema,
CoderProvidersFileSchema,
type CoderProvidersFile,
} from '../provider-config.js';
describe('ProviderConfigPatchSchema', () => {
it('accepts a per-provider override patch', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: false } } });
expect(parsed.success).toBe(true);
});
it('accepts a null value (delete-the-override sentinel)', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: null } });
expect(parsed.success).toBe(true);
});
it('defaults providers to {} on an empty body', () => {
const parsed = ProviderConfigPatchSchema.safeParse({});
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.providers).toEqual({});
});
it('rejects a malformed override (wrong field type)', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: 'yes' } } });
expect(parsed.success).toBe(false);
});
it('rejects a non-object providers map', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: 123 });
expect(parsed.success).toBe(false);
});
});
describe('mergeProviderConfigPatch', () => {
const current: CoderProvidersFile = {
providers: {
goose: { enabled: true, label: 'Goose' },
opencode: { enabled: true },
},
};
it('replaces an existing override object wholesale (not deep-merge)', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
// Whole override replaced — the prior `label` is gone, only `enabled` remains.
expect(merged.providers.goose).toEqual({ enabled: false });
});
it('adds a brand-new override id', () => {
const merged = mergeProviderConfigPatch(current, {
providers: { 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp'] } },
});
expect(merged.providers['amp-acp']).toEqual({ extends: 'acp', label: 'Amp', command: ['amp-acp'] });
});
it('deletes an override when the value is null', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: null } });
expect(merged.providers.goose).toBeUndefined();
expect(Object.keys(merged.providers)).toEqual(['opencode']);
});
it('leaves ids absent from the patch untouched', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
expect(merged.providers.opencode).toEqual({ enabled: true });
});
it('does not mutate the input config', () => {
const snapshot = JSON.parse(JSON.stringify(current));
mergeProviderConfigPatch(current, { providers: { goose: null, opencode: { enabled: false } } });
expect(current).toEqual(snapshot);
});
it('empty patch returns an equivalent config', () => {
const merged = mergeProviderConfigPatch(current, { providers: {} });
expect(merged).toEqual(current);
});
});
describe('CoderProvidersFileSchema (validate-before-save guard)', () => {
it('accepts a clean merged config', () => {
const merged = mergeProviderConfigPatch(
{ providers: {} },
{ providers: { goose: { enabled: false } } },
);
expect(CoderProvidersFileSchema.safeParse(merged).success).toBe(true);
});
it('rejects a config carrying an invalid override (never written)', () => {
// A merged object that somehow holds a bad override must fail validation
// so the PATCH route returns 422 and never calls save().
const invalid = { providers: { goose: { enabled: 'nope' } } };
expect(CoderProvidersFileSchema.safeParse(invalid).success).toBe(false);
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { getProviderDiagnostic, type DiagnosticAgentRow } from '../provider-diagnostic.js';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import { PROVIDERS } from '../provider-registry.js';
import type { ProviderSnapshotEntry } from '../provider-types.js';
const registry = buildResolvedRegistry(PROVIDERS, {
providers: {
goose: { enabled: false },
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'] },
},
});
const alwaysAvailable = () => Promise.resolve(true);
const neverAvailable = () => Promise.resolve(false);
describe('getProviderDiagnostic', () => {
it('reports a disabled built-in (enabled:false, no install)', async () => {
const report = await getProviderDiagnostic(registry.get('goose')!, undefined, {
checkAvailable: neverAvailable,
});
expect(report).toContain('provider: goose');
expect(report).toContain('enabled: false');
expect(report).toContain('installed: false');
expect(report).toMatch(/command_available:\s*false/);
});
it('reports an installed built-in with its install_path, last_probed_at, model count', async () => {
const agentRow: DiagnosticAgentRow = {
name: 'opencode',
install_path: '/usr/bin/opencode',
supports_acp: true,
models: [
{ id: 'm1', label: 'M1' },
{ id: 'm2', label: 'M2' },
],
last_probed_at: '2026-05-29T12:00:00.000Z',
};
const report = await getProviderDiagnostic(registry.get('opencode')!, agentRow, {
checkAvailable: alwaysAvailable,
});
expect(report).toContain('install_path: /usr/bin/opencode');
expect(report).toContain('2026-05-29T12:00:00.000Z');
expect(report).toContain('installed: true');
expect(report).toMatch(/models_in_db:\s*2/);
expect(report).toMatch(/command_available:\s*true/);
});
it('reports a custom ACP launch command + its binary', async () => {
const report = await getProviderDiagnostic(registry.get('amp-acp')!, undefined, {
checkAvailable: alwaysAvailable,
});
expect(report).toContain('provider: amp-acp');
expect(report).toContain('amp-acp --acp');
expect(report).toContain('customAcp: true');
});
it('surfaces the last probe error from a cached snapshot entry', async () => {
const cachedEntry: ProviderSnapshotEntry = {
name: 'opencode',
label: 'OpenCode',
transport: 'acp',
status: 'error',
enabled: true,
installed: true,
models: [],
modes: [],
defaultModeId: null,
commands: [],
error: 'ACP initialize timed out',
};
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
cachedEntry,
checkAvailable: alwaysAvailable,
});
expect(report).toContain('ACP initialize timed out');
});
it('reports no error when none is cached', async () => {
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
checkAvailable: alwaysAvailable,
});
expect(report).toMatch(/last_probe_error:\s*\(none/);
});
});

View File

@@ -7,6 +7,7 @@ import {
prefixLlamaSwapModels,
clearProviderSnapshotCache,
getProviderSnapshot,
peekSnapshotEntry,
} from '../provider-snapshot.js';
import { loadProviderConfig } from '../provider-config-registry.js';
@@ -324,6 +325,18 @@ describe('getProviderSnapshot', () => {
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
});
it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => {
loadConfigFixture({});
// Cold cache → undefined (no build triggered).
expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined();
const sql = mockSql([]);
await getProviderSnapshot(sql, config, '/tmp/peek', true);
expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode');
expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined();
});
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({

View File

@@ -29,6 +29,41 @@ export const CoderProvidersFileSchema = z.object({
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
/**
* PATCH body schema (design.md §6.2). A partial providers map where each value
* is either a full override object (REPLACES that id's override) or `null`
* (DELETES the override → revert to the built-in default). Ids absent from the
* patch are left untouched. The route validates the body against this first
* (malformed → 422) so a bad shape can never reach the merge/save step.
*/
export const ProviderConfigPatchSchema = z.object({
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
});
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
/**
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
* `patch.providers` REPLACES that id's override object wholesale (NOT a deep
* field merge); a `null` value DELETES the override. Returns a new object —
* never mutates `current`. The result is a plain CoderProvidersFile (no nulls),
* which the route re-validates against CoderProvidersFileSchema before save.
*/
export function mergeProviderConfigPatch(
current: CoderProvidersFile,
patch: ProviderConfigPatch,
): CoderProvidersFile {
const providers: Record<string, ProviderOverride> = { ...current.providers };
for (const [id, override] of Object.entries(patch.providers)) {
if (override === null) {
delete providers[id];
} else {
providers[id] = override;
}
}
return { providers };
}
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
export function load(path: string): CoderProvidersFile {
let raw: string;

View File

@@ -0,0 +1,71 @@
/**
* v2.3 Phase 4 (design.md §8) — per-provider plaintext diagnostic report.
*
* Read-only by default: reports CACHED state (resolved registry def + the
* available_agents row + the warm snapshot-cache entry) plus a `which`-style
* PATH check for the launch binary. It does NOT spawn an ACP probe — §8 lists
* the live initialize probe as optional, and the route defaults to cached state.
*
* A template string is the whole formatter (no Paseo diagnostic-utils port).
*/
import type { ResolvedProviderDef } from './provider-config-registry.js';
import type { ProviderSnapshotEntry, ProviderModel } from './provider-types.js';
import { isCommandAvailable } from './command-availability.js';
/** The subset of an `available_agents` row the diagnostic reads. */
export interface DiagnosticAgentRow {
name: string;
install_path: string | null;
supports_acp?: boolean;
models?: ProviderModel[] | null;
last_probed_at?: string | Date | null;
}
interface DiagnosticOpts {
/** Warm snapshot-cache entry (read-only peek) — source of the last probe error. */
cachedEntry?: ProviderSnapshotEntry;
/** Injectable PATH check (defaults to the real `which`); stubbed in tests. */
checkAvailable?: (binary: string) => Promise<boolean>;
}
/** Resolve the binary the dispatcher would launch (for the PATH check + report). */
function resolveBinary(resolved: ResolvedProviderDef, agentRow: DiagnosticAgentRow | undefined): string {
return resolved.launchCommand?.[0] ?? agentRow?.install_path ?? resolved.id;
}
export async function getProviderDiagnostic(
resolved: ResolvedProviderDef,
agentRow: DiagnosticAgentRow | undefined,
opts: DiagnosticOpts = {},
): Promise<string> {
const checkAvailable = opts.checkAvailable ?? isCommandAvailable;
const installed = agentRow?.install_path != null;
const binary = resolveBinary(resolved, agentRow);
// boocode is native (no binary to launch) — short-circuit the PATH check.
const commandAvailable = resolved.transport === 'native' ? true : await checkAvailable(binary);
const lastProbedAt =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).toISOString() : '(never)';
const modelCount = agentRow?.models?.length ?? 0;
const launchCommand = resolved.launchCommand
? resolved.launchCommand.join(' ')
: '(built-in default, resolved at dispatch)';
const lastError = opts.cachedEntry?.error ?? '(none recorded)';
return [
`provider: ${resolved.id}`,
`label: ${resolved.configLabel ?? resolved.label}`,
`transport: ${resolved.transport}`,
`enabled: ${resolved.enabled}`,
`builtin: ${resolved.isBuiltin}`,
`customAcp: ${resolved.isCustomAcp}`,
`installed: ${installed}`,
`install_path: ${agentRow?.install_path ?? '(none)'}`,
`binary: ${binary}`,
`command_available: ${commandAvailable}`,
`launch_command: ${launchCommand}`,
`supports_acp: ${agentRow?.supports_acp ?? '(unknown)'}`,
`last_probed_at: ${lastProbedAt}`,
`models_in_db: ${modelCount}`,
`last_probe_error: ${lastError}`,
].join('\n');
}

View File

@@ -283,6 +283,16 @@ export function clearProviderSnapshotCache(): void {
snapshotInflight.clear();
}
/**
* Read-only peek into the warm snapshot cache for one provider (no build, no
* probe). Used by the diagnostic route to report the last computed probe error
* without spawning anything. Returns undefined on a cold cache / unknown name.
*/
export function peekSnapshotEntry(name: string, cwd?: string): ProviderSnapshotEntry | undefined {
const resolvedCwd = cwd?.trim() || homedir();
return snapshotCache.get(resolvedCwd)?.entries.find((e) => e.name === name);
}
/** Persist probed model lists back to available_agents for fast legacy reads. */
export async function persistProbedModels(
sql: Sql,