@gobing-ai/ts-ai-runner 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.
@@ -2,8 +2,8 @@ import { type Logger } from '@gobing-ai/ts-infra';
2
2
  import { type FileSystem } from '@gobing-ai/ts-runtime';
3
3
  import { AgentDetector } from './agent-detector';
4
4
  import { type AuthState } from './agents/auth-shims';
5
- import { type AgentName } from './agents/shims';
6
5
  import { AiRunner } from './ai-runner';
6
+ import { ModelHealthProbeRegistry, type ModelHealthResult } from './model-health-probe';
7
7
  /** Health-check result for one coding agent. */
8
8
  export interface DoctorResult {
9
9
  /** Agent identifier. */
@@ -32,10 +32,21 @@ export interface DoctorResult {
32
32
  /** True when the resolved canonical id is marked deprecated. */
33
33
  deprecated?: boolean;
34
34
  /** Canonical replacement when the resolved id is deprecated, if any. */
35
- replacedBy?: AgentName;
35
+ replacedBy?: string;
36
+ /** Model health status when a probe was run; `null` when no model override applies. */
37
+ modelStatus?: ModelHealthResult | null;
36
38
  /** Probe error when unavailable. */
37
39
  error: string | null;
38
40
  }
41
+ /** Executor configuration — mirrors Spur's AgentExecutorConfig for doctor probing. */
42
+ export interface ExecutorConfig {
43
+ /** Executor name (e.g. `omp-zai`, `omp-zai-volc`). */
44
+ name: string;
45
+ /** Underlying agent binary (e.g. `omp`). */
46
+ agent: string;
47
+ /** Model string in `provider/model` form (e.g. `zai/glm-5.2`). */
48
+ model?: string;
49
+ }
39
50
  /** Constructor options for DoctorRunner. */
40
51
  export interface DoctorRunnerOptions {
41
52
  /** Shared detector. */
@@ -50,6 +61,12 @@ export interface DoctorRunnerOptions {
50
61
  logger?: Logger;
51
62
  /** Filesystem for auth-file checks. Defaults to `createNodeFileSystem()`; inject to test without disk. */
52
63
  fileSystem?: FileSystem;
64
+ /** Executor configurations — when present, doctor probes each executor's model health. */
65
+ executors?: ExecutorConfig[];
66
+ /** Probe registry for model health checks. Defaults to a registry with {@link OmpModelProbe}. */
67
+ probeRegistry?: ModelHealthProbeRegistry;
68
+ /** Model health probe timeout in milliseconds (default 10s). */
69
+ probeTimeoutMs?: number;
53
70
  }
54
71
  /** Runs installation, liveness, and auth health checks for supported coding agents. */
55
72
  export declare class DoctorRunner {
@@ -59,11 +76,25 @@ export declare class DoctorRunner {
59
76
  private readonly env;
60
77
  private readonly fs;
61
78
  private readonly logger;
79
+ private readonly executors;
80
+ private readonly probeRegistry;
81
+ private readonly probeTimeoutMs;
62
82
  constructor(options?: DoctorRunnerOptions);
83
+ /** Create the default probe registry with OmpModelProbe for all known omp providers. */
84
+ private createDefaultRegistry;
63
85
  /** Run a health check on all supported agents. */
64
86
  runAll(): Promise<DoctorResult[]>;
87
+ /**
88
+ * Executor-aware runAll: iterate configured executors instead of
89
+ * DISPLAY_ORDER, giving each executor its own row with model health.
90
+ */
91
+ private runAllWithExecutors;
65
92
  /** Run a health check on one agent. */
66
93
  runOne(agent: string): Promise<DoctorResult>;
94
+ /** Probe model health for a `provider/model` string. */
95
+ private probeModel;
96
+ /** Resolve API key from env using `{PROVIDER_UPPERCASE}_API_KEY` convention. */
97
+ private resolveApiKey;
67
98
  private buildResult;
68
99
  }
69
100
  //# sourceMappingURL=doctor-runner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"doctor-runner.d.ts","sourceRoot":"","sources":["../src/doctor-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAwB,KAAK,UAAU,EAAiB,MAAM,uBAAuB,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAE,KAAK,SAAS,EAAmB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,KAAK,SAAS,EAAiD,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,gDAAgD;AAChD,MAAM,WAAW,YAAY;IACzB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,SAAS,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB;;;;;OAKG;IACH,aAAa,EAAE,SAAS,CAAC;IACzB;;;;OAIG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,oEAAoE;IACpE,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC;IACZ,wDAAwD;IACxD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gEAAgE;IAChE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wEAAwE;IACxE,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,oCAAoC;IACpC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAChC,uBAAuB;IACvB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,qBAAqB;IACrB,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0GAA0G;IAC1G,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAID,uFAAuF;AACvF,qBAAa,YAAY;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAqC;IACzD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,OAAO,GAAE,mBAAwB;IAS7C,kDAAkD;IAC5C,MAAM,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAkBvC,uCAAuC;IACjC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;YAIpC,WAAW;CA4B5B"}
1
+ {"version":3,"file":"doctor-runner.d.ts","sourceRoot":"","sources":["../src/doctor-runner.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,7 @@ import { AgentDetector } from './agent-detector.js';
4
4
  import { isAuthenticated } from './agents/auth-shims.js';
5
5
  import { DISPLAY_ORDER, resolveAgentName, TIER2_AGENTS } from './agents/shims.js';
6
6
  import { AiRunner } from './ai-runner.js';
7
+ import { DEFAULT_PROBE_TIMEOUT_MS, extractProvider, ModelHealthProbeRegistry, OmpModelProbe, } from './model-health-probe.js';
7
8
  const DEFAULT_TIMEOUT_MS = 5_000;
8
9
  /** Runs installation, liveness, and auth health checks for supported coding agents. */
9
10
  export class DoctorRunner {
@@ -13,6 +14,9 @@ export class DoctorRunner {
13
14
  env;
14
15
  fs;
15
16
  logger;
17
+ executors;
18
+ probeRegistry;
19
+ probeTimeoutMs;
16
20
  constructor(options = {}) {
17
21
  this.runner = options.runner ?? new AiRunner();
18
22
  this.detector = options.agentDetector ?? new AgentDetector({ runner: this.runner });
@@ -20,9 +24,24 @@ export class DoctorRunner {
20
24
  this.env = options.env ?? getProcessEnv();
21
25
  this.fs = options.fileSystem ?? createNodeFileSystem();
22
26
  this.logger = options.logger ?? getLogger('doctor');
27
+ this.executors = options.executors ?? [];
28
+ this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
29
+ this.probeRegistry = options.probeRegistry ?? this.createDefaultRegistry();
30
+ }
31
+ /** Create the default probe registry with OmpModelProbe for all known omp providers. */
32
+ createDefaultRegistry() {
33
+ const registry = new ModelHealthProbeRegistry();
34
+ const probe = new OmpModelProbe();
35
+ for (const provider of ['zai', 'volc', 'minimax', 'deepseek']) {
36
+ registry.register(provider, probe);
37
+ }
38
+ return registry;
23
39
  }
24
40
  /** Run a health check on all supported agents. */
25
41
  async runAll() {
42
+ if (this.executors.length > 0) {
43
+ return await this.runAllWithExecutors();
44
+ }
26
45
  const detected = await this.detector.detectAll();
27
46
  const byName = new Map(detected.map((agent) => [agent.name, agent]));
28
47
  return await Promise.all(DISPLAY_ORDER.map((agent) => this.buildResult(byName.get(agent) ?? {
@@ -33,10 +52,74 @@ export class DoctorRunner {
33
52
  error: `Unknown agent: ${agent}`,
34
53
  })));
35
54
  }
55
+ /**
56
+ * Executor-aware runAll: iterate configured executors instead of
57
+ * DISPLAY_ORDER, giving each executor its own row with model health.
58
+ */
59
+ async runAllWithExecutors() {
60
+ const detected = await this.detector.detectAll();
61
+ const byName = new Map(detected.map((agent) => [agent.name, agent]));
62
+ const results = [];
63
+ for (const executor of this.executors) {
64
+ const agentDetected = byName.get(executor.agent) ?? {
65
+ name: executor.agent,
66
+ installed: false,
67
+ version: null,
68
+ channels: [],
69
+ error: `Unknown agent: ${executor.agent}`,
70
+ };
71
+ const result = await this.buildResult(agentDetected);
72
+ // Override the agent name with the executor name for display
73
+ result.agent = executor.name;
74
+ // R1: probe model health when a model override is present; null otherwise.
75
+ result.modelStatus = executor.model ? await this.probeModel(executor.model) : null;
76
+ results.push(result);
77
+ }
78
+ return results;
79
+ }
36
80
  /** Run a health check on one agent. */
37
81
  async runOne(agent) {
82
+ // When executors are configured, match executor name first
83
+ if (this.executors.length > 0) {
84
+ const executor = this.executors.find((e) => e.name === agent);
85
+ if (executor) {
86
+ const detected = await this.detector.detectOne(executor.agent);
87
+ const result = await this.buildResult(detected);
88
+ result.agent = executor.name;
89
+ result.modelStatus = executor.model ? await this.probeModel(executor.model) : null;
90
+ return result;
91
+ }
92
+ }
38
93
  return this.buildResult(await this.detector.detectOne(agent));
39
94
  }
95
+ /** Probe model health for a `provider/model` string. */
96
+ async probeModel(model) {
97
+ const provider = extractProvider(model);
98
+ const probe = this.probeRegistry.resolve(model);
99
+ if (!probe) {
100
+ return {
101
+ status: 'unknown',
102
+ detail: `no probe registered for provider '${provider}'`,
103
+ checkedAt: new Date().toISOString(),
104
+ };
105
+ }
106
+ const apiKey = this.resolveApiKey(provider);
107
+ if (!apiKey) {
108
+ return {
109
+ status: 'unknown',
110
+ detail: `API key not found for provider '${provider}'`,
111
+ checkedAt: new Date().toISOString(),
112
+ };
113
+ }
114
+ return await probe.probe(provider, model, {
115
+ apiKey,
116
+ timeoutMs: this.probeTimeoutMs,
117
+ });
118
+ }
119
+ /** Resolve API key from env using `{PROVIDER_UPPERCASE}_API_KEY` convention. */
120
+ resolveApiKey(provider) {
121
+ return this.env[`${provider.toUpperCase()}_API_KEY`];
122
+ }
40
123
  async buildResult(detected) {
41
124
  const canonical = resolveAgentName(detected.name) ?? null;
42
125
  const tier = canonical !== null && TIER2_AGENTS.has(canonical) ? 2 : 1;
@@ -54,8 +137,8 @@ export class DoctorRunner {
54
137
  installed: detected.installed,
55
138
  version: detected.version,
56
139
  authenticated,
57
- // Liveness only: auth never gates runnability. A logged-out agent
58
- // is usable (it fails at runtime with its own error).
140
+ // Liveness only: auth never gates runnability. A logged-out
141
+ // agent is usable (it fails at runtime with its own error).
59
142
  usable: detected.installed && detected.version !== null,
60
143
  tier,
61
144
  channels: detected.channels,
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './doctor-runner';
7
7
  export * from './events';
8
8
  export * from './identity';
9
9
  export * from './messages';
10
+ export * from './model-health-probe';
10
11
  export * from './slash-command';
11
12
  export * from './team-agent-process';
12
13
  export * from './team-orchestrator';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './doctor-runner.js';
7
7
  export * from './events.js';
8
8
  export * from './identity.js';
9
9
  export * from './messages.js';
10
+ export * from './model-health-probe.js';
10
11
  export * from './slash-command.js';
11
12
  export * from './team-agent-process.js';
12
13
  export * from './team-orchestrator.js';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Model health probe infrastructure.
3
+ *
4
+ * Provides a pluggable probe interface for checking model-level liveness and
5
+ * quota state on provider APIs. The {@link ModelHealthProbeRegistry} maps
6
+ * provider prefixes (e.g. `volc/`, `zai/`) to probe implementations, so new
7
+ * providers can be added without modifying {@link DoctorRunner} core (R9).
8
+ *
9
+ * The shipped {@link OmpModelProbe} handles all `omp`-configured providers by
10
+ * issuing a minimal 1-token completion request and interpreting the HTTP
11
+ * response (R3).
12
+ */
13
+ import { type APIClientConfig } from '@gobing-ai/ts-infra';
14
+ /** Health status for a single model endpoint. */
15
+ export type ModelHealthStatus = 'available' | 'quota_exhausted' | 'rate_limited' | 'unavailable' | 'unknown';
16
+ /** Result of probing one model's health (R2). */
17
+ export interface ModelHealthResult {
18
+ status: ModelHealthStatus;
19
+ /** Sanitized provider error message — never contains API keys or headers. */
20
+ detail?: string;
21
+ /** ISO 8601 timestamp of when the probe completed. */
22
+ checkedAt: string;
23
+ }
24
+ /** Configuration passed to a probe — the probe is a pure HTTP client (R3). */
25
+ export interface ProbeConfig {
26
+ /** API key for the provider, resolved by the caller (DoctorRunner) from env. */
27
+ apiKey: string;
28
+ /** Bounded timeout in milliseconds (default 10s). A timeout resolves to `unknown` (R8). */
29
+ timeoutMs: number;
30
+ /** Optional endpoint override (for testing or custom deployments). */
31
+ endpoint?: string;
32
+ }
33
+ /** Provider-agnostic probe interface (R2). */
34
+ export interface ModelHealthProbe {
35
+ probe(provider: string, model: string, config: ProbeConfig): Promise<ModelHealthResult>;
36
+ }
37
+ /**
38
+ * Maps provider prefixes to probe implementations (R9).
39
+ * The provider prefix is extracted from the model string's first path segment
40
+ * (e.g. `volc/glm-5.2` → `volc`).
41
+ */
42
+ export declare class ModelHealthProbeRegistry {
43
+ private readonly probes;
44
+ /** Register a probe for a provider prefix (e.g. `volc`, `zai`). */
45
+ register(providerPrefix: string, probe: ModelHealthProbe): void;
46
+ /** Resolve a probe for a model string, or `null` when no probe is registered. */
47
+ resolve(model: string): ModelHealthProbe | null;
48
+ }
49
+ /** Extract the provider prefix from a `provider/model` string. */
50
+ export declare function extractProvider(model: string): string;
51
+ /** Extract the model name from a `provider/model` string. */
52
+ export declare function extractModelName(model: string): string;
53
+ declare const DEFAULT_PROBE_TIMEOUT_MS = 10000;
54
+ /** Options for constructing an {@link OmpModelProbe}. */
55
+ export interface OmpModelProbeOptions {
56
+ /**
57
+ * Optional {@link APIClientConfig} (without `baseUrl` — the probe owns the
58
+ * provider→baseUrl mapping). Pass `fetch` here to inject a custom fetch
59
+ * for testing; omit to use `globalThis.fetch` (resolved lazily per request
60
+ * by `APIClient`, so test-time `globalThis.fetch` mutations take effect).
61
+ */
62
+ apiClientConfig?: Omit<APIClientConfig, 'baseUrl'>;
63
+ }
64
+ /**
65
+ * Probe for `omp`-configured providers (R3).
66
+ *
67
+ * Issues a minimal 1-token completion request (`prompt: "ping"`, `max_tokens: 1`)
68
+ * to the provider's API and interprets the response:
69
+ * - 200 → `available`
70
+ * - 429 + quota body → `quota_exhausted`
71
+ * - 429 + rate-limit body → `rate_limited`
72
+ * - Other 4xx/5xx → `unavailable`
73
+ * - HTTP timeout (via APIClient, reported as APIError status 0) → `unknown`
74
+ *
75
+ * Uses {@link APIClient} from `@gobing-ai/ts-infra` for all HTTP — the
76
+ * centralized HTTP seam per `external-api-boundaries` rule. Supports both
77
+ * `openai-completions` and `anthropic-messages` API formats. The
78
+ * provider→apiType mapping is built into this probe since it is specifically
79
+ * the `OmpModelProbe` — pluggability (R9) is at the registry level.
80
+ */
81
+ export declare class OmpModelProbe implements ModelHealthProbe {
82
+ private readonly apiClientConfig?;
83
+ constructor(options?: OmpModelProbeOptions);
84
+ probe(provider: string, model: string, config: ProbeConfig): Promise<ModelHealthResult>;
85
+ /** Issue a minimal completion request based on the API format. */
86
+ private issueRequest;
87
+ /** Interpret the HTTP response into a {@link ModelHealthResult}. */
88
+ private interpretResponse;
89
+ /** Best-effort JSON body parse; returns null on failure. */
90
+ private parseBody;
91
+ }
92
+ /** Default probe timeout in milliseconds (R8). */
93
+ export { DEFAULT_PROBE_TIMEOUT_MS };
94
+ //# sourceMappingURL=model-health-probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-health-probe.d.ts","sourceRoot":"","sources":["../src/model-health-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAa,KAAK,eAAe,EAAkC,MAAM,qBAAqB,CAAC;AAEtG,iDAAiD;AACjD,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG,iBAAiB,GAAG,cAAc,GAAG,aAAa,GAAG,SAAS,CAAC;AAE7G,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAC9B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,8EAA8E;AAC9E,MAAM,WAAW,WAAW;IACxB,gFAAgF;IAChF,MAAM,EAAE,MAAM,CAAC;IACf,2FAA2F;IAC3F,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAC7B,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CAC3F;AAED;;;;GAIG;AACH,qBAAa,wBAAwB;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuC;IAE9D,mEAAmE;IACnE,QAAQ,CAAC,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI;IAI/D,iFAAiF;IACjF,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI;CAKlD;AAED,kEAAkE;AAClE,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED,6DAA6D;AAC7D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGtD;AAgBD,QAAA,MAAM,wBAAwB,QAAS,CAAC;AAExC,yDAAyD;AACzD,MAAM,WAAW,oBAAoB;IACjC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,CAAC;CACtD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,aAAc,YAAW,gBAAgB;IAClD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAmC;gBAExD,OAAO,CAAC,EAAE,oBAAoB;IAIpC,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA6B7F,kEAAkE;YACpD,YAAY;IAiD1B,oEAAoE;IACpE,OAAO,CAAC,iBAAiB;IAwBzB,4DAA4D;IAC5D,OAAO,CAAC,SAAS;CAOpB;AAED,kDAAkD;AAClD,OAAO,EAAE,wBAAwB,EAAE,CAAC"}
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Model health probe infrastructure.
3
+ *
4
+ * Provides a pluggable probe interface for checking model-level liveness and
5
+ * quota state on provider APIs. The {@link ModelHealthProbeRegistry} maps
6
+ * provider prefixes (e.g. `volc/`, `zai/`) to probe implementations, so new
7
+ * providers can be added without modifying {@link DoctorRunner} core (R9).
8
+ *
9
+ * The shipped {@link OmpModelProbe} handles all `omp`-configured providers by
10
+ * issuing a minimal 1-token completion request and interpreting the HTTP
11
+ * response (R3).
12
+ */
13
+ import { APIClient, APIError } from '@gobing-ai/ts-infra';
14
+ /**
15
+ * Maps provider prefixes to probe implementations (R9).
16
+ * The provider prefix is extracted from the model string's first path segment
17
+ * (e.g. `volc/glm-5.2` → `volc`).
18
+ */
19
+ export class ModelHealthProbeRegistry {
20
+ probes = new Map();
21
+ /** Register a probe for a provider prefix (e.g. `volc`, `zai`). */
22
+ register(providerPrefix, probe) {
23
+ this.probes.set(providerPrefix, probe);
24
+ }
25
+ /** Resolve a probe for a model string, or `null` when no probe is registered. */
26
+ resolve(model) {
27
+ const slashIndex = model.indexOf('/');
28
+ const provider = slashIndex >= 0 ? model.slice(0, slashIndex) : model;
29
+ return this.probes.get(provider) ?? null;
30
+ }
31
+ }
32
+ /** Extract the provider prefix from a `provider/model` string. */
33
+ export function extractProvider(model) {
34
+ const slashIndex = model.indexOf('/');
35
+ return slashIndex >= 0 ? model.slice(0, slashIndex) : model;
36
+ }
37
+ /** Extract the model name from a `provider/model` string. */
38
+ export function extractModelName(model) {
39
+ const slashIndex = model.indexOf('/');
40
+ return slashIndex >= 0 ? model.slice(slashIndex + 1) : model;
41
+ }
42
+ /** Known omp provider configurations (baseUrl + API format). */
43
+ const OMP_PROVIDERS = {
44
+ zai: { baseUrl: 'https://api.z.ai/api/coding/paas/v4', apiType: 'openai-completions' },
45
+ volc: { baseUrl: 'https://ark.cn-beijing.volces.com/api/coding', apiType: 'anthropic-messages' },
46
+ minimax: { baseUrl: 'https://api.minimaxi.com/v1', apiType: 'openai-completions' },
47
+ deepseek: { baseUrl: 'https://api.deepseek.com', apiType: 'openai-completions' },
48
+ };
49
+ const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
50
+ /**
51
+ * Probe for `omp`-configured providers (R3).
52
+ *
53
+ * Issues a minimal 1-token completion request (`prompt: "ping"`, `max_tokens: 1`)
54
+ * to the provider's API and interprets the response:
55
+ * - 200 → `available`
56
+ * - 429 + quota body → `quota_exhausted`
57
+ * - 429 + rate-limit body → `rate_limited`
58
+ * - Other 4xx/5xx → `unavailable`
59
+ * - HTTP timeout (via APIClient, reported as APIError status 0) → `unknown`
60
+ *
61
+ * Uses {@link APIClient} from `@gobing-ai/ts-infra` for all HTTP — the
62
+ * centralized HTTP seam per `external-api-boundaries` rule. Supports both
63
+ * `openai-completions` and `anthropic-messages` API formats. The
64
+ * provider→apiType mapping is built into this probe since it is specifically
65
+ * the `OmpModelProbe` — pluggability (R9) is at the registry level.
66
+ */
67
+ export class OmpModelProbe {
68
+ apiClientConfig;
69
+ constructor(options) {
70
+ this.apiClientConfig = options?.apiClientConfig;
71
+ }
72
+ async probe(provider, model, config) {
73
+ const checkedAt = new Date().toISOString();
74
+ const providerConfig = OMP_PROVIDERS[provider];
75
+ if (!providerConfig) {
76
+ return { status: 'unknown', detail: `unknown omp provider '${provider}'`, checkedAt };
77
+ }
78
+ const baseUrl = config.endpoint ?? providerConfig.baseUrl;
79
+ try {
80
+ const response = await this.issueRequest(providerConfig.apiType, baseUrl, model, config.apiKey, config.timeoutMs);
81
+ return this.interpretResponse(response, checkedAt);
82
+ }
83
+ catch (error) {
84
+ // APIClient reports timeouts as APIError with status 0.
85
+ if (error instanceof APIError && error.status === 0) {
86
+ return { status: 'unknown', detail: 'probe timed out', checkedAt };
87
+ }
88
+ const message = error instanceof Error ? error.message : 'probe failed';
89
+ return { status: 'unknown', detail: message, checkedAt };
90
+ }
91
+ }
92
+ /** Issue a minimal completion request based on the API format. */
93
+ async issueRequest(apiType, baseUrl, model, apiKey, timeoutMs) {
94
+ const client = new APIClient({ ...this.apiClientConfig, baseUrl });
95
+ if (apiType === 'anthropic-messages') {
96
+ return await client.rawRequest('POST', '/messages', JSON.stringify({
97
+ model,
98
+ messages: [{ role: 'user', content: 'ping' }],
99
+ max_tokens: 1,
100
+ }), {
101
+ headers: {
102
+ 'Content-Type': 'application/json',
103
+ 'x-api-key': apiKey,
104
+ 'anthropic-version': '2023-06-01',
105
+ },
106
+ timeout: timeoutMs,
107
+ operationName: 'model-health-probe anthropic-messages',
108
+ });
109
+ }
110
+ return await client.rawRequest('POST', '/chat/completions', JSON.stringify({
111
+ model,
112
+ messages: [{ role: 'user', content: 'ping' }],
113
+ max_tokens: 1,
114
+ }), {
115
+ headers: {
116
+ 'Content-Type': 'application/json',
117
+ Authorization: `Bearer ${apiKey}`,
118
+ },
119
+ timeout: timeoutMs,
120
+ operationName: 'model-health-probe openai-completions',
121
+ });
122
+ }
123
+ /** Interpret the HTTP response into a {@link ModelHealthResult}. */
124
+ interpretResponse(response, checkedAt) {
125
+ if (response.status === 200) {
126
+ return { status: 'available', checkedAt };
127
+ }
128
+ if (response.status === 429) {
129
+ const body = this.parseBody(response.body);
130
+ const errorType = body?.error?.type ?? body?.error?.code ?? '';
131
+ const errorMessage = body?.error?.message ?? '';
132
+ if (errorType.includes('quota') ||
133
+ errorType.includes('insufficient_quota') ||
134
+ errorType.includes('insufficient')) {
135
+ return { status: 'quota_exhausted', detail: errorMessage || 'quota exceeded', checkedAt };
136
+ }
137
+ return { status: 'rate_limited', detail: errorMessage || 'rate limited', checkedAt };
138
+ }
139
+ // Other 4xx/5xx → unavailable
140
+ return { status: 'unavailable', detail: `HTTP ${response.status}`, checkedAt };
141
+ }
142
+ /** Best-effort JSON body parse; returns null on failure. */
143
+ parseBody(body) {
144
+ try {
145
+ return JSON.parse(body);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ }
152
+ /** Default probe timeout in milliseconds (R8). */
153
+ export { DEFAULT_PROBE_TIMEOUT_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-ai-runner",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "@gobing-ai/ts-ai-runner — Coding-agent shims, detection, doctor checks, and prompt execution.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -47,9 +47,9 @@
47
47
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-ai-runner-v<version> && git push --tags' && exit 1"
48
48
  },
49
49
  "dependencies": {
50
- "@gobing-ai/ts-db": "^0.4.4",
51
- "@gobing-ai/ts-infra": "^0.4.4",
52
- "@gobing-ai/ts-runtime": "^0.4.4"
50
+ "@gobing-ai/ts-db": "^0.4.6",
51
+ "@gobing-ai/ts-infra": "^0.4.6",
52
+ "@gobing-ai/ts-runtime": "^0.4.6"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/bun": "1.3.14"
@@ -2,8 +2,15 @@ import { getLogger, type Logger } from '@gobing-ai/ts-infra';
2
2
  import { createNodeFileSystem, type FileSystem, getProcessEnv } from '@gobing-ai/ts-runtime';
3
3
  import { AgentDetector, type DetectedAgent } from './agent-detector';
4
4
  import { type AuthState, isAuthenticated } from './agents/auth-shims';
5
- import { type AgentName, DISPLAY_ORDER, resolveAgentName, TIER2_AGENTS } from './agents/shims';
5
+ import { DISPLAY_ORDER, resolveAgentName, TIER2_AGENTS } from './agents/shims';
6
6
  import { AiRunner } from './ai-runner';
7
+ import {
8
+ DEFAULT_PROBE_TIMEOUT_MS,
9
+ extractProvider,
10
+ ModelHealthProbeRegistry,
11
+ type ModelHealthResult,
12
+ OmpModelProbe,
13
+ } from './model-health-probe';
7
14
 
8
15
  /** Health-check result for one coding agent. */
9
16
  export interface DoctorResult {
@@ -33,11 +40,23 @@ export interface DoctorResult {
33
40
  /** True when the resolved canonical id is marked deprecated. */
34
41
  deprecated?: boolean;
35
42
  /** Canonical replacement when the resolved id is deprecated, if any. */
36
- replacedBy?: AgentName;
43
+ replacedBy?: string;
44
+ /** Model health status when a probe was run; `null` when no model override applies. */
45
+ modelStatus?: ModelHealthResult | null;
37
46
  /** Probe error when unavailable. */
38
47
  error: string | null;
39
48
  }
40
49
 
50
+ /** Executor configuration — mirrors Spur's AgentExecutorConfig for doctor probing. */
51
+ export interface ExecutorConfig {
52
+ /** Executor name (e.g. `omp-zai`, `omp-zai-volc`). */
53
+ name: string;
54
+ /** Underlying agent binary (e.g. `omp`). */
55
+ agent: string;
56
+ /** Model string in `provider/model` form (e.g. `zai/glm-5.2`). */
57
+ model?: string;
58
+ }
59
+
41
60
  /** Constructor options for DoctorRunner. */
42
61
  export interface DoctorRunnerOptions {
43
62
  /** Shared detector. */
@@ -52,6 +71,12 @@ export interface DoctorRunnerOptions {
52
71
  logger?: Logger;
53
72
  /** Filesystem for auth-file checks. Defaults to `createNodeFileSystem()`; inject to test without disk. */
54
73
  fileSystem?: FileSystem;
74
+ /** Executor configurations — when present, doctor probes each executor's model health. */
75
+ executors?: ExecutorConfig[];
76
+ /** Probe registry for model health checks. Defaults to a registry with {@link OmpModelProbe}. */
77
+ probeRegistry?: ModelHealthProbeRegistry;
78
+ /** Model health probe timeout in milliseconds (default 10s). */
79
+ probeTimeoutMs?: number;
55
80
  }
56
81
 
57
82
  const DEFAULT_TIMEOUT_MS = 5_000;
@@ -64,6 +89,9 @@ export class DoctorRunner {
64
89
  private readonly env: Record<string, string | undefined>;
65
90
  private readonly fs: FileSystem;
66
91
  private readonly logger: Logger;
92
+ private readonly executors: ExecutorConfig[];
93
+ private readonly probeRegistry: ModelHealthProbeRegistry;
94
+ private readonly probeTimeoutMs: number;
67
95
 
68
96
  constructor(options: DoctorRunnerOptions = {}) {
69
97
  this.runner = options.runner ?? new AiRunner();
@@ -72,10 +100,26 @@ export class DoctorRunner {
72
100
  this.env = options.env ?? getProcessEnv();
73
101
  this.fs = options.fileSystem ?? createNodeFileSystem();
74
102
  this.logger = options.logger ?? getLogger('doctor');
103
+ this.executors = options.executors ?? [];
104
+ this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
105
+ this.probeRegistry = options.probeRegistry ?? this.createDefaultRegistry();
106
+ }
107
+
108
+ /** Create the default probe registry with OmpModelProbe for all known omp providers. */
109
+ private createDefaultRegistry(): ModelHealthProbeRegistry {
110
+ const registry = new ModelHealthProbeRegistry();
111
+ const probe = new OmpModelProbe();
112
+ for (const provider of ['zai', 'volc', 'minimax', 'deepseek']) {
113
+ registry.register(provider, probe);
114
+ }
115
+ return registry;
75
116
  }
76
117
 
77
118
  /** Run a health check on all supported agents. */
78
119
  async runAll(): Promise<DoctorResult[]> {
120
+ if (this.executors.length > 0) {
121
+ return await this.runAllWithExecutors();
122
+ }
79
123
  const detected = await this.detector.detectAll();
80
124
  const byName = new Map(detected.map((agent) => [agent.name, agent]));
81
125
  return await Promise.all(
@@ -93,11 +137,78 @@ export class DoctorRunner {
93
137
  );
94
138
  }
95
139
 
140
+ /**
141
+ * Executor-aware runAll: iterate configured executors instead of
142
+ * DISPLAY_ORDER, giving each executor its own row with model health.
143
+ */
144
+ private async runAllWithExecutors(): Promise<DoctorResult[]> {
145
+ const detected = await this.detector.detectAll();
146
+ const byName = new Map(detected.map((agent) => [agent.name, agent]));
147
+ const results: DoctorResult[] = [];
148
+ for (const executor of this.executors) {
149
+ const agentDetected = byName.get(executor.agent) ?? {
150
+ name: executor.agent,
151
+ installed: false,
152
+ version: null,
153
+ channels: [],
154
+ error: `Unknown agent: ${executor.agent}`,
155
+ };
156
+ const result = await this.buildResult(agentDetected);
157
+ // Override the agent name with the executor name for display
158
+ result.agent = executor.name;
159
+ // R1: probe model health when a model override is present; null otherwise.
160
+ result.modelStatus = executor.model ? await this.probeModel(executor.model) : null;
161
+ results.push(result);
162
+ }
163
+ return results;
164
+ }
165
+
96
166
  /** Run a health check on one agent. */
97
167
  async runOne(agent: string): Promise<DoctorResult> {
168
+ // When executors are configured, match executor name first
169
+ if (this.executors.length > 0) {
170
+ const executor = this.executors.find((e) => e.name === agent);
171
+ if (executor) {
172
+ const detected = await this.detector.detectOne(executor.agent);
173
+ const result = await this.buildResult(detected);
174
+ result.agent = executor.name;
175
+ result.modelStatus = executor.model ? await this.probeModel(executor.model) : null;
176
+ return result;
177
+ }
178
+ }
98
179
  return this.buildResult(await this.detector.detectOne(agent));
99
180
  }
100
181
 
182
+ /** Probe model health for a `provider/model` string. */
183
+ private async probeModel(model: string): Promise<ModelHealthResult> {
184
+ const provider = extractProvider(model);
185
+ const probe = this.probeRegistry.resolve(model);
186
+ if (!probe) {
187
+ return {
188
+ status: 'unknown',
189
+ detail: `no probe registered for provider '${provider}'`,
190
+ checkedAt: new Date().toISOString(),
191
+ };
192
+ }
193
+ const apiKey = this.resolveApiKey(provider);
194
+ if (!apiKey) {
195
+ return {
196
+ status: 'unknown',
197
+ detail: `API key not found for provider '${provider}'`,
198
+ checkedAt: new Date().toISOString(),
199
+ };
200
+ }
201
+ return await probe.probe(provider, model, {
202
+ apiKey,
203
+ timeoutMs: this.probeTimeoutMs,
204
+ });
205
+ }
206
+
207
+ /** Resolve API key from env using `{PROVIDER_UPPERCASE}_API_KEY` convention. */
208
+ private resolveApiKey(provider: string): string | undefined {
209
+ return this.env[`${provider.toUpperCase()}_API_KEY`];
210
+ }
211
+
101
212
  private async buildResult(detected: DetectedAgent): Promise<DoctorResult> {
102
213
  const canonical = resolveAgentName(detected.name) ?? null;
103
214
  const tier = canonical !== null && TIER2_AGENTS.has(canonical) ? 2 : 1;
@@ -116,8 +227,8 @@ export class DoctorRunner {
116
227
  installed: detected.installed,
117
228
  version: detected.version,
118
229
  authenticated,
119
- // Liveness only: auth never gates runnability. A logged-out agent
120
- // is usable (it fails at runtime with its own error).
230
+ // Liveness only: auth never gates runnability. A logged-out
231
+ // agent is usable (it fails at runtime with its own error).
121
232
  usable: detected.installed && detected.version !== null,
122
233
  tier,
123
234
  channels: detected.channels,
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './doctor-runner';
7
7
  export * from './events';
8
8
  export * from './identity';
9
9
  export * from './messages';
10
+ export * from './model-health-probe';
10
11
  export * from './slash-command';
11
12
  export * from './team-agent-process';
12
13
  export * from './team-orchestrator';
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Model health probe infrastructure.
3
+ *
4
+ * Provides a pluggable probe interface for checking model-level liveness and
5
+ * quota state on provider APIs. The {@link ModelHealthProbeRegistry} maps
6
+ * provider prefixes (e.g. `volc/`, `zai/`) to probe implementations, so new
7
+ * providers can be added without modifying {@link DoctorRunner} core (R9).
8
+ *
9
+ * The shipped {@link OmpModelProbe} handles all `omp`-configured providers by
10
+ * issuing a minimal 1-token completion request and interpreting the HTTP
11
+ * response (R3).
12
+ */
13
+ import { APIClient, type APIClientConfig, APIError, type RawHttpResponse } from '@gobing-ai/ts-infra';
14
+
15
+ /** Health status for a single model endpoint. */
16
+ export type ModelHealthStatus = 'available' | 'quota_exhausted' | 'rate_limited' | 'unavailable' | 'unknown';
17
+
18
+ /** Result of probing one model's health (R2). */
19
+ export interface ModelHealthResult {
20
+ status: ModelHealthStatus;
21
+ /** Sanitized provider error message — never contains API keys or headers. */
22
+ detail?: string;
23
+ /** ISO 8601 timestamp of when the probe completed. */
24
+ checkedAt: string;
25
+ }
26
+
27
+ /** Configuration passed to a probe — the probe is a pure HTTP client (R3). */
28
+ export interface ProbeConfig {
29
+ /** API key for the provider, resolved by the caller (DoctorRunner) from env. */
30
+ apiKey: string;
31
+ /** Bounded timeout in milliseconds (default 10s). A timeout resolves to `unknown` (R8). */
32
+ timeoutMs: number;
33
+ /** Optional endpoint override (for testing or custom deployments). */
34
+ endpoint?: string;
35
+ }
36
+
37
+ /** Provider-agnostic probe interface (R2). */
38
+ export interface ModelHealthProbe {
39
+ probe(provider: string, model: string, config: ProbeConfig): Promise<ModelHealthResult>;
40
+ }
41
+
42
+ /**
43
+ * Maps provider prefixes to probe implementations (R9).
44
+ * The provider prefix is extracted from the model string's first path segment
45
+ * (e.g. `volc/glm-5.2` → `volc`).
46
+ */
47
+ export class ModelHealthProbeRegistry {
48
+ private readonly probes = new Map<string, ModelHealthProbe>();
49
+
50
+ /** Register a probe for a provider prefix (e.g. `volc`, `zai`). */
51
+ register(providerPrefix: string, probe: ModelHealthProbe): void {
52
+ this.probes.set(providerPrefix, probe);
53
+ }
54
+
55
+ /** Resolve a probe for a model string, or `null` when no probe is registered. */
56
+ resolve(model: string): ModelHealthProbe | null {
57
+ const slashIndex = model.indexOf('/');
58
+ const provider = slashIndex >= 0 ? model.slice(0, slashIndex) : model;
59
+ return this.probes.get(provider) ?? null;
60
+ }
61
+ }
62
+
63
+ /** Extract the provider prefix from a `provider/model` string. */
64
+ export function extractProvider(model: string): string {
65
+ const slashIndex = model.indexOf('/');
66
+ return slashIndex >= 0 ? model.slice(0, slashIndex) : model;
67
+ }
68
+
69
+ /** Extract the model name from a `provider/model` string. */
70
+ export function extractModelName(model: string): string {
71
+ const slashIndex = model.indexOf('/');
72
+ return slashIndex >= 0 ? model.slice(slashIndex + 1) : model;
73
+ }
74
+
75
+ /** Provider configuration for omp-managed providers. */
76
+ interface OmpProviderConfig {
77
+ baseUrl: string;
78
+ apiType: 'openai-completions' | 'anthropic-messages';
79
+ }
80
+
81
+ /** Known omp provider configurations (baseUrl + API format). */
82
+ const OMP_PROVIDERS: Record<string, OmpProviderConfig> = {
83
+ zai: { baseUrl: 'https://api.z.ai/api/coding/paas/v4', apiType: 'openai-completions' },
84
+ volc: { baseUrl: 'https://ark.cn-beijing.volces.com/api/coding', apiType: 'anthropic-messages' },
85
+ minimax: { baseUrl: 'https://api.minimaxi.com/v1', apiType: 'openai-completions' },
86
+ deepseek: { baseUrl: 'https://api.deepseek.com', apiType: 'openai-completions' },
87
+ };
88
+
89
+ const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
90
+
91
+ /** Options for constructing an {@link OmpModelProbe}. */
92
+ export interface OmpModelProbeOptions {
93
+ /**
94
+ * Optional {@link APIClientConfig} (without `baseUrl` — the probe owns the
95
+ * provider→baseUrl mapping). Pass `fetch` here to inject a custom fetch
96
+ * for testing; omit to use `globalThis.fetch` (resolved lazily per request
97
+ * by `APIClient`, so test-time `globalThis.fetch` mutations take effect).
98
+ */
99
+ apiClientConfig?: Omit<APIClientConfig, 'baseUrl'>;
100
+ }
101
+
102
+ /**
103
+ * Probe for `omp`-configured providers (R3).
104
+ *
105
+ * Issues a minimal 1-token completion request (`prompt: "ping"`, `max_tokens: 1`)
106
+ * to the provider's API and interprets the response:
107
+ * - 200 → `available`
108
+ * - 429 + quota body → `quota_exhausted`
109
+ * - 429 + rate-limit body → `rate_limited`
110
+ * - Other 4xx/5xx → `unavailable`
111
+ * - HTTP timeout (via APIClient, reported as APIError status 0) → `unknown`
112
+ *
113
+ * Uses {@link APIClient} from `@gobing-ai/ts-infra` for all HTTP — the
114
+ * centralized HTTP seam per `external-api-boundaries` rule. Supports both
115
+ * `openai-completions` and `anthropic-messages` API formats. The
116
+ * provider→apiType mapping is built into this probe since it is specifically
117
+ * the `OmpModelProbe` — pluggability (R9) is at the registry level.
118
+ */
119
+ export class OmpModelProbe implements ModelHealthProbe {
120
+ private readonly apiClientConfig?: Omit<APIClientConfig, 'baseUrl'>;
121
+
122
+ constructor(options?: OmpModelProbeOptions) {
123
+ this.apiClientConfig = options?.apiClientConfig;
124
+ }
125
+
126
+ async probe(provider: string, model: string, config: ProbeConfig): Promise<ModelHealthResult> {
127
+ const checkedAt = new Date().toISOString();
128
+ const providerConfig = OMP_PROVIDERS[provider];
129
+
130
+ if (!providerConfig) {
131
+ return { status: 'unknown', detail: `unknown omp provider '${provider}'`, checkedAt };
132
+ }
133
+
134
+ const baseUrl = config.endpoint ?? providerConfig.baseUrl;
135
+
136
+ try {
137
+ const response = await this.issueRequest(
138
+ providerConfig.apiType,
139
+ baseUrl,
140
+ model,
141
+ config.apiKey,
142
+ config.timeoutMs,
143
+ );
144
+ return this.interpretResponse(response, checkedAt);
145
+ } catch (error) {
146
+ // APIClient reports timeouts as APIError with status 0.
147
+ if (error instanceof APIError && error.status === 0) {
148
+ return { status: 'unknown', detail: 'probe timed out', checkedAt };
149
+ }
150
+ const message = error instanceof Error ? error.message : 'probe failed';
151
+ return { status: 'unknown', detail: message, checkedAt };
152
+ }
153
+ }
154
+
155
+ /** Issue a minimal completion request based on the API format. */
156
+ private async issueRequest(
157
+ apiType: OmpProviderConfig['apiType'],
158
+ baseUrl: string,
159
+ model: string,
160
+ apiKey: string,
161
+ timeoutMs: number,
162
+ ): Promise<RawHttpResponse> {
163
+ const client = new APIClient({ ...this.apiClientConfig, baseUrl });
164
+
165
+ if (apiType === 'anthropic-messages') {
166
+ return await client.rawRequest(
167
+ 'POST',
168
+ '/messages',
169
+ JSON.stringify({
170
+ model,
171
+ messages: [{ role: 'user', content: 'ping' }],
172
+ max_tokens: 1,
173
+ }),
174
+ {
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ 'x-api-key': apiKey,
178
+ 'anthropic-version': '2023-06-01',
179
+ },
180
+ timeout: timeoutMs,
181
+ operationName: 'model-health-probe anthropic-messages',
182
+ },
183
+ );
184
+ }
185
+
186
+ return await client.rawRequest(
187
+ 'POST',
188
+ '/chat/completions',
189
+ JSON.stringify({
190
+ model,
191
+ messages: [{ role: 'user', content: 'ping' }],
192
+ max_tokens: 1,
193
+ }),
194
+ {
195
+ headers: {
196
+ 'Content-Type': 'application/json',
197
+ Authorization: `Bearer ${apiKey}`,
198
+ },
199
+ timeout: timeoutMs,
200
+ operationName: 'model-health-probe openai-completions',
201
+ },
202
+ );
203
+ }
204
+
205
+ /** Interpret the HTTP response into a {@link ModelHealthResult}. */
206
+ private interpretResponse(response: RawHttpResponse, checkedAt: string): ModelHealthResult {
207
+ if (response.status === 200) {
208
+ return { status: 'available', checkedAt };
209
+ }
210
+
211
+ if (response.status === 429) {
212
+ const body = this.parseBody(response.body);
213
+ const errorType = body?.error?.type ?? body?.error?.code ?? '';
214
+ const errorMessage = body?.error?.message ?? '';
215
+
216
+ if (
217
+ errorType.includes('quota') ||
218
+ errorType.includes('insufficient_quota') ||
219
+ errorType.includes('insufficient')
220
+ ) {
221
+ return { status: 'quota_exhausted', detail: errorMessage || 'quota exceeded', checkedAt };
222
+ }
223
+ return { status: 'rate_limited', detail: errorMessage || 'rate limited', checkedAt };
224
+ }
225
+
226
+ // Other 4xx/5xx → unavailable
227
+ return { status: 'unavailable', detail: `HTTP ${response.status}`, checkedAt };
228
+ }
229
+
230
+ /** Best-effort JSON body parse; returns null on failure. */
231
+ private parseBody(body: string): { error?: { type?: string; code?: string; message?: string } } | null {
232
+ try {
233
+ return JSON.parse(body) as { error?: { type?: string; code?: string; message?: string } };
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+ }
239
+
240
+ /** Default probe timeout in milliseconds (R8). */
241
+ export { DEFAULT_PROBE_TIMEOUT_MS };