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:
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerProviderRoutes } from '../providers.js';
|
||||
import { load } from '../../services/provider-config.js';
|
||||
import { loadProviderConfig } from '../../services/provider-config-registry.js';
|
||||
import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js';
|
||||
import type { Config } from '../../config.js';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/** Minimal sql stub: available_agents reads return []. */
|
||||
function mockSql(): Sql {
|
||||
return vi.fn((strings: TemplateStringsArray) => {
|
||||
const q = strings.join('');
|
||||
if (q.includes('available_agents')) return Promise.resolve([]);
|
||||
return Promise.resolve([]);
|
||||
}) as unknown as Sql;
|
||||
}
|
||||
|
||||
let tmpCounter = 0;
|
||||
function freshPath(): string {
|
||||
tmpCounter += 1;
|
||||
return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`);
|
||||
}
|
||||
|
||||
function buildApp(providersPath: string): FastifyInstance {
|
||||
const app = Fastify();
|
||||
// Mirror index.ts: tolerate empty JSON bodies.
|
||||
app.removeContentTypeParser(['application/json']);
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const str = (body as string) ?? '';
|
||||
if (str.trim().length === 0) return done(null, {});
|
||||
try {
|
||||
done(null, JSON.parse(str));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
const config = {
|
||||
CODER_PROVIDERS_PATH: providersPath,
|
||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||
} as unknown as Config;
|
||||
registerProviderRoutes(app, mockSql(), config);
|
||||
return app;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'content-type': 'application/json' };
|
||||
const createdPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
clearProviderSnapshotCache();
|
||||
loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const p of createdPaths.splice(0)) {
|
||||
try {
|
||||
rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /api/providers/config', () => {
|
||||
it('returns the current config file (built-ins-only when missing)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
const app = buildApp(path);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ providers: {} });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('reflects an existing file', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } }));
|
||||
const app = buildApp(path);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||
expect(res.json()).toEqual({ providers: { goose: { enabled: false } } });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/providers/config', () => {
|
||||
it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } }));
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { opencode: { enabled: false } } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ ok: true });
|
||||
// File written + merged (goose untouched, opencode added).
|
||||
const onDisk = load(path);
|
||||
expect(onDisk.providers).toEqual({
|
||||
goose: { label: 'Goose' },
|
||||
opencode: { enabled: false },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('null value deletes the override', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } }));
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: null } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(load(path).providers).toEqual({ opencode: { enabled: false } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('INVALID body → 422 and the file is NOT written (validate before save)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
const before = JSON.stringify({ providers: { goose: { enabled: true } } });
|
||||
writeFileSync(path, before);
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
// File must be byte-for-byte unchanged — nothing written on a 422.
|
||||
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('save failure → 500 and the file is NOT created (no state divergence)', async () => {
|
||||
const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json');
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: { enabled: false } } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/providers/refresh', () => {
|
||||
it('no body → refreshes all registered providers', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().refreshed).toBeGreaterThan(0);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('subset body → refreshed count reflects only the requested providers', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/providers/refresh',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: ['boocode'] }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ refreshed: 1 });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/providers/:id/diagnostic', () => {
|
||||
it('known provider → 200 JSON { diagnostic }', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.json().diagnostic).toContain('provider: boocode');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('unknown provider → 404', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,29 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.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) => {
|
||||
@@ -9,9 +31,97 @@ export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: C
|
||||
return getProviderSnapshot(sql, config, cwd);
|
||||
});
|
||||
|
||||
app.post('/api/providers/refresh', async (_req, _reply) => {
|
||||
// 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);
|
||||
return { refreshed: entries.length };
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user