@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.
- package/dist/doctor-runner.d.ts +33 -2
- package/dist/doctor-runner.d.ts.map +1 -1
- package/dist/doctor-runner.js +85 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/model-health-probe.d.ts +94 -0
- package/dist/model-health-probe.d.ts.map +1 -0
- package/dist/model-health-probe.js +153 -0
- package/package.json +4 -4
- package/src/doctor-runner.ts +115 -4
- package/src/index.ts +1 -0
- package/src/model-health-probe.ts +241 -0
package/dist/doctor-runner.d.ts
CHANGED
|
@@ -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?:
|
|
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;
|
|
1
|
+
{"version":3,"file":"doctor-runner.d.ts","sourceRoot":"","sources":["../src/doctor-runner.ts"],"names":[],"mappings":"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"}
|
package/dist/doctor-runner.js
CHANGED
|
@@ -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
|
|
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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.
|
|
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.
|
|
51
|
-
"@gobing-ai/ts-infra": "^0.4.
|
|
52
|
-
"@gobing-ai/ts-runtime": "^0.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"
|
package/src/doctor-runner.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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
|
|
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 };
|