Files
boocode/apps/coder/src/services/provider-config-registry.ts
indifferentketchup fe52250d78 coder(providers): fix empty picker (loading-state) + config model overrides + current Claude models
Fix: getProviderSnapshot returned synchronous installed:false 'loading' entries on a cache miss (v2.5.5/Phase 2), which AgentComposerBar filters out — with the Phase 5 client poll not yet built, a single fetch stranded on 'loading' and the picker showed no providers. It now awaits the build and returns terminal entries; the sync loading-return is deferred until Phase 5. Builds stay fast via the tier-2 cold-probe skip.

Feature: wire the v2.3 config schema's models/additionalModels — buildResolvedRegistry carries them onto ResolvedProviderDef (models replace, additionalModels merge) and provider-snapshot applies them to every ready model list, so /data/coder-providers.json can edit any provider's models with no code change. Claude staticModels bumped from the stale 2-entry list to opus/sonnet/haiku latest-aliases + pinned claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5-20251001 (passed verbatim to claude --model). +2 tests (109 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:37:01 +00:00

134 lines
4.8 KiB
TypeScript

/**
* 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;
/** Config `models` — REPLACES the discovered/static model list when present. */
configModels?: Array<{ id: string; label: string }>;
/** Config `additionalModels` — MERGED on top of the resolved model list. */
configAdditionalModels?: Array<{ id: string; label: 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,
configModels: ov?.models,
configAdditionalModels: ov?.additionalModels,
});
}
// 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,
configModels: ov.models,
configAdditionalModels: ov.additionalModels,
});
}
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()];
}