@jini-ai/agent-runtime 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/agent-protocol/acp/models.d.ts +11 -0
  3. package/dist/agent-protocol/acp/models.js +12 -3
  4. package/dist/capabilities.d.ts +1 -8
  5. package/dist/capabilities.js +8 -1
  6. package/dist/claude-code-models.d.ts +13 -1
  7. package/dist/claude-code-models.js +15 -17
  8. package/dist/claude-segment-usage.d.ts +9 -2
  9. package/dist/claude-segment-usage.js +20 -4
  10. package/dist/claude-stream.d.ts +4 -0
  11. package/dist/claude-stream.js +5 -1
  12. package/dist/defs/aider.js +1 -1
  13. package/dist/defs/amp.js +1 -1
  14. package/dist/defs/amr.js +1 -1
  15. package/dist/defs/antigravity.js +1 -1
  16. package/dist/defs/auggie.js +1 -1
  17. package/dist/defs/claude.d.ts +2 -0
  18. package/dist/defs/claude.js +11 -3
  19. package/dist/defs/cline.js +1 -1
  20. package/dist/defs/codebuddy.js +2 -2
  21. package/dist/defs/codex.js +1 -1
  22. package/dist/defs/copilot.js +1 -1
  23. package/dist/defs/crush.js +1 -1
  24. package/dist/defs/cursor-agent.js +2 -2
  25. package/dist/defs/deepseek.js +1 -1
  26. package/dist/defs/devin.js +1 -1
  27. package/dist/defs/droid.js +1 -1
  28. package/dist/defs/goose.js +1 -1
  29. package/dist/defs/grok-build.js +1 -1
  30. package/dist/defs/hermes.js +1 -1
  31. package/dist/defs/kilo.js +1 -1
  32. package/dist/defs/kimi.js +1 -1
  33. package/dist/defs/kiro.js +1 -1
  34. package/dist/defs/mimo.js +1 -1
  35. package/dist/defs/opencode.js +2 -2
  36. package/dist/defs/pi.js +1 -1
  37. package/dist/defs/qoder.js +1 -1
  38. package/dist/defs/qwen.js +1 -1
  39. package/dist/defs/reasonix.js +1 -1
  40. package/dist/defs/trae-cli.js +1 -1
  41. package/dist/defs/vibe.js +1 -1
  42. package/dist/detection.d.ts +28 -4
  43. package/dist/detection.js +82 -43
  44. package/dist/diagnostics.d.ts +6 -1
  45. package/dist/diagnostics.js +2 -2
  46. package/dist/env.js +1 -1
  47. package/dist/executables.d.ts +15 -9
  48. package/dist/executables.js +73 -65
  49. package/dist/index.d.ts +3 -2
  50. package/dist/index.js +3 -2
  51. package/dist/invocation.d.ts +24 -2
  52. package/dist/invocation.js +19 -7
  53. package/dist/launch.d.ts +2 -1
  54. package/dist/launch.js +2 -2
  55. package/dist/model-discovery-adapters.d.ts +6 -2
  56. package/dist/model-discovery-adapters.js +64 -51
  57. package/dist/model-discovery-native.d.ts +14 -0
  58. package/dist/model-discovery-native.js +22 -0
  59. package/dist/model-discovery-sdk.js +1 -1
  60. package/dist/model-discovery-shared.d.ts +14 -0
  61. package/dist/model-discovery-shared.js +53 -0
  62. package/dist/model-discovery-types.d.ts +26 -0
  63. package/dist/model-discovery.d.ts +17 -22
  64. package/dist/model-discovery.js +18 -69
  65. package/dist/models.d.ts +9 -4
  66. package/dist/models.js +11 -11
  67. package/dist/probe-scheduler.d.ts +19 -0
  68. package/dist/probe-scheduler.js +30 -0
  69. package/dist/runtime-state.d.ts +41 -0
  70. package/dist/runtime-state.js +31 -0
  71. package/dist/types.d.ts +4 -0
  72. package/package.json +36 -9
@@ -1,15 +1,18 @@
1
+ import { defaultRuntimeState } from './runtime-state.js';
2
+ import { VENDOR_API_BASE_URLS } from '@jini-ai/integrations/media-providers/catalog';
1
3
  import { execFile } from 'node:child_process';
2
4
  import { readFile, stat } from 'node:fs/promises';
3
5
  import { homedir } from 'node:os';
4
6
  import { join, dirname } from 'node:path';
5
- import { createCommandInvocation } from '@jini-ai/platform';
7
+ import { createCommandInvocation } from '@jini-ai/platform/command';
6
8
  import { detectAcpModelMetadata } from './agent-protocol/acp/models.js';
7
9
  import { CLAUDE_CODE_INITIALIZE_ARGS, CLAUDE_CODE_INITIALIZE_REQUEST } from './claude-code-models.js';
8
10
  import { listProviderModels } from './providers/model-catalog.js';
9
11
  import { execAgentFile } from './invocation.js';
12
+ import { scheduleProbeProcess } from './probe-scheduler.js';
10
13
  import { probeCopilotSdk, probeDroidSdk } from './model-discovery-sdk.js';
11
14
  import { parseSettingsJson, readModelConfiguration } from './model-discovery-config.js';
12
- import { ModelProbeError, modelIdentityKind, withModelProbeTimeout } from './model-discovery.js';
15
+ import { ModelProbeError, modelIdentityKind, withModelProbeTimeout } from './model-discovery-shared.js';
13
16
  const ACP_ARGS = {
14
17
  amr: ['agent', 'run', '--runtime', 'opencode'],
15
18
  auggie: ['--acp'], cline: ['--acp'], devin: ['--permission-mode', 'dangerous', '--respect-workspace-trust', 'false', 'acp'],
@@ -252,49 +255,53 @@ function runProcess(input) {
252
255
  cwd: input.context.cwd, env: input.context.env, signal: input.signal,
253
256
  timeout: input.timeoutMs, killSignal: 'SIGKILL', maxBuffer: 8 * 1024 * 1024,
254
257
  } }).then(({ stdout, stderr }) => ({ stdout: String(stdout), stderr: String(stderr) }));
255
- return new Promise((resolve, reject) => {
256
- let completed = false;
257
- let captured = '';
258
- const invocation = createCommandInvocation({ command: input.context.executable, args: [...input.args], env: input.context.env });
259
- const tree = input.terminateProcessTree && process.platform !== 'win32';
260
- let treeTimer;
261
- const killTree = () => { if (tree && child.pid) {
262
- try {
263
- process.kill(-child.pid, 'SIGKILL');
264
- }
265
- catch { }
266
- } };
267
- // execFile forwards `detached` to spawn at runtime, but its typings omit it; a typed
268
- // variable (not a literal) keeps the process-group option without a cast.
269
- const options = { cwd: input.context.cwd, env: input.context.env,
270
- timeout: input.timeoutMs, killSignal: 'SIGKILL', signal: input.signal, maxBuffer: 8 * 1024 * 1024,
271
- detached: Boolean(tree),
272
- windowsVerbatimArguments: invocation.windowsVerbatimArguments, };
273
- const child = execFile(invocation.command, invocation.args, options, (error, stdout, stderr) => {
274
- if (treeTimer)
275
- clearTimeout(treeTimer);
276
- input.signal.removeEventListener('abort', killTree);
277
- if (error)
278
- killTree();
279
- error && !completed ? reject(error) : resolve({ stdout: String(stdout), stderr: String(stderr) });
280
- });
281
- if (tree) {
282
- input.signal.addEventListener('abort', killTree, { once: true });
283
- treeTimer = setTimeout(killTree, input.timeoutMs);
284
- if (input.signal.aborted)
285
- killTree();
286
- }
287
- if (input.complete)
288
- child.stdout?.on('data', (chunk) => {
289
- captured += String(chunk);
290
- if (input.complete?.(captured)) {
291
- completed = true;
292
- child.kill('SIGKILL');
258
+ return scheduleProbeProcess({ work: () => new Promise((resolve, reject) => {
259
+ let completed = false;
260
+ let captured = '';
261
+ const invocation = createCommandInvocation({ command: input.context.executable, args: [...input.args], env: input.context.env });
262
+ const tree = input.terminateProcessTree && process.platform !== 'win32';
263
+ let treeTimer;
264
+ const killTree = () => { if (tree && child.pid) {
265
+ try {
266
+ process.kill(-child.pid, 'SIGKILL');
293
267
  }
268
+ catch { }
269
+ } };
270
+ // execFile forwards `detached` to spawn at runtime, but its typings omit it; a typed
271
+ // variable (not a literal) keeps the process-group option without a cast.
272
+ const options = { cwd: input.context.cwd, env: input.context.env,
273
+ timeout: input.timeoutMs, killSignal: 'SIGKILL', signal: input.signal, maxBuffer: 8 * 1024 * 1024,
274
+ detached: Boolean(tree),
275
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments, };
276
+ const child = execFile(invocation.command, invocation.args, options, (error, stdout, stderr) => {
277
+ if (treeTimer)
278
+ clearTimeout(treeTimer);
279
+ input.signal.removeEventListener('abort', killTree);
280
+ if (error)
281
+ killTree();
282
+ const settle = () => { error && !completed ? reject(error) : resolve({ stdout: String(stdout), stderr: String(stderr) }); };
283
+ if (child.pid && child.exitCode === null && child.signalCode === null)
284
+ child.once('close', settle);
285
+ else
286
+ settle();
294
287
  });
295
- child.stdin?.on('error', () => { });
296
- child.stdin?.end(input.stdin);
297
- });
288
+ if (tree) {
289
+ input.signal.addEventListener('abort', killTree, { once: true });
290
+ treeTimer = setTimeout(killTree, input.timeoutMs);
291
+ if (input.signal.aborted)
292
+ killTree();
293
+ }
294
+ if (input.complete)
295
+ child.stdout?.on('data', (chunk) => {
296
+ captured += String(chunk);
297
+ if (input.complete?.(captured)) {
298
+ completed = true;
299
+ child.kill('SIGKILL');
300
+ }
301
+ });
302
+ child.stdin?.on('error', () => { });
303
+ child.stdin?.end(input.stdin);
304
+ }) }, { signal: input.signal });
298
305
  }
299
306
  export const defaultModelDiscoveryDeps = {
300
307
  process: { run: runProcess },
@@ -319,29 +326,29 @@ export const defaultModelDiscoveryDeps = {
319
326
  // handles. BYOK env is usable only when it identifies this CLI's effective provider.
320
327
  const env = context.env;
321
328
  if (agentId === 'claude' && env.ANTHROPIC_API_KEY)
322
- return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', apiKey: env.ANTHROPIC_API_KEY };
329
+ return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || VENDOR_API_BASE_URLS.anthropic, apiKey: env.ANTHROPIC_API_KEY };
323
330
  const selected = context.model || (agentId === 'aider' ? env.AIDER_MODEL : agentId === 'qwen' ? env.QWEN_MODEL || env.OPENAI_MODEL : undefined);
324
331
  if (!selected)
325
332
  return null;
326
333
  if (/^(anthropic\/|claude-)/.test(selected) && env.ANTHROPIC_API_KEY)
327
- return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', apiKey: env.ANTHROPIC_API_KEY };
334
+ return { protocol: 'anthropic', baseUrl: env.ANTHROPIC_BASE_URL || VENDOR_API_BASE_URLS.anthropic, apiKey: env.ANTHROPIC_API_KEY };
328
335
  if (/^(gemini\/|google\/|gemini-)/.test(selected) && env.GEMINI_API_KEY)
329
- return { protocol: 'google', baseUrl: 'https://generativelanguage.googleapis.com', apiKey: env.GEMINI_API_KEY };
336
+ return { protocol: 'google', baseUrl: VENDOR_API_BASE_URLS.google, apiKey: env.GEMINI_API_KEY };
330
337
  if (/^openrouter\//.test(selected) && env.OPENROUTER_API_KEY)
331
- return { protocol: 'openai', baseUrl: env.OPENROUTER_API_BASE || 'https://openrouter.ai/api/v1', apiKey: env.OPENROUTER_API_KEY };
338
+ return { protocol: 'openai', baseUrl: env.OPENROUTER_API_BASE || VENDOR_API_BASE_URLS.openrouter, apiKey: env.OPENROUTER_API_KEY };
332
339
  if ((/^(openai\/|gpt-|o\d)/.test(selected) || agentId === 'qwen' && (env.OPENAI_MODEL || env.OPENAI_BASE_URL)) && env.OPENAI_API_KEY)
333
- return { protocol: 'openai', baseUrl: env.OPENAI_BASE_URL || env.OPENAI_API_BASE || 'https://api.openai.com/v1', apiKey: env.OPENAI_API_KEY };
340
+ return { protocol: 'openai', baseUrl: env.OPENAI_BASE_URL || env.OPENAI_API_BASE || VENDOR_API_BASE_URLS.openai, apiKey: env.OPENAI_API_KEY };
334
341
  return null;
335
342
  } },
336
343
  acp: { async probe({ context, args, signal }) {
337
344
  const invocation = createCommandInvocation({ command: context.executable, args: [...args], env: context.env });
338
- const result = await detectAcpModelMetadata({ bin: invocation.command, args: invocation.args }, { cwd: context.cwd, env: context.env, timeoutMs: 15_000, signal });
345
+ const result = await scheduleProbeProcess({ work: () => detectAcpModelMetadata({ bin: invocation.command, args: invocation.args }, { cwd: context.cwd, env: context.env, timeoutMs: 15_000, signal }) }, { signal });
339
346
  return { models: result.models, source: 'rpc', coverage: 'account', ...(result.currentModelId ? { defaultSelectionId: result.currentModelId } : {}) };
340
347
  } },
341
348
  rpc: { async probe({ agentId, context, signal }) {
342
349
  // UNVERIFIED locally: use official prompt-free SDK interfaces, not invented CLI flags.
343
350
  if (agentId === 'copilot')
344
- return probeCopilotSdk(context, signal);
351
+ return scheduleProbeProcess({ work: () => probeCopilotSdk(context, signal) }, { signal });
345
352
  if (agentId === 'droid')
346
353
  return probeDroidSdk(context, defaultModelDiscoveryDeps, signal);
347
354
  if (agentId !== 'pi')
@@ -358,7 +365,7 @@ export const defaultModelDiscoveryDeps = {
358
365
  } });
359
366
  return parsePiRpcMetadata(stdout);
360
367
  } },
361
- clock: { now: Date.now }, cache: new Map(),
368
+ clock: { now: Date.now }, cache: defaultRuntimeState.modelCatalog,
362
369
  };
363
370
  /** Pi's native RPC supplies catalog AND current provider/id without making an inference call. */
364
371
  export function parsePiRpcMetadata(stdout) {
@@ -388,4 +395,10 @@ export function parsePiRpcMetadata(stdout) {
388
395
  throw new ModelProbeError('malformed-response');
389
396
  return { models, source: 'rpc', coverage: 'account', ...(defaultSelectionId ? { defaultSelectionId } : {}) };
390
397
  }
398
+ /** Stable dependency identity preserves the existing per-host single-probe budget as well as its cache. */
399
+ export function getRuntimeModelDiscoveryDeps({ state }, _optional = {}) {
400
+ if (state === defaultRuntimeState)
401
+ return defaultModelDiscoveryDeps;
402
+ return state.discoveryDeps ??= { ...defaultModelDiscoveryDeps, cache: state.modelCatalog };
403
+ }
391
404
  //# sourceMappingURL=model-discovery-adapters.js.map
@@ -0,0 +1,14 @@
1
+ /** Native composition and legacy public signatures. Injected hosts import model-discovery instead. */
2
+ import { resolveModelForLaunch as resolvePolicyModelForLaunch, type ModelDiscoveryDefinition } from './model-discovery.js';
3
+ import type { AgentDiscoveryConfiguration, AgentModelDiscoveryPort, ModelDiscoveryContext, ModelDiscoveryDeps } from './model-discovery-types.js';
4
+ export * from './model-discovery.js';
5
+ export declare function createAgentModelDiscovery(agentId: string, configuration: AgentDiscoveryConfiguration): AgentModelDiscoveryPort;
6
+ export declare function modelDiscoveryForDef(def: ModelDiscoveryDefinition): AgentModelDiscoveryPort;
7
+ export declare function resolveModelForLaunch(required: {
8
+ def: ModelDiscoveryDefinition;
9
+ context: ModelDiscoveryContext;
10
+ }, { deps, ...optional }?: {
11
+ deps?: ModelDiscoveryDeps;
12
+ signal?: AbortSignal;
13
+ }): ReturnType<typeof resolvePolicyModelForLaunch>;
14
+ //# sourceMappingURL=model-discovery-native.d.ts.map
@@ -0,0 +1,22 @@
1
+ /** Native composition and legacy public signatures. Injected hosts import model-discovery instead. */
2
+ import { createAgentModelDiscovery as createPolicyDiscovery, modelDiscoveryForDef as policyDiscoveryForDef, resolveModelForLaunch as resolvePolicyModelForLaunch, } from './model-discovery.js';
3
+ import { defaultModelDiscoveryDeps, modelProbeTimeoutMs, probeAgentCatalog, probeCliCache } from './model-discovery-adapters.js';
4
+ export * from './model-discovery.js';
5
+ /** Reuse the existing probe owners, including their credential, cancellation and cache guards. */
6
+ const nativeModelDiscoveryProbes = {
7
+ catalog: ({ agentId, context, configuration, deps, signal }, _optional = {}) => probeAgentCatalog(agentId, context, configuration, deps, signal),
8
+ cliCache: ({ agentId, context, deps, signal }, _optional = {}) => probeCliCache(agentId, context, deps, signal),
9
+ timeoutMs: ({ agentId }, _optional = {}) => modelProbeTimeoutMs(agentId),
10
+ };
11
+ // These forwarding signatures deliberately retain the published API. New policy APIs use
12
+ // required/optional objects; changing these compatibility wrappers would break existing hosts.
13
+ export function createAgentModelDiscovery(agentId, configuration) {
14
+ return createPolicyDiscovery({ agentId, configuration, deps: defaultModelDiscoveryDeps, probes: nativeModelDiscoveryProbes });
15
+ }
16
+ export function modelDiscoveryForDef(def) {
17
+ return policyDiscoveryForDef({ def, deps: defaultModelDiscoveryDeps });
18
+ }
19
+ export function resolveModelForLaunch(required, { deps = defaultModelDiscoveryDeps, ...optional } = {}) {
20
+ return resolvePolicyModelForLaunch({ ...required, deps }, optional);
21
+ }
22
+ //# sourceMappingURL=model-discovery-native.js.map
@@ -1,4 +1,4 @@
1
- import { ModelProbeError, modelIdentityKind } from './model-discovery.js';
1
+ import { ModelProbeError, modelIdentityKind } from './model-discovery-shared.js';
2
2
  export async function probeCopilotSdk(context, signal, optional = {}) {
3
3
  const client = await (optional.createClient ?? createCopilotMetadataClient)(context);
4
4
  if (signal.aborted) {
@@ -0,0 +1,14 @@
1
+ /** Shared discovery normalization and timeout/error contracts; no native adapters. */
2
+ import type { RuntimeModelOption } from './types.js';
3
+ import type { DiscoveredModel, ModelCatalogSnapshot } from './model-discovery-types.js';
4
+ export declare function modelIdentityKind(id: string): DiscoveredModel['identityKind'];
5
+ export declare function normalizeDiscoveredModels(models: readonly RuntimeModelOption[]): DiscoveredModel[];
6
+ export declare class ProbeTimeout extends Error {
7
+ }
8
+ /** Cancellation always reaches the effect port. A broken port cannot hold callers indefinitely. */
9
+ export declare function withModelProbeTimeout<T>(work: (signal: AbortSignal) => Promise<T>, timeoutMs: number, parent?: AbortSignal): Promise<T>;
10
+ export declare class ModelProbeError extends Error {
11
+ readonly code: ModelCatalogSnapshot['diagnostics'][number]['code'];
12
+ constructor(code: ModelCatalogSnapshot['diagnostics'][number]['code']);
13
+ }
14
+ //# sourceMappingURL=model-discovery-shared.d.ts.map
@@ -0,0 +1,53 @@
1
+ export function modelIdentityKind(id) {
2
+ const token = id.slice(id.lastIndexOf('/') + 1);
3
+ if (/^(default|auto|adaptive|smart|deep|rush|ultimate|premium|balanced|fast|vercel-ai-gateway|grok-build)$/i.test(token) || /(?:^|[-/])gateway$/i.test(id))
4
+ return 'routing-mode';
5
+ if (/^(opus|sonnet|haiku|fable|best|latest|claude|gemini|gpt|codex|swe)(?:\[.*\])?$/i.test(token) || /(?:^|[-/])latest$/i.test(id))
6
+ return 'alias';
7
+ return 'concrete';
8
+ }
9
+ export function normalizeDiscoveredModels(models) {
10
+ const seen = new Set();
11
+ return models.flatMap((row) => {
12
+ if (!row.id || row.id === 'default' || seen.has(row.id))
13
+ return [];
14
+ seen.add(row.id);
15
+ const model = row;
16
+ return [{ ...row, identityKind: model.identityKind ?? modelIdentityKind(row.id), ...(model.resolvedId ? { resolvedId: model.resolvedId } : {}) }];
17
+ });
18
+ }
19
+ export class ProbeTimeout extends Error {
20
+ }
21
+ /** Cancellation always reaches the effect port. A broken port cannot hold callers indefinitely. */
22
+ export async function withModelProbeTimeout(work, timeoutMs, parent) {
23
+ if (parent?.aborted)
24
+ throw new ProbeTimeout('Probe cancelled');
25
+ const controller = new AbortController();
26
+ let timer;
27
+ let abortListener;
28
+ const stopped = new Promise((_resolve, reject) => {
29
+ abortListener = () => { controller.abort(); reject(new ProbeTimeout('Probe cancelled')); };
30
+ if (parent?.aborted)
31
+ abortListener();
32
+ else
33
+ parent?.addEventListener('abort', abortListener, { once: true });
34
+ timer = setTimeout(() => { controller.abort(); reject(new ProbeTimeout('Probe timed out')); }, timeoutMs);
35
+ });
36
+ try {
37
+ return await Promise.race([work(controller.signal), stopped]);
38
+ }
39
+ finally {
40
+ if (timer)
41
+ clearTimeout(timer);
42
+ if (abortListener)
43
+ parent?.removeEventListener('abort', abortListener);
44
+ }
45
+ }
46
+ export class ModelProbeError extends Error {
47
+ code;
48
+ constructor(code) {
49
+ super(code);
50
+ this.code = code;
51
+ }
52
+ }
53
+ //# sourceMappingURL=model-discovery-shared.js.map
@@ -136,4 +136,30 @@ export interface ModelDiscoveryDeps {
136
136
  };
137
137
  cache: Map<string, ModelCacheEntry>;
138
138
  }
139
+ export interface AgentDiscoveryConfiguration {
140
+ fallbackModels: () => readonly RuntimeModelOption[];
141
+ parse?: (input: {
142
+ stdout: string;
143
+ }) => RuntimeModelOption[] | null;
144
+ args?: readonly string[];
145
+ }
146
+ /** Host composition selects native probes or substitutes another discovery mechanism. */
147
+ export interface ModelDiscoveryProbes {
148
+ catalog(required: {
149
+ agentId: string;
150
+ context: ModelDiscoveryContext;
151
+ configuration: AgentDiscoveryConfiguration;
152
+ deps: ModelDiscoveryDeps;
153
+ signal: AbortSignal;
154
+ }, optional?: Record<string, never>): Promise<ModelProbeResult>;
155
+ cliCache(required: {
156
+ agentId: string;
157
+ context: ModelDiscoveryContext;
158
+ deps: ModelDiscoveryDeps;
159
+ signal: AbortSignal;
160
+ }, optional?: Record<string, never>): Promise<ModelProbeResult>;
161
+ timeoutMs(required: {
162
+ agentId: string;
163
+ }, optional?: Record<string, never>): number;
164
+ }
139
165
  //# sourceMappingURL=model-discovery-types.d.ts.map
@@ -1,42 +1,37 @@
1
1
  import type { RuntimeModelOption } from './types.js';
2
- import type { AgentModelDiscoveryPort, DefaultModelResolution, DiscoveredModel, ModelCatalogSnapshot, ModelDiscoveryContext, ModelDiscoveryDeps } from './model-discovery-types.js';
3
- export * from './model-discovery-types.js';
2
+ import type { AgentModelDiscoveryPort, AgentDiscoveryConfiguration, DefaultModelResolution, ModelCatalogSnapshot, ModelDiscoveryContext, ModelDiscoveryDeps, ModelDiscoveryProbes } from './model-discovery-types.js';
3
+ export { ModelProbeError, modelIdentityKind, normalizeDiscoveredModels, withModelProbeTimeout } from './model-discovery-shared.js';
4
+ export type * from './model-discovery-types.js';
4
5
  export declare const MODEL_CATALOG_TTL_MS: number;
5
6
  export declare const MODEL_NEGATIVE_TTL_MS = 30000;
6
- export declare function modelIdentityKind(id: string): DiscoveredModel['identityKind'];
7
- export declare function normalizeDiscoveredModels(models: readonly RuntimeModelOption[]): DiscoveredModel[];
8
7
  /** Hash all launch-affecting state, including env credentials; none of the raw material escapes. */
9
8
  export declare function modelLaunchFingerprint(context: ModelDiscoveryContext, configMaterial?: string): string;
10
- /** Cancellation always reaches the effect port. A broken port cannot hold callers indefinitely. */
11
- export declare function withModelProbeTimeout<T>(work: (signal: AbortSignal) => Promise<T>, timeoutMs: number, parent?: AbortSignal): Promise<T>;
12
- export declare class ModelProbeError extends Error {
13
- readonly code: ModelCatalogSnapshot['diagnostics'][number]['code'];
14
- constructor(code: ModelCatalogSnapshot['diagnostics'][number]['code']);
15
- }
16
- export interface AgentDiscoveryConfiguration {
17
- fallbackModels: () => readonly RuntimeModelOption[];
18
- parse?: (input: {
19
- stdout: string;
20
- }) => RuntimeModelOption[] | null;
21
- args?: readonly string[];
22
- }
23
- export declare function createAgentModelDiscovery(agentId: string, configuration: AgentDiscoveryConfiguration): AgentModelDiscoveryPort;
9
+ /** Bind supplied effect ports and probes without loading their native implementations. */
10
+ export declare function createAgentModelDiscovery({ agentId, configuration, deps: suppliedDeps, probes }: {
11
+ agentId: string;
12
+ configuration: AgentDiscoveryConfiguration;
13
+ deps: ModelDiscoveryDeps;
14
+ probes: ModelDiscoveryProbes;
15
+ }, _optional?: Record<string, never>): AgentModelDiscoveryPort;
24
16
  export declare function offlineSnapshot(fingerprint: string, now: number, fallback: readonly RuntimeModelOption[], issue?: ModelCatalogSnapshot['diagnostics'][number]): ModelCatalogSnapshot;
25
17
  /** Hosts may supply either discovery port independently; static definitions need no probes. */
26
- type ModelDiscoveryDefinition = Partial<AgentModelDiscoveryPort> & {
18
+ export type ModelDiscoveryDefinition = Partial<AgentModelDiscoveryPort> & {
27
19
  id?: string;
28
20
  fallbackModels?: readonly RuntimeModelOption[];
29
21
  defaultModelEnvVar?: string;
30
22
  supportsCustomModel?: boolean;
31
23
  supportsConcreteModelSelection?: boolean;
32
24
  };
33
- export declare function modelDiscoveryForDef(def: ModelDiscoveryDefinition): AgentModelDiscoveryPort;
25
+ export declare function modelDiscoveryForDef({ def, deps: suppliedDeps }: {
26
+ def: ModelDiscoveryDefinition;
27
+ deps: ModelDiscoveryDeps;
28
+ }, _optional?: Record<string, never>): AgentModelDiscoveryPort;
34
29
  /** Resolve against the actual launch scope, pinning the same ID shown by the picker. */
35
- export declare function resolveModelForLaunch({ def, context }: {
30
+ export declare function resolveModelForLaunch({ def, context, deps }: {
36
31
  def: ModelDiscoveryDefinition;
37
32
  context: ModelDiscoveryContext;
33
+ deps: ModelDiscoveryDeps;
38
34
  }, optional?: {
39
- deps?: ModelDiscoveryDeps;
40
35
  signal?: AbortSignal;
41
36
  }): Promise<{
42
37
  model: string;
@@ -1,40 +1,20 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readModelConfiguration } from './model-discovery-config.js';
3
- import { defaultModelDiscoveryDeps, probeAgentCatalog, probeCliCache, modelProbeTimeoutMs } from './model-discovery-adapters.js';
4
- export * from './model-discovery-types.js';
3
+ import { ModelProbeError, ProbeTimeout, modelIdentityKind, normalizeDiscoveredModels, withModelProbeTimeout } from './model-discovery-shared.js';
4
+ export { ModelProbeError, modelIdentityKind, normalizeDiscoveredModels, withModelProbeTimeout } from './model-discovery-shared.js';
5
5
  export const MODEL_CATALOG_TTL_MS = 15 * 60_000;
6
6
  export const MODEL_NEGATIVE_TTL_MS = 30_000;
7
7
  const MAX_CATALOG_SCOPES = 128;
8
- export function modelIdentityKind(id) {
9
- const token = id.slice(id.lastIndexOf('/') + 1);
10
- if (/^(default|auto|adaptive|smart|deep|rush|ultimate|premium|balanced|fast|vercel-ai-gateway|grok-build)$/i.test(token) || /(?:^|[-/])gateway$/i.test(id))
11
- return 'routing-mode';
12
- if (/^(opus|sonnet|haiku|fable|best|latest|claude|gemini|gpt|codex|swe)(?:\[.*\])?$/i.test(token) || /(?:^|[-/])latest$/i.test(id))
13
- return 'alias';
14
- return 'concrete';
15
- }
16
- export function normalizeDiscoveredModels(models) {
17
- const seen = new Set();
18
- return models.flatMap((row) => {
19
- if (!row.id || row.id === 'default' || seen.has(row.id))
20
- return [];
21
- seen.add(row.id);
22
- const model = row;
23
- return [{ ...row, identityKind: model.identityKind ?? modelIdentityKind(row.id), ...(model.resolvedId ? { resolvedId: model.resolvedId } : {}) }];
24
- });
25
- }
26
8
  /** Hash all launch-affecting state, including env credentials; none of the raw material escapes. */
27
9
  export function modelLaunchFingerprint(context, configMaterial = '') {
28
10
  return createHash('sha256').update(JSON.stringify({ ...context, env: Object.entries(context.env).sort(([a], [b]) => a.localeCompare(b)), configMaterial })).digest('hex');
29
11
  }
30
- class ProbeTimeout extends Error {
31
- }
32
12
  const probePools = new WeakMap();
33
- /** Four simultaneous cold probes per host; queued callers keep cancellation responsive. */
13
+ /** One cold catalog probe per host; queued callers keep cancellation responsive. */
34
14
  async function boundedProbe(deps, work, signal) {
35
15
  const pool = probePools.get(deps) || { active: 0, queue: [] };
36
16
  probePools.set(deps, pool);
37
- if (pool.active >= 4) {
17
+ if (pool.active >= 1) {
38
18
  await new Promise((resolve, reject) => {
39
19
  const ready = () => { signal?.removeEventListener('abort', abort); resolve(); };
40
20
  const abort = () => { const index = pool.queue.indexOf(ready); if (index >= 0)
@@ -61,50 +41,19 @@ async function boundedProbe(deps, work, signal) {
61
41
  pool.active -= 1;
62
42
  }
63
43
  }
64
- /** Cancellation always reaches the effect port. A broken port cannot hold callers indefinitely. */
65
- export async function withModelProbeTimeout(work, timeoutMs, parent) {
66
- if (parent?.aborted)
67
- throw new ProbeTimeout('Probe cancelled');
68
- const controller = new AbortController();
69
- let timer;
70
- let abortListener;
71
- const stopped = new Promise((_resolve, reject) => {
72
- abortListener = () => { controller.abort(); reject(new ProbeTimeout('Probe cancelled')); };
73
- if (parent?.aborted)
74
- abortListener();
75
- else
76
- parent?.addEventListener('abort', abortListener, { once: true });
77
- timer = setTimeout(() => { controller.abort(); reject(new ProbeTimeout('Probe timed out')); }, timeoutMs);
78
- });
79
- try {
80
- return await Promise.race([work(controller.signal), stopped]);
81
- }
82
- finally {
83
- if (timer)
84
- clearTimeout(timer);
85
- if (abortListener)
86
- parent?.removeEventListener('abort', abortListener);
87
- }
88
- }
89
44
  function diagnostic(error) {
90
45
  // Never stringify vendor errors: stderr/HTTP bodies may include credentials or OAuth URLs.
91
46
  const code = error instanceof ProbeTimeout ? 'timeout' : error instanceof ModelProbeError ? error.code : 'offline';
92
47
  return { code, message: ({ timeout: 'Model discovery timed out or was cancelled.', 'auth-missing': 'CLI credentials are unavailable.', 'unsupported-version': 'Native metadata discovery is unavailable for this version.', 'malformed-response': 'Native metadata contained no usable catalog.', offline: 'Model discovery could not reach its source.' })[code] };
93
48
  }
94
- export class ModelProbeError extends Error {
95
- code;
96
- constructor(code) {
97
- super(code);
98
- this.code = code;
99
- }
100
- }
101
- export function createAgentModelDiscovery(agentId, configuration) {
49
+ /** Bind supplied effect ports and probes without loading their native implementations. */
50
+ export function createAgentModelDiscovery({ agentId, configuration, deps: suppliedDeps, probes }, _optional = {}) {
102
51
  async function scoped(context, deps, signal) {
103
52
  const config = await withModelProbeTimeout((active) => readModelConfiguration(agentId, context, deps, active), 1000, signal);
104
53
  return { config, key: `${agentId}:${modelLaunchFingerprint(context, config.fingerprintMaterial)}` };
105
54
  }
106
55
  const port = {
107
- async discoverModels({ context }, { signal, force = false, staleWhileRevalidate = false, deps = defaultModelDiscoveryDeps } = {}) {
56
+ async discoverModels({ context }, { signal, force = false, staleWhileRevalidate = false, deps = suppliedDeps } = {}) {
108
57
  let state;
109
58
  try {
110
59
  state = await scoped(context, deps, signal);
@@ -129,7 +78,7 @@ export function createAgentModelDiscovery(agentId, configuration) {
129
78
  // immediately; keep stale provenance and refresh without holding the first user prompt.
130
79
  if (staleWhileRevalidate && !cached.good && ['codex', 'opencode', 'claude', 'codebuddy'].includes(agentId)) {
131
80
  try {
132
- const disk = await withModelProbeTimeout(active => probeCliCache(agentId, context, deps, active), 1000, signal);
81
+ const disk = await withModelProbeTimeout(active => probes.cliCache({ agentId, context, deps, signal: active }), 1000, signal);
133
82
  const models = normalizeDiscoveredModels(disk.models);
134
83
  if (models.length)
135
84
  cached.good = { models, source: 'cli-cache', coverage: disk.coverage,
@@ -145,7 +94,7 @@ export function createAgentModelDiscovery(agentId, configuration) {
145
94
  }
146
95
  const pending = (async () => {
147
96
  try {
148
- const result = await boundedProbe(deps, () => withModelProbeTimeout((active) => probeAgentCatalog(agentId, context, configuration, deps, active), modelProbeTimeoutMs(agentId), signal), signal);
97
+ const result = await boundedProbe(deps, () => withModelProbeTimeout((active) => probes.catalog({ agentId, context, configuration, deps, signal: active }), probes.timeoutMs({ agentId }), signal), signal);
149
98
  const models = normalizeDiscoveredModels(result.models);
150
99
  if (models.length === 0)
151
100
  throw new ModelProbeError('malformed-response');
@@ -184,7 +133,7 @@ export function createAgentModelDiscovery(agentId, configuration) {
184
133
  // catalog while this coalesced, timeout-bounded refresh updates the cache in the background.
185
134
  return staleWhileRevalidate && lastGood() || pending;
186
135
  },
187
- async resolveDefaultModel({ context, catalog }, { signal, deps = defaultModelDiscoveryDeps } = {}) {
136
+ async resolveDefaultModel({ context, catalog }, { signal, deps = suppliedDeps } = {}) {
188
137
  let state;
189
138
  try {
190
139
  state = await scoped(context, deps, signal);
@@ -214,9 +163,9 @@ export function createAgentModelDiscovery(agentId, configuration) {
214
163
  export function offlineSnapshot(fingerprint, now, fallback, issue) {
215
164
  return { models: normalizeDiscoveredModels(fallback), source: 'offline-fallback', freshness: 'offline-fallback', fetchedAt: new Date(now).toISOString(), expiresAt: new Date(now + MODEL_NEGATIVE_TTL_MS).toISOString(), coverage: 'configured', launchFingerprint: fingerprint, diagnostics: issue ? [issue] : [] };
216
165
  }
217
- export function modelDiscoveryForDef(def) {
166
+ export function modelDiscoveryForDef({ def, deps: suppliedDeps }, _optional = {}) {
218
167
  return {
219
- discoverModels: def.discoverModels?.bind(def) || (async ({ context }, { deps = defaultModelDiscoveryDeps } = {}) => {
168
+ discoverModels: def.discoverModels?.bind(def) || (async ({ context }, { deps = suppliedDeps } = {}) => {
220
169
  const catalog = offlineSnapshot(modelLaunchFingerprint(context), deps.clock.now(), def.fallbackModels || []);
221
170
  const configured = def.defaultModelEnvVar && context.env[def.defaultModelEnvVar]?.trim();
222
171
  // Static hosts used their first concrete fallback before discovery existed. Preserve
@@ -224,7 +173,7 @@ export function modelDiscoveryForDef(def) {
224
173
  const selection = context.model || context.resumedModelId || configured || catalog.models.find(row => row.identityKind === 'concrete')?.id;
225
174
  return { ...catalog, ...(selection ? { defaultSelectionId: selection } : {}) };
226
175
  }),
227
- resolveDefaultModel: def.resolveDefaultModel?.bind(def) || (async ({ context, catalog }, { deps = defaultModelDiscoveryDeps } = {}) => {
176
+ resolveDefaultModel: def.resolveDefaultModel?.bind(def) || (async ({ context, catalog }, { deps = suppliedDeps } = {}) => {
228
177
  if (context.resumeSessionId && !context.resumedModelId && !context.model)
229
178
  return { status: 'unresolved', reason: 'Resumed session model metadata is required.' };
230
179
  const configured = def.defaultModelEnvVar && context.env[def.defaultModelEnvVar]?.trim();
@@ -241,16 +190,16 @@ export function modelDiscoveryForDef(def) {
241
190
  };
242
191
  }
243
192
  /** Resolve against the actual launch scope, pinning the same ID shown by the picker. */
244
- export async function resolveModelForLaunch({ def, context }, optional = {}) {
193
+ export async function resolveModelForLaunch({ def, context, deps }, optional = {}) {
245
194
  if (def.supportsConcreteModelSelection === false)
246
195
  throw new Error('This CLI cannot pin a concrete starting model.');
247
- const discovery = modelDiscoveryForDef(def);
248
- const catalog = await discovery.discoverModels({ context }, { ...optional, staleWhileRevalidate: true });
196
+ const discovery = modelDiscoveryForDef({ def, deps });
197
+ const catalog = await discovery.discoverModels({ context }, { ...optional, deps, staleWhileRevalidate: true });
249
198
  // Custom input is allowed unless the definition explicitly disables it: an unlisted user ID is already
250
199
  // the intended launch value. Never substitute a catalog default or reinterpret its spelling.
251
200
  const resolution = context.model && def.supportsCustomModel !== false && !catalog.models.some(row => row.id === context.model)
252
- ? { status: 'resolved', id: context.model, selectionId: context.model, source: 'config-file', resolvedAt: new Date((optional.deps || defaultModelDiscoveryDeps).clock.now()).toISOString(), launchFingerprint: catalog.launchFingerprint }
253
- : await discovery.resolveDefaultModel({ context, catalog }, optional);
201
+ ? { status: 'resolved', id: context.model, selectionId: context.model, source: 'config-file', resolvedAt: new Date(deps.clock.now()).toISOString(), launchFingerprint: catalog.launchFingerprint }
202
+ : await discovery.resolveDefaultModel({ context, catalog }, { ...optional, deps });
254
203
  if (resolution.status !== 'resolved')
255
204
  throw new Error(resolution.reason);
256
205
  return { model: resolution.id, catalog, resolution };
package/dist/models.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type AgentRuntimeState } from './runtime-state.js';
1
2
  /**
2
3
  * @module models
3
4
  * Model-option defaults, a per-agent last-surfaced live-model cache and model-id sanitization.
@@ -7,13 +8,15 @@ export declare const DEFAULT_MODEL_OPTION: RuntimeModelOption;
7
8
  export declare function rememberLiveModels({ agentId, models }: {
8
9
  agentId: string;
9
10
  models: RuntimeModelOption[];
10
- }, { scope }?: {
11
+ }, { scope, state }?: {
11
12
  scope?: string | null;
13
+ state?: AgentRuntimeState;
12
14
  }): void;
13
15
  export declare function getRememberedLiveModels({ agentId }: {
14
16
  agentId: string;
15
- }, { scope }?: {
17
+ }, { scope, state }?: {
16
18
  scope?: string | null;
19
+ state?: AgentRuntimeState;
17
20
  }): RuntimeModelOption[];
18
21
  export declare function preferFreshLiveModels({ freshModels, rememberedModels }: {
19
22
  freshModels: RuntimeModelOption[];
@@ -22,15 +25,17 @@ export declare function preferFreshLiveModels({ freshModels, rememberedModels }:
22
25
  export declare function isKnownModel({ def, modelId }: {
23
26
  def: RuntimeAgentDef;
24
27
  modelId: string | null | undefined;
25
- }, { scope }?: {
28
+ }, { scope, state }?: {
26
29
  scope?: string | null;
30
+ state?: AgentRuntimeState;
27
31
  }): boolean;
28
32
  export declare function resolveModelForAgent({ def, resolved }: {
29
33
  def: RuntimeAgentDef;
30
34
  resolved: string | null;
31
- }, { env, liveModelScope }?: {
35
+ }, { env, liveModelScope, state }?: {
32
36
  env?: Record<string, string | undefined>;
33
37
  liveModelScope?: string | null;
38
+ state?: AgentRuntimeState;
34
39
  }): string | null;
35
40
  export declare function sanitizeCustomModel({ id }: {
36
41
  id: string | null | undefined;