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>
128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import type { Config } from '../config.js';
|
|
import {
|
|
getProviderSnapshot,
|
|
clearProviderSnapshotCache,
|
|
peekSnapshotEntry,
|
|
} from '../services/provider-snapshot.js';
|
|
import {
|
|
load,
|
|
save,
|
|
CoderProvidersFileSchema,
|
|
ProviderConfigPatchSchema,
|
|
mergeProviderConfigPatch,
|
|
} from '../services/provider-config.js';
|
|
import {
|
|
reloadProviderConfig,
|
|
getResolvedRegistry,
|
|
} from '../services/provider-config-registry.js';
|
|
import {
|
|
getProviderDiagnostic,
|
|
type DiagnosticAgentRow,
|
|
} from '../services/provider-diagnostic.js';
|
|
|
|
const RefreshBodySchema = z.object({ providers: z.array(z.string()).optional() });
|
|
|
|
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
|
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
|
const cwd = req.query.cwd;
|
|
return getProviderSnapshot(sql, config, cwd);
|
|
});
|
|
|
|
// 4.1 — current loaded config file (raw CoderProvidersFile, not the resolved registry).
|
|
app.get('/api/providers/config', async (_req, _reply) => {
|
|
return load(config.CODER_PROVIDERS_PATH);
|
|
});
|
|
|
|
// 4.2 — patch the config file (design.md §6.2). Strict order is the whole
|
|
// correctness story: validate → save → reload → clear. A malformed body or an
|
|
// invalid merged result returns 422 and NEVER writes; a save failure returns
|
|
// 500 and leaves in-memory state untouched (no file/registry divergence).
|
|
app.patch('/api/providers/config', async (req, reply) => {
|
|
// 1. Validate the PATCH body shape (malformed → 422, never reaches merge).
|
|
const parsed = ProviderConfigPatchSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return reply.code(422).send({
|
|
error: 'invalid provider config patch',
|
|
issues: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
// 2. Shallow per-id merge over the current file (null deletes; object replaces).
|
|
const current = load(config.CODER_PROVIDERS_PATH);
|
|
const merged = mergeProviderConfigPatch(current, parsed.data);
|
|
|
|
// 3. Validate the merged result — refuse to write a config that won't load.
|
|
const validated = CoderProvidersFileSchema.safeParse(merged);
|
|
if (!validated.success) {
|
|
return reply.code(422).send({
|
|
error: 'merged provider config is invalid',
|
|
issues: validated.error.flatten(),
|
|
});
|
|
}
|
|
|
|
// 4. Persist. If save throws, STOP here — do NOT reload/clear, so the file on
|
|
// disk and the in-memory resolved registry can never diverge.
|
|
try {
|
|
save(config.CODER_PROVIDERS_PATH, validated.data);
|
|
} catch (err) {
|
|
req.log.error(
|
|
{ err: err instanceof Error ? err.message : String(err), path: config.CODER_PROVIDERS_PATH },
|
|
'provider-config: save failed — in-memory state untouched',
|
|
);
|
|
return reply.code(500).send({ error: 'failed to write provider config' });
|
|
}
|
|
|
|
// 5 + 6. Rebuild the in-memory resolved registry from the new file, then drop
|
|
// the snapshot cache so the next /snapshot reflects the change.
|
|
reloadProviderConfig();
|
|
clearProviderSnapshotCache();
|
|
|
|
// 7. Return the new config (per §6.2 `{ ok: true }`, plus the merged providers
|
|
// so the client can update without a follow-up GET).
|
|
return { ok: true, providers: validated.data.providers };
|
|
});
|
|
|
|
// 4.3 — force a cold probe. Optional { providers?: string[] } narrows the
|
|
// reported subset (design.md §6.3 Paseo pattern). The force=true snapshot is
|
|
// the only existing re-probe primitive (per-provider force would be a
|
|
// snapshot-internal change, out of Phase 4 scope), so the probe runs for all
|
|
// installed providers; the `refreshed` count reflects the requested subset.
|
|
app.post('/api/providers/refresh', async (req, reply) => {
|
|
const parsed = RefreshBodySchema.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
return reply.code(422).send({ error: 'invalid refresh body', issues: parsed.error.flatten() });
|
|
}
|
|
const subset = parsed.data.providers;
|
|
clearProviderSnapshotCache();
|
|
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
|
const refreshed =
|
|
subset && subset.length > 0
|
|
? entries.filter((e) => subset.includes(e.name)).length
|
|
: entries.length;
|
|
return { refreshed };
|
|
});
|
|
|
|
// 4.4 — per-provider diagnostic (design.md §6.4 → JSON `{ diagnostic: string }`).
|
|
// Read-only: reports cached state (resolved def + available_agents row + warm
|
|
// snapshot cache for the last probe error) plus a `which` PATH check. No probe
|
|
// spawn. The report itself is a plaintext block (§8); the route wraps it as JSON.
|
|
app.get<{ Params: { id: string } }>('/api/providers/:id/diagnostic', async (req, reply) => {
|
|
const id = req.params.id;
|
|
const resolved = getResolvedRegistry().get(id);
|
|
if (!resolved) {
|
|
return reply.code(404).send({ error: `unknown provider '${id}'` });
|
|
}
|
|
const rows = await sql<DiagnosticAgentRow[]>`
|
|
SELECT name, install_path, supports_acp, models, last_probed_at
|
|
FROM available_agents WHERE name = ${id}
|
|
`;
|
|
const report = await getProviderDiagnostic(resolved, rows[0], {
|
|
cachedEntry: peekSnapshotEntry(id),
|
|
});
|
|
return { diagnostic: report };
|
|
});
|
|
}
|