coder(providers): v2.3 provider-lifecycle phase 1 — config-backed registry
Adds a config layer merged over the hardcoded built-ins (tasks 1.1-1.6): CODER_PROVIDERS_PATH env (default /data/coder-providers.json); provider-config.ts (Zod schema + never-throw loader — missing/invalid file falls back to built-ins only — + save); provider-config-registry.ts (ResolvedProviderDef + buildResolvedRegistry merge: override built-ins, add custom extends:'acp' entries, boocode always enabled + singleton); agent-probe now iterates the resolved registry, probes custom-ACP command[0] via execFile (no shell), skips disabled providers (keeps the row), reads enabled from memory only (no DB column). No snapshot/dispatch/route/UI changes (Phase 2+). 6 new unit tests; empty config provably yields exactly the built-ins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
125
apps/coder/src/services/provider-config-registry.ts
Normal file
125
apps/coder/src/services/provider-config-registry.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* v2.3 resolved provider registry — single in-memory source of truth after
|
||||
* merging the hardcoded built-ins (provider-registry.ts) with the config file
|
||||
* (provider-config.ts). Mirrors Paseo's buildProviderRegistry/addDerivedProviders.
|
||||
*
|
||||
* Phase 1 scope: build + expose the resolved registry. `launchCommand` is null
|
||||
* for built-ins (the default argv is resolved at dispatch time in Phase 3) and
|
||||
* is the config `command` for custom ACP entries. No DB columns (design.md §3.3);
|
||||
* `enabled` lives in memory only.
|
||||
*/
|
||||
import type { ProviderDef } from './provider-registry.js';
|
||||
import { PROVIDERS } from './provider-registry.js';
|
||||
import { load, type CoderProvidersFile } from './provider-config.js';
|
||||
|
||||
export interface ResolvedProviderDef extends ProviderDef {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
isBuiltin: boolean;
|
||||
isCustomAcp: boolean;
|
||||
/** Full argv for spawn: [binary, ...args]. Null for built-ins (resolved at dispatch). */
|
||||
launchCommand: [string, ...string[]] | null;
|
||||
env: Record<string, string> | undefined;
|
||||
configLabel?: string;
|
||||
configDescription?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge built-ins with config overrides into the resolved registry.
|
||||
* Algorithm verbatim from design.md §3.1.
|
||||
*/
|
||||
export function buildResolvedRegistry(
|
||||
builtins: ProviderDef[],
|
||||
config: CoderProvidersFile,
|
||||
): Map<string, ResolvedProviderDef> {
|
||||
const out = new Map<string, ResolvedProviderDef>();
|
||||
const overrides = config.providers ?? {};
|
||||
const builtinNames = new Set(builtins.map((b) => b.name));
|
||||
|
||||
// 1. Built-ins, applying a config override if one is present.
|
||||
for (const def of builtins) {
|
||||
const ov = overrides[def.name];
|
||||
let enabled = ov?.enabled !== false;
|
||||
|
||||
// 3. boocode is always enabled; an enabled:false override is ignored + warned.
|
||||
if (def.name === 'boocode' && ov?.enabled === false) {
|
||||
console.warn("provider-config: ignoring enabled:false for built-in 'boocode' (always enabled)");
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
const launchCommand =
|
||||
ov?.command && ov.command.length > 0 ? (ov.command as [string, ...string[]]) : null;
|
||||
|
||||
out.set(def.name, {
|
||||
...def,
|
||||
label: ov?.label ?? def.label,
|
||||
id: def.name,
|
||||
enabled,
|
||||
isBuiltin: true,
|
||||
isCustomAcp: false,
|
||||
launchCommand,
|
||||
env: ov?.env,
|
||||
configLabel: ov?.label,
|
||||
configDescription: ov?.description,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Config ids that are not built-ins → custom ACP entries.
|
||||
for (const [id, ov] of Object.entries(overrides)) {
|
||||
if (builtinNames.has(id)) continue;
|
||||
// §2.2 rules: "New id without extends → Reject at load with log."
|
||||
if (ov.extends !== 'acp' || !ov.label || !ov.command || ov.command.length === 0) {
|
||||
console.warn(
|
||||
`provider-config: skipping custom provider '${id}' — requires extends:'acp', label, and command`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
out.set(id, {
|
||||
name: id,
|
||||
label: ov.label,
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
id,
|
||||
enabled: ov.enabled !== false,
|
||||
isBuiltin: false,
|
||||
isCustomAcp: true,
|
||||
launchCommand: ov.command as [string, ...string[]],
|
||||
env: ov.env,
|
||||
configLabel: ov.label,
|
||||
configDescription: ov.description,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Module singleton ---------------------------------------------------------
|
||||
|
||||
let cachedRegistry: Map<string, ResolvedProviderDef> | null = null;
|
||||
let cachedPath: string | null = null;
|
||||
|
||||
/** Load the config file at `path`, rebuild, and cache the resolved registry. */
|
||||
export function loadProviderConfig(path: string): Map<string, ResolvedProviderDef> {
|
||||
cachedPath = path;
|
||||
cachedRegistry = buildResolvedRegistry(PROVIDERS, load(path));
|
||||
return cachedRegistry;
|
||||
}
|
||||
|
||||
/** Re-read the last-loaded config file and rebuild (Phase 4 calls this after PATCH). */
|
||||
export function reloadProviderConfig(): Map<string, ResolvedProviderDef> {
|
||||
if (cachedPath == null) {
|
||||
cachedRegistry = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||
return cachedRegistry;
|
||||
}
|
||||
return loadProviderConfig(cachedPath);
|
||||
}
|
||||
|
||||
/** The cached resolved registry (built-ins only if nothing has been loaded yet). */
|
||||
export function getResolvedRegistry(): Map<string, ResolvedProviderDef> {
|
||||
return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||
}
|
||||
|
||||
/** Resolved provider ids in registry order. */
|
||||
export function getResolvedProviderIds(): string[] {
|
||||
return [...getResolvedRegistry().keys()];
|
||||
}
|
||||
Reference in New Issue
Block a user