Compare commits
1 Commits
v2.5.4-pro
...
v2.5.5-pro
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a0aba211 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,4 +16,5 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
!data/coder-providers.json
|
||||
codecontext/fork.tar.gz
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
|
||||
|
||||
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 5–30s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
|
||||
|
||||
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
|
||||
|
||||
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §2–3): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
|
||||
|
||||
@@ -26,6 +26,10 @@ const ConfigSchema = z.object({
|
||||
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
|
||||
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
|
||||
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
|
||||
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
|
||||
// probed more recently than this. 24h default — stale model lists self-heal
|
||||
// on the next snapshot; an explicit /refresh always re-probes.
|
||||
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
|
||||
// v2.0.5: cheaper model for titles, summaries, labeling.
|
||||
FAST_MODEL: z.string().optional(),
|
||||
// SSH access to the host for external agent dispatch (Phase 5)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
mergeModels,
|
||||
prefixLlamaSwapModels,
|
||||
clearProviderSnapshotCache,
|
||||
getProviderSnapshot,
|
||||
} from '../provider-snapshot.js';
|
||||
import { loadProviderConfig } from '../provider-config-registry.js';
|
||||
|
||||
vi.mock('../acp-probe.js', () => ({
|
||||
probeAcpProvider: vi.fn(),
|
||||
@@ -14,6 +18,13 @@ import { probeAcpProvider } from '../acp-probe.js';
|
||||
|
||||
const mockProbe = vi.mocked(probeAcpProvider);
|
||||
|
||||
/** Write a temp coder-providers.json and point the resolved registry at it. */
|
||||
function loadConfigFixture(providers: Record<string, unknown>): void {
|
||||
const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
|
||||
writeFileSync(path, JSON.stringify({ providers }), 'utf8');
|
||||
loadProviderConfig(path);
|
||||
}
|
||||
|
||||
function mockSql(agents: Array<{
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
@@ -21,6 +32,7 @@ function mockSql(agents: Array<{
|
||||
models: Array<{ id: string; label: string }> | null;
|
||||
label: string | null;
|
||||
transport: string | null;
|
||||
last_probed_at?: string | null;
|
||||
}>) {
|
||||
return vi.fn((strings: TemplateStringsArray) => {
|
||||
const query = strings.join('');
|
||||
@@ -36,6 +48,7 @@ function mockSql(agents: Array<{
|
||||
|
||||
const config = {
|
||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||
} as import('../config.js').Config;
|
||||
|
||||
describe('prefixLlamaSwapModels', () => {
|
||||
@@ -68,6 +81,8 @@ describe('mergeModels', () => {
|
||||
describe('getProviderSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
clearProviderSnapshotCache();
|
||||
// Reset the resolved registry to built-ins-only (missing path → {} config).
|
||||
loadProviderConfig('/nonexistent-coder-providers.json');
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
@@ -165,4 +180,147 @@ describe('getProviderSnapshot', () => {
|
||||
expect(claude?.modes.length).toBeGreaterThan(0);
|
||||
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||
});
|
||||
|
||||
it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
|
||||
loadConfigFixture({ goose: { enabled: false } });
|
||||
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: [{ id: 'g1', label: 'G1' }],
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const goose = entries.find((e) => e.name === 'goose');
|
||||
|
||||
expect(goose?.status).toBe('unavailable');
|
||||
expect(goose?.enabled).toBe(false);
|
||||
expect(goose?.installed).toBe(false);
|
||||
expect(mockProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
|
||||
loadConfigFixture({});
|
||||
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||
|
||||
const sql = mockSql([]); // nothing probed/installed
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const opencode = entries.find((e) => e.name === 'opencode');
|
||||
|
||||
expect(opencode?.status).toBe('unavailable');
|
||||
expect(opencode?.enabled).toBe(true);
|
||||
expect(opencode?.installed).toBe(false);
|
||||
expect(mockProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
|
||||
loadConfigFixture({});
|
||||
// If this were wrongly called, cached-goose would be replaced and the
|
||||
// not.toHaveBeenCalled assertion would fail.
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: [{ id: 'cached-goose', label: 'Cached Goose' }],
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: new Date().toISOString(), // fresh
|
||||
},
|
||||
]);
|
||||
|
||||
// force=false → cache-miss returns loading; second call joins the build / cache.
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||
const goose = entries.find((e) => e.name === 'goose');
|
||||
|
||||
expect(goose?.status).toBe('ready');
|
||||
expect(goose?.installed).toBe(true);
|
||||
expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
|
||||
expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
|
||||
expect(mockProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
|
||||
loadConfigFixture({});
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'fresh-probe', label: 'Fresh' }],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: [{ id: 'cached-goose', label: 'Cached' }],
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: new Date().toISOString(), // fresh, but force overrides
|
||||
},
|
||||
]);
|
||||
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
|
||||
expect(mockProbe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('native boocode → ready, enabled, installed', async () => {
|
||||
loadConfigFixture({});
|
||||
const sql = mockSql([]);
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const boocode = entries.find((e) => e.name === 'boocode');
|
||||
|
||||
expect(boocode?.status).toBe('ready');
|
||||
expect(boocode?.enabled).toBe(true);
|
||||
expect(boocode?.installed).toBe(true);
|
||||
});
|
||||
|
||||
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||
loadConfigFixture({});
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'm1', label: 'M1' }],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: null,
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
|
||||
const probeCallsAfterFirst = mockProbe.mock.calls.length;
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
|
||||
const probeCallsAfterSecond = mockProbe.mock.calls.length;
|
||||
|
||||
// Success criterion: second snapshot is served from cache with no ACP spawns.
|
||||
expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Parity guard between the two copies of the provider snapshot types:
|
||||
* apps/coder/src/services/provider-types.ts (backend source of truth)
|
||||
* apps/web/src/api/types.ts (web wire copy)
|
||||
*
|
||||
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
|
||||
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
|
||||
* assignability check was attempted first (a web-side file importing coder's
|
||||
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
|
||||
* project and rejects out-of-include files with TS6307 — so cross-project type
|
||||
* import is structurally blocked. This runtime guard FAILS on any field
|
||||
* add/remove/rename/loosen in either copy, including the nested model/mode/
|
||||
* command types that ProviderSnapshotEntry references. Single-source-of-truth
|
||||
* (shared workspace package) is deferred as a Tier-2 follow-up.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
|
||||
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
|
||||
|
||||
function extractBlock(src: string, name: string): string {
|
||||
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
|
||||
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
|
||||
const block = iface?.[0] ?? alias?.[0];
|
||||
if (!block) throw new Error(`type block '${name}' not found`);
|
||||
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
|
||||
// trim each line. Field add/remove/rename/loosen still changes a field line.
|
||||
return block
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(
|
||||
(l) =>
|
||||
l.length > 0 &&
|
||||
!l.startsWith('//') &&
|
||||
!l.startsWith('/*') &&
|
||||
!l.startsWith('*'),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('provider snapshot type parity (coder ↔ web)', () => {
|
||||
// Includes the nested types ProviderSnapshotEntry references, so structural
|
||||
// drift anywhere in the snapshot surface is caught.
|
||||
const names = [
|
||||
'ProviderSnapshotStatus',
|
||||
'ProviderSnapshotEntry',
|
||||
'ProviderModel',
|
||||
'ProviderMode',
|
||||
'ThinkingOption',
|
||||
'AgentCommand',
|
||||
];
|
||||
for (const name of names) {
|
||||
it(`${name} is identical in both copies`, () => {
|
||||
expect(
|
||||
extractBlock(webSrc, name),
|
||||
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
|
||||
).toBe(extractBlock(coderSrc, name));
|
||||
});
|
||||
}
|
||||
});
|
||||
22
apps/coder/src/services/command-availability.ts
Normal file
22
apps/coder/src/services/command-availability.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
|
||||
*
|
||||
* Uses execFile (NO shell) because the binary name can come from the provider
|
||||
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
|
||||
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
|
||||
* `install_path`); this returns a boolean. Kept separate rather than over-
|
||||
* refactored into one helper — different return contracts, two short call sites.
|
||||
*/
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export async function isCommandAvailable(binary: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { homedir } from 'node:os';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
||||
import {
|
||||
getManifestDefaultModeId,
|
||||
getManifestModes,
|
||||
@@ -15,6 +14,8 @@ import { probeAcpProvider } from './acp-probe.js';
|
||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||
import { isCommandAvailable } from './command-availability.js';
|
||||
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
@@ -23,6 +24,7 @@ interface AgentRow {
|
||||
models: ProviderModel[] | null;
|
||||
label: string | null;
|
||||
transport: string | null;
|
||||
last_probed_at: string | Date | null;
|
||||
}
|
||||
|
||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
@@ -68,113 +70,160 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
||||
}
|
||||
|
||||
async function buildProviderEntry(
|
||||
provider: ProviderDef,
|
||||
resolved: ResolvedProviderDef,
|
||||
agentRow: AgentRow | undefined,
|
||||
llamaModels: ProviderModel[],
|
||||
cwd: string,
|
||||
): Promise<ProviderSnapshotEntry | null> {
|
||||
const isNative = provider.name === 'boocode';
|
||||
const installed = isNative || !!agentRow;
|
||||
if (!installed) return null;
|
||||
ttlMs: number,
|
||||
force: boolean,
|
||||
): Promise<ProviderSnapshotEntry> {
|
||||
const name = resolved.id;
|
||||
const isNative = resolved.transport === 'native';
|
||||
const fallbackModes = getManifestModes(name);
|
||||
const defaultModeId = getManifestDefaultModeId(name);
|
||||
const manifestCommands = getManifestCommands(name);
|
||||
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
|
||||
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
||||
|
||||
let transport = provider.transport;
|
||||
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
||||
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
|
||||
let transport = resolved.transport;
|
||||
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
|
||||
transport = 'pty';
|
||||
}
|
||||
|
||||
const fallbackModes = getManifestModes(provider.name);
|
||||
const defaultModeId = getManifestDefaultModeId(provider.name);
|
||||
|
||||
if (isNative) {
|
||||
// 1. Disabled → unavailable, no probe.
|
||||
if (!resolved.enabled) {
|
||||
return {
|
||||
name: provider.name,
|
||||
label: provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models: llamaModels,
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, ...descr, transport, status: 'unavailable',
|
||||
enabled: false, installed: false, models: [], modes: fallbackModes,
|
||||
defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Native boocode → always ready (llama-swap models).
|
||||
if (isNative) {
|
||||
return {
|
||||
name, label: resolved.label, transport, status: 'ready',
|
||||
enabled: true, installed: true, models: llamaModels, modes: [],
|
||||
defaultModeId: null, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
|
||||
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
|
||||
const fast =
|
||||
agentRow?.install_path != null ||
|
||||
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
|
||||
|
||||
if (!fast) {
|
||||
return {
|
||||
name, label, ...descr, transport, status: 'unavailable',
|
||||
enabled: true, installed: false, models: [], modes: fallbackModes,
|
||||
defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
// Baseline model precedence (used by claude + non-probe fallbacks).
|
||||
let models: ProviderModel[] = [];
|
||||
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
||||
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
|
||||
models = llamaModels;
|
||||
} else if (agentRow?.models?.length) {
|
||||
models = agentRow.models;
|
||||
} else if (provider.staticModels) {
|
||||
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||
} else if (resolved.staticModels) {
|
||||
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||
}
|
||||
|
||||
if (provider.name === 'claude') {
|
||||
models = attachClaudeThinking(models);
|
||||
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
||||
if (name === 'claude') {
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models: attachClaudeThinking(models), modes: fallbackModes, defaultModeId,
|
||||
commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
||||
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
||||
if (probe.models.length > 0) {
|
||||
models = probe.models;
|
||||
} else if (provider.modelSource === 'llama-swap') {
|
||||
models = llamaModels;
|
||||
const canProbeAcp =
|
||||
transport === 'acp' &&
|
||||
((agentRow?.install_path != null && agentRow.supports_acp) ||
|
||||
(resolved.isCustomAcp && resolved.launchCommand != null));
|
||||
|
||||
if (canProbeAcp) {
|
||||
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
|
||||
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
|
||||
const lastProbedMs =
|
||||
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
|
||||
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
|
||||
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
|
||||
const runTier2 = force || stale || dbEmpty;
|
||||
|
||||
if (!runTier2) {
|
||||
let skipModels = agentRow?.models ?? [];
|
||||
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
|
||||
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
|
||||
skipModels = llamaModels;
|
||||
}
|
||||
return {
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models: skipModels, modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
if (provider.name === 'qwen') {
|
||||
const settingsModels = await readQwenSettingsModels();
|
||||
models = mergeModels(models, settingsModels);
|
||||
}
|
||||
const probeTarget =
|
||||
resolved.isCustomAcp && resolved.launchCommand
|
||||
? resolved.launchCommand[0]
|
||||
: agentRow!.install_path!;
|
||||
const probe = await probeAcpProvider(name, probeTarget, cwd);
|
||||
|
||||
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
||||
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
||||
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||
let probeModels = probe.models.length > 0 ? probe.models : models;
|
||||
if (name === 'qwen') {
|
||||
probeModels = mergeModels(probeModels, await readQwenSettingsModels());
|
||||
}
|
||||
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
|
||||
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||
}
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow.label ?? provider.label,
|
||||
transport,
|
||||
name, label, transport,
|
||||
status: probe.ok ? 'ready' : 'error',
|
||||
installed: true,
|
||||
models,
|
||||
enabled: true, installed: true,
|
||||
models: probeModels,
|
||||
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
||||
error: probe.error,
|
||||
commands: mergeCommands(manifestCommands, probe.commands),
|
||||
...(probe.error ? { error: probe.error } : {}),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// PTY-only providers (qwen fallback when ACP unavailable)
|
||||
if (provider.name === 'qwen') {
|
||||
if (models.length === 0) {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
|
||||
if (name === 'qwen' && models.length === 0) {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models, modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
/** Synchronous placeholder entries for a cache-miss while the build runs (§4.4). */
|
||||
function loadingEntries(): ProviderSnapshotEntry[] {
|
||||
return [...getResolvedRegistry().values()].map((r) => ({
|
||||
name: r.id,
|
||||
label: r.configLabel ?? r.label,
|
||||
...(r.configDescription ? { description: r.configDescription } : {}),
|
||||
transport: r.transport,
|
||||
status: r.enabled ? ('loading' as const) : ('unavailable' as const),
|
||||
enabled: r.enabled,
|
||||
installed: false,
|
||||
models: [],
|
||||
modes: getManifestModes(r.id),
|
||||
defaultModeId: getManifestDefaultModeId(r.id),
|
||||
commands: getManifestCommands(r.id),
|
||||
}));
|
||||
}
|
||||
|
||||
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
|
||||
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
|
||||
const CACHE_TTL_MS = 5 * 60_000;
|
||||
@@ -200,16 +249,16 @@ export async function getProviderSnapshot(
|
||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||
const llamaModels = await fetchLlamaSwapModels(config);
|
||||
const agents = await sql<AgentRow[]>`
|
||||
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
|
||||
SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents
|
||||
`;
|
||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
||||
|
||||
const built = await Promise.all(
|
||||
PROVIDERS.map((provider) =>
|
||||
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
||||
const entries = await Promise.all(
|
||||
[...getResolvedRegistry().values()].map((resolved) =>
|
||||
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
|
||||
),
|
||||
);
|
||||
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
|
||||
|
||||
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
||||
return entries;
|
||||
@@ -219,7 +268,15 @@ export async function getProviderSnapshot(
|
||||
snapshotInflight.delete(cacheKey);
|
||||
});
|
||||
snapshotInflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
|
||||
// force → await the full build (cold probes included). Non-force cache miss →
|
||||
// return loading entries synchronously; the build settles in the background
|
||||
// and the next call returns it via cache / inflight (§4.4; client polls).
|
||||
if (force) return promise;
|
||||
promise.catch(() => {
|
||||
/* settled errors surface on the next call that awaits inflight/rebuilds */
|
||||
});
|
||||
return loadingEntries();
|
||||
}
|
||||
|
||||
export function clearProviderSnapshotCache(): void {
|
||||
|
||||
@@ -23,24 +23,31 @@ export interface ProviderModel {
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
||||
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
|
||||
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
|
||||
@@ -232,19 +232,25 @@ export interface ThinkingOption {
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
||||
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
|
||||
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
|
||||
3
data/coder-providers.json
Normal file
3
data/coder-providers.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"providers": {}
|
||||
}
|
||||
Reference in New Issue
Block a user