@elyracode/doctor 0.9.6 → 0.9.8
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/README.md +18 -0
- package/extensions/index.ts +51 -0
- package/extensions/probes.ts +311 -0
- package/package.json +2 -1
- package/test/probes.test.ts +216 -0
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ elyra install npm:@elyracode/doctor
|
|
|
18
18
|
| Tool | Description |
|
|
19
19
|
|------|-------------|
|
|
20
20
|
| `project_health_check` | Run health checks. Can filter by category: security, dependencies, config, code-debt, code-quality, git, project |
|
|
21
|
+
| `probe_models` | Live-verify configured LLM providers against the model registry. Optional filters: `provider`, `model_pattern`, `max_models` |
|
|
21
22
|
|
|
22
23
|
## Checks
|
|
23
24
|
|
|
@@ -42,6 +43,23 @@ elyra install npm:@elyracode/doctor
|
|
|
42
43
|
|
|
43
44
|
The agent runs the checks automatically and suggests fixes based on findings.
|
|
44
45
|
|
|
46
|
+
## Model Probes
|
|
47
|
+
|
|
48
|
+
`probe_models` live-verifies that configured LLM providers actually behave the way the model registry claims (registry metadata can drift from provider reality):
|
|
49
|
+
|
|
50
|
+
- Detects providers with credentials via environment API keys (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`); providers without credentials are skipped
|
|
51
|
+
- Probes at most 2-3 cheap representative models per provider with a minimal live completion
|
|
52
|
+
- For models with `thinkingType` metadata (`adaptive` | `budget`), runs the probe with reasoning enabled at the lowest level and checks whether thinking content is actually produced
|
|
53
|
+
- Reports per model: provider, id, ok/failed, latency, and any mismatch between registry claims and observed behavior
|
|
54
|
+
- With no credentials configured it returns a clear "no providers configured" result instead of failing
|
|
55
|
+
|
|
56
|
+
Note: probes make real (paid) API calls.
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
> Probe my configured models
|
|
60
|
+
> Verify that Bedrock thinking models actually support thinking
|
|
61
|
+
```
|
|
62
|
+
|
|
45
63
|
### Auto-Heal Mode
|
|
46
64
|
```
|
|
47
65
|
/doctor --heal
|
package/extensions/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@elyracode/coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { type Finding, runAllChecks } from "./checks.js";
|
|
4
|
+
import { DEFAULT_MAX_MODELS, formatProbeReport, runModelProbes } from "./probes.js";
|
|
4
5
|
|
|
5
6
|
export default function (elyra: ExtensionAPI): void {
|
|
6
7
|
// ── Command: /doctor ──
|
|
@@ -85,6 +86,56 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
85
86
|
};
|
|
86
87
|
},
|
|
87
88
|
});
|
|
89
|
+
|
|
90
|
+
// ── Tool: probe_models ──
|
|
91
|
+
elyra.registerTool({
|
|
92
|
+
name: "probe_models",
|
|
93
|
+
label: "Probe Models",
|
|
94
|
+
description:
|
|
95
|
+
"Live-verify that configured LLM providers behave the way the model registry claims. " +
|
|
96
|
+
"Probes a small set of cheap representative models per provider with credentials (env API keys) " +
|
|
97
|
+
"using a minimal completion, and verifies thinking mode for models with thinkingType metadata. " +
|
|
98
|
+
"Reports ok/failed, latency, and mismatches between registry claims and observed behavior. " +
|
|
99
|
+
"Note: makes real (paid) API calls.",
|
|
100
|
+
parameters: Type.Object({
|
|
101
|
+
provider: Type.Optional(
|
|
102
|
+
Type.String({ description: "Only probe this provider (e.g. openai, anthropic, amazon-bedrock)" }),
|
|
103
|
+
),
|
|
104
|
+
model_pattern: Type.Optional(
|
|
105
|
+
Type.String({ description: "Only probe models whose id contains this substring (case-insensitive)" }),
|
|
106
|
+
),
|
|
107
|
+
max_models: Type.Optional(
|
|
108
|
+
Type.Number({ description: `Maximum total number of models to probe (default: ${DEFAULT_MAX_MODELS})` }),
|
|
109
|
+
),
|
|
110
|
+
}),
|
|
111
|
+
execute: async (_toolCallId, params, signal) => {
|
|
112
|
+
try {
|
|
113
|
+
const report = await runModelProbes({
|
|
114
|
+
provider: params.provider,
|
|
115
|
+
modelPattern: params.model_pattern,
|
|
116
|
+
maxModels: params.max_models,
|
|
117
|
+
signal,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: formatProbeReport(report) }],
|
|
122
|
+
details: {
|
|
123
|
+
probed: report.probed.length,
|
|
124
|
+
ok: report.probed.filter((r) => r.ok).length,
|
|
125
|
+
failed: report.probed.filter((r) => !r.ok).length,
|
|
126
|
+
mismatches: report.probed.reduce((sum, r) => sum + r.mismatches.length, 0),
|
|
127
|
+
skippedProviders: report.skippedProviders,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
132
|
+
return {
|
|
133
|
+
content: [{ type: "text", text: `Model probes failed: ${msg}` }],
|
|
134
|
+
details: { probed: 0, ok: 0, failed: 0, mismatches: 0, skippedProviders: [] },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
88
139
|
}
|
|
89
140
|
|
|
90
141
|
function formatReport(findings: Finding[]): string {
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live model probes for elyra doctor.
|
|
3
|
+
* Verifies that configured LLM providers actually behave the way the model
|
|
4
|
+
* registry claims (availability, reasoning support, thinking mode), since
|
|
5
|
+
* registry metadata can drift from provider reality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Api, KnownProvider, Model, ThinkingLevel } from "@elyracode/ai";
|
|
9
|
+
import { completeSimple, getEnvApiKey, getModels, getProviders, getSupportedThinkingLevels } from "@elyracode/ai";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_MAX_MODELS = 10;
|
|
12
|
+
export const MODELS_PER_PROVIDER = 2;
|
|
13
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 60000;
|
|
14
|
+
|
|
15
|
+
export interface ProbeOptions {
|
|
16
|
+
/** Only probe this provider (case-insensitive). */
|
|
17
|
+
provider?: string;
|
|
18
|
+
/** Only probe models whose id contains this substring (case-insensitive). */
|
|
19
|
+
modelPattern?: string;
|
|
20
|
+
/** Maximum total number of models to probe. */
|
|
21
|
+
maxModels?: number;
|
|
22
|
+
/** Abort signal from the tool execution. */
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
/** Per-probe timeout in milliseconds. */
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ProbeResult {
|
|
29
|
+
provider: string;
|
|
30
|
+
modelId: string;
|
|
31
|
+
ok: boolean;
|
|
32
|
+
latencyMs: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
/** Set when the probe ran with reasoning enabled. */
|
|
35
|
+
reasoningProbe?: { level: ThinkingLevel; observedThinking: boolean };
|
|
36
|
+
/** Discrepancies between registry claims and observed behavior. */
|
|
37
|
+
mismatches: string[];
|
|
38
|
+
/** Informational observations that are not necessarily mismatches. */
|
|
39
|
+
notes: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProbeReport {
|
|
43
|
+
probed: ProbeResult[];
|
|
44
|
+
/** Providers in the registry that were skipped because no credentials were found. */
|
|
45
|
+
skippedProviders: string[];
|
|
46
|
+
/** Set when no probes could run at all (e.g. no credentials configured). */
|
|
47
|
+
message?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ThinkingObservation {
|
|
51
|
+
/** Reasoning level the probe requested, if any. */
|
|
52
|
+
requestedLevel?: ThinkingLevel;
|
|
53
|
+
/** Whether the response contained thinking content. Undefined if the probe failed. */
|
|
54
|
+
observedThinking?: boolean;
|
|
55
|
+
/** Error message when the probe failed. */
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Pure logic: model selection ──
|
|
60
|
+
|
|
61
|
+
function probeCost(model: Model<Api>): number {
|
|
62
|
+
return model.cost.input + model.cost.output;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pick a small representative set of models for a provider:
|
|
67
|
+
* the cheapest two, plus the cheapest model with thinkingType metadata
|
|
68
|
+
* if none of the cheap picks have it (at most 3 total).
|
|
69
|
+
*/
|
|
70
|
+
export function selectModelsForProvider(models: Model<Api>[], modelPattern?: string): Model<Api>[] {
|
|
71
|
+
const pattern = modelPattern?.toLowerCase();
|
|
72
|
+
const filtered = pattern ? models.filter((m) => m.id.toLowerCase().includes(pattern)) : models;
|
|
73
|
+
const byCost = [...filtered].sort((a, b) => probeCost(a) - probeCost(b));
|
|
74
|
+
|
|
75
|
+
const selected = byCost.slice(0, MODELS_PER_PROVIDER);
|
|
76
|
+
if (!selected.some((m) => m.thinkingType !== undefined)) {
|
|
77
|
+
const thinkingModel = byCost.find((m) => m.thinkingType !== undefined && !selected.includes(m));
|
|
78
|
+
if (thinkingModel) selected.push(thinkingModel);
|
|
79
|
+
}
|
|
80
|
+
return selected;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Pure logic: thinking claims ──
|
|
84
|
+
|
|
85
|
+
/** Lowest supported thinking level for a reasoning probe, or undefined when not applicable. */
|
|
86
|
+
export function pickReasoningLevel(model: Model<Api>): ThinkingLevel | undefined {
|
|
87
|
+
if (model.thinkingType === undefined || !model.reasoning) return undefined;
|
|
88
|
+
const levels = getSupportedThinkingLevels(model).filter((l): l is ThinkingLevel => l !== "off");
|
|
89
|
+
return levels[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Compare registry thinking claims against observed probe behavior. */
|
|
93
|
+
export function evaluateThinkingClaims(
|
|
94
|
+
model: Model<Api>,
|
|
95
|
+
observation: ThinkingObservation,
|
|
96
|
+
): { mismatches: string[]; notes: string[] } {
|
|
97
|
+
const mismatches: string[] = [];
|
|
98
|
+
const notes: string[] = [];
|
|
99
|
+
|
|
100
|
+
if (model.thinkingType === undefined) return { mismatches, notes };
|
|
101
|
+
|
|
102
|
+
if (!model.reasoning) {
|
|
103
|
+
mismatches.push(`registry claims thinkingType=${model.thinkingType} but reasoning=false`);
|
|
104
|
+
return { mismatches, notes };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (observation.requestedLevel === undefined) {
|
|
108
|
+
mismatches.push(
|
|
109
|
+
`registry claims thinkingType=${model.thinkingType} but thinkingLevelMap disables all thinking levels`,
|
|
110
|
+
);
|
|
111
|
+
return { mismatches, notes };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (observation.error !== undefined) {
|
|
115
|
+
mismatches.push(
|
|
116
|
+
`request with thinking enabled (level: ${observation.requestedLevel}) failed: ${observation.error}`,
|
|
117
|
+
);
|
|
118
|
+
return { mismatches, notes };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (observation.observedThinking === false) {
|
|
122
|
+
if (model.thinkingType === "budget") {
|
|
123
|
+
mismatches.push("no thinking content observed despite thinkingType=budget");
|
|
124
|
+
} else {
|
|
125
|
+
notes.push("adaptive thinking produced no thinking content (model may skip thinking for trivial prompts)");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { mismatches, notes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Live probing ──
|
|
133
|
+
|
|
134
|
+
function combineSignals(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
135
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
136
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function probeModel(model: Model<Api>, apiKey: string, options: ProbeOptions): Promise<ProbeResult> {
|
|
140
|
+
const result: ProbeResult = {
|
|
141
|
+
provider: model.provider,
|
|
142
|
+
modelId: model.id,
|
|
143
|
+
ok: false,
|
|
144
|
+
latencyMs: 0,
|
|
145
|
+
mismatches: [],
|
|
146
|
+
notes: [],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const reasoningLevel = pickReasoningLevel(model);
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const message = await completeSimple(
|
|
154
|
+
model,
|
|
155
|
+
{ messages: [{ role: "user", content: "Reply with the single word: OK", timestamp: Date.now() }] },
|
|
156
|
+
{
|
|
157
|
+
apiKey,
|
|
158
|
+
maxTokens: 64,
|
|
159
|
+
reasoning: reasoningLevel,
|
|
160
|
+
signal: combineSignals(options.signal, options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS),
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
result.latencyMs = Date.now() - start;
|
|
164
|
+
|
|
165
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
166
|
+
result.error = message.errorMessage ?? `request ended with stopReason=${message.stopReason}`;
|
|
167
|
+
} else {
|
|
168
|
+
result.ok = true;
|
|
169
|
+
if (reasoningLevel !== undefined) {
|
|
170
|
+
result.reasoningProbe = {
|
|
171
|
+
level: reasoningLevel,
|
|
172
|
+
observedThinking: message.content.some((c) => c.type === "thinking"),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
result.latencyMs = Date.now() - start;
|
|
178
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const claims = evaluateThinkingClaims(model, {
|
|
182
|
+
requestedLevel: reasoningLevel,
|
|
183
|
+
observedThinking: result.reasoningProbe?.observedThinking,
|
|
184
|
+
error: result.error,
|
|
185
|
+
});
|
|
186
|
+
result.mismatches.push(...claims.mismatches);
|
|
187
|
+
result.notes.push(...claims.notes);
|
|
188
|
+
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Run live probes for all providers with credentials. Never throws. */
|
|
193
|
+
export async function runModelProbes(options: ProbeOptions = {}): Promise<ProbeReport> {
|
|
194
|
+
const providerFilter = options.provider?.toLowerCase();
|
|
195
|
+
const providers = getProviders().filter((p) => !providerFilter || p.toLowerCase() === providerFilter);
|
|
196
|
+
|
|
197
|
+
const available: { provider: string; apiKey: string }[] = [];
|
|
198
|
+
const skipped: string[] = [];
|
|
199
|
+
for (const provider of providers) {
|
|
200
|
+
const apiKey = getEnvApiKey(provider);
|
|
201
|
+
if (apiKey) {
|
|
202
|
+
available.push({ provider, apiKey });
|
|
203
|
+
} else {
|
|
204
|
+
skipped.push(provider);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (available.length === 0) {
|
|
209
|
+
return {
|
|
210
|
+
probed: [],
|
|
211
|
+
skippedProviders: skipped,
|
|
212
|
+
message: options.provider
|
|
213
|
+
? `No credentials found for provider "${options.provider}". Set the provider's API key environment variable to enable probing.`
|
|
214
|
+
: "No providers configured. Set an API key environment variable (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY) to enable model probes.",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const maxModels = Math.max(1, Math.floor(options.maxModels ?? DEFAULT_MAX_MODELS));
|
|
219
|
+
const groups: { models: Model<Api>[]; apiKey: string }[] = [];
|
|
220
|
+
let budget = maxModels;
|
|
221
|
+
for (const { provider, apiKey } of available) {
|
|
222
|
+
if (budget <= 0) break;
|
|
223
|
+
const all = getModels(provider as KnownProvider) as Model<Api>[];
|
|
224
|
+
const models = selectModelsForProvider(all, options.modelPattern).slice(0, budget);
|
|
225
|
+
if (models.length > 0) {
|
|
226
|
+
groups.push({ models, apiKey });
|
|
227
|
+
budget -= models.length;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (groups.length === 0) {
|
|
232
|
+
return {
|
|
233
|
+
probed: [],
|
|
234
|
+
skippedProviders: skipped,
|
|
235
|
+
message: options.modelPattern
|
|
236
|
+
? `No registry models match pattern "${options.modelPattern}" for the configured providers.`
|
|
237
|
+
: "No registry models found for the configured providers.",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Probe providers in parallel, models within a provider sequentially (rate-limit friendly).
|
|
242
|
+
const results = await Promise.all(
|
|
243
|
+
groups.map(async ({ models, apiKey }) => {
|
|
244
|
+
const groupResults: ProbeResult[] = [];
|
|
245
|
+
for (const model of models) {
|
|
246
|
+
if (options.signal?.aborted) break;
|
|
247
|
+
groupResults.push(await probeModel(model, apiKey, options));
|
|
248
|
+
}
|
|
249
|
+
return groupResults;
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return { probed: results.flat(), skippedProviders: skipped };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Pure logic: report formatting ──
|
|
257
|
+
|
|
258
|
+
export function formatProbeReport(report: ProbeReport): string {
|
|
259
|
+
if (report.message) {
|
|
260
|
+
return `# Model Probe Report\n\n${report.message}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ok = report.probed.filter((r) => r.ok);
|
|
264
|
+
const failed = report.probed.filter((r) => !r.ok);
|
|
265
|
+
const mismatchCount = report.probed.reduce((sum, r) => sum + r.mismatches.length, 0);
|
|
266
|
+
|
|
267
|
+
const lines: string[] = [
|
|
268
|
+
"# Model Probe Report",
|
|
269
|
+
"",
|
|
270
|
+
`Probed: ${report.probed.length} | OK: ${ok.length} | Failed: ${failed.length} | Mismatches: ${mismatchCount}`,
|
|
271
|
+
"",
|
|
272
|
+
"## Results",
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
for (const r of report.probed) {
|
|
276
|
+
const status = r.ok ? `OK (${r.latencyMs}ms)` : `FAILED (${r.latencyMs}ms): ${r.error ?? "unknown error"}`;
|
|
277
|
+
const thinking = r.reasoningProbe
|
|
278
|
+
? `, thinking(${r.reasoningProbe.level}): ${r.reasoningProbe.observedThinking ? "observed" : "not observed"}`
|
|
279
|
+
: "";
|
|
280
|
+
lines.push(`- [${r.provider}] ${r.modelId} -- ${status}${thinking}`);
|
|
281
|
+
}
|
|
282
|
+
lines.push("");
|
|
283
|
+
|
|
284
|
+
if (mismatchCount > 0) {
|
|
285
|
+
lines.push("## Mismatches");
|
|
286
|
+
for (const r of report.probed) {
|
|
287
|
+
for (const m of r.mismatches) {
|
|
288
|
+
lines.push(`- [${r.provider}] ${r.modelId}: ${m}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
lines.push("");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const noteCount = report.probed.reduce((sum, r) => sum + r.notes.length, 0);
|
|
295
|
+
if (noteCount > 0) {
|
|
296
|
+
lines.push("## Notes");
|
|
297
|
+
for (const r of report.probed) {
|
|
298
|
+
for (const n of r.notes) {
|
|
299
|
+
lines.push(`- [${r.provider}] ${r.modelId}: ${n}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
lines.push("");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (report.skippedProviders.length > 0) {
|
|
306
|
+
lines.push("## Skipped Providers (no credentials)");
|
|
307
|
+
lines.push(report.skippedProviders.join(", "));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return lines.join("\n").trimEnd();
|
|
311
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elyracode/doctor",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"description": "Elyra extension for project health analysis -- dependency audit, security checks, code quality, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
+
"@elyracode/ai": "*",
|
|
26
27
|
"@elyracode/coding-agent": "*",
|
|
27
28
|
"typebox": "*"
|
|
28
29
|
},
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { Api, Model } from "@elyracode/ai";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
evaluateThinkingClaims,
|
|
5
|
+
formatProbeReport,
|
|
6
|
+
type ProbeReport,
|
|
7
|
+
pickReasoningLevel,
|
|
8
|
+
selectModelsForProvider,
|
|
9
|
+
} from "../extensions/probes.js";
|
|
10
|
+
|
|
11
|
+
function makeModel(overrides: Partial<Model<Api>> & { id: string }): Model<Api> {
|
|
12
|
+
return {
|
|
13
|
+
name: overrides.id,
|
|
14
|
+
api: "openai-completions",
|
|
15
|
+
provider: "test-provider",
|
|
16
|
+
baseUrl: "https://example.invalid",
|
|
17
|
+
reasoning: false,
|
|
18
|
+
input: ["text"],
|
|
19
|
+
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
|
20
|
+
contextWindow: 128000,
|
|
21
|
+
maxTokens: 8192,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("selectModelsForProvider", () => {
|
|
27
|
+
it("picks the two cheapest models", () => {
|
|
28
|
+
const models = [
|
|
29
|
+
makeModel({ id: "expensive", cost: { input: 10, output: 30, cacheRead: 0, cacheWrite: 0 } }),
|
|
30
|
+
makeModel({ id: "cheap", cost: { input: 0.1, output: 0.4, cacheRead: 0, cacheWrite: 0 } }),
|
|
31
|
+
makeModel({ id: "mid", cost: { input: 1, output: 4, cacheRead: 0, cacheWrite: 0 } }),
|
|
32
|
+
];
|
|
33
|
+
const selected = selectModelsForProvider(models);
|
|
34
|
+
expect(selected.map((m) => m.id)).toEqual(["cheap", "mid"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("adds the cheapest thinkingType model when the cheap picks have none", () => {
|
|
38
|
+
const models = [
|
|
39
|
+
makeModel({ id: "cheap-a", cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0 } }),
|
|
40
|
+
makeModel({ id: "cheap-b", cost: { input: 0.2, output: 0.4, cacheRead: 0, cacheWrite: 0 } }),
|
|
41
|
+
makeModel({
|
|
42
|
+
id: "thinker-pricey",
|
|
43
|
+
reasoning: true,
|
|
44
|
+
thinkingType: "budget",
|
|
45
|
+
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
|
|
46
|
+
}),
|
|
47
|
+
makeModel({
|
|
48
|
+
id: "thinker-cheap",
|
|
49
|
+
reasoning: true,
|
|
50
|
+
thinkingType: "adaptive",
|
|
51
|
+
cost: { input: 3, output: 15, cacheRead: 0, cacheWrite: 0 },
|
|
52
|
+
}),
|
|
53
|
+
];
|
|
54
|
+
const selected = selectModelsForProvider(models);
|
|
55
|
+
expect(selected.map((m) => m.id)).toEqual(["cheap-a", "cheap-b", "thinker-cheap"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not add a third model when a cheap pick already has thinkingType", () => {
|
|
59
|
+
const models = [
|
|
60
|
+
makeModel({ id: "cheap-thinker", reasoning: true, thinkingType: "budget" }),
|
|
61
|
+
makeModel({ id: "cheap-plain", cost: { input: 2, output: 4, cacheRead: 0, cacheWrite: 0 } }),
|
|
62
|
+
makeModel({
|
|
63
|
+
id: "other-thinker",
|
|
64
|
+
reasoning: true,
|
|
65
|
+
thinkingType: "adaptive",
|
|
66
|
+
cost: { input: 5, output: 10, cacheRead: 0, cacheWrite: 0 },
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
const selected = selectModelsForProvider(models);
|
|
70
|
+
expect(selected.map((m) => m.id)).toEqual(["cheap-thinker", "cheap-plain"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("filters by model pattern (case-insensitive substring)", () => {
|
|
74
|
+
const models = [makeModel({ id: "gpt-5-mini" }), makeModel({ id: "claude-haiku" })];
|
|
75
|
+
const selected = selectModelsForProvider(models, "HAIKU");
|
|
76
|
+
expect(selected.map((m) => m.id)).toEqual(["claude-haiku"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns empty for an empty registry", () => {
|
|
80
|
+
expect(selectModelsForProvider([])).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("pickReasoningLevel", () => {
|
|
85
|
+
it("returns undefined when the model has no thinkingType", () => {
|
|
86
|
+
expect(pickReasoningLevel(makeModel({ id: "plain" }))).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns undefined when thinkingType is set but reasoning is false", () => {
|
|
90
|
+
expect(pickReasoningLevel(makeModel({ id: "broken", thinkingType: "budget" }))).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns the lowest supported level", () => {
|
|
94
|
+
expect(pickReasoningLevel(makeModel({ id: "thinker", reasoning: true, thinkingType: "budget" }))).toBe("minimal");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("respects thinkingLevelMap exclusions", () => {
|
|
98
|
+
const model = makeModel({
|
|
99
|
+
id: "no-minimal",
|
|
100
|
+
reasoning: true,
|
|
101
|
+
thinkingType: "adaptive",
|
|
102
|
+
thinkingLevelMap: { minimal: null },
|
|
103
|
+
});
|
|
104
|
+
expect(pickReasoningLevel(model)).toBe("low");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("evaluateThinkingClaims", () => {
|
|
109
|
+
it("returns nothing for models without thinkingType", () => {
|
|
110
|
+
const result = evaluateThinkingClaims(makeModel({ id: "plain" }), {});
|
|
111
|
+
expect(result.mismatches).toEqual([]);
|
|
112
|
+
expect(result.notes).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("flags thinkingType set on a non-reasoning model", () => {
|
|
116
|
+
const result = evaluateThinkingClaims(makeModel({ id: "broken", thinkingType: "budget" }), {});
|
|
117
|
+
expect(result.mismatches).toEqual(["registry claims thinkingType=budget but reasoning=false"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("flags thinkingType with all levels disabled", () => {
|
|
121
|
+
const model = makeModel({ id: "no-levels", reasoning: true, thinkingType: "adaptive" });
|
|
122
|
+
const result = evaluateThinkingClaims(model, { requestedLevel: undefined });
|
|
123
|
+
expect(result.mismatches).toEqual([
|
|
124
|
+
"registry claims thinkingType=adaptive but thinkingLevelMap disables all thinking levels",
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("flags a failed thinking-enabled request", () => {
|
|
129
|
+
const model = makeModel({ id: "thinker", reasoning: true, thinkingType: "budget" });
|
|
130
|
+
const result = evaluateThinkingClaims(model, { requestedLevel: "minimal", error: "400 invalid thinking config" });
|
|
131
|
+
expect(result.mismatches).toEqual([
|
|
132
|
+
"request with thinking enabled (level: minimal) failed: 400 invalid thinking config",
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("flags missing thinking content for budget models", () => {
|
|
137
|
+
const model = makeModel({ id: "thinker", reasoning: true, thinkingType: "budget" });
|
|
138
|
+
const result = evaluateThinkingClaims(model, { requestedLevel: "minimal", observedThinking: false });
|
|
139
|
+
expect(result.mismatches).toEqual(["no thinking content observed despite thinkingType=budget"]);
|
|
140
|
+
expect(result.notes).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("notes (not flags) missing thinking content for adaptive models", () => {
|
|
144
|
+
const model = makeModel({ id: "thinker", reasoning: true, thinkingType: "adaptive" });
|
|
145
|
+
const result = evaluateThinkingClaims(model, { requestedLevel: "minimal", observedThinking: false });
|
|
146
|
+
expect(result.mismatches).toEqual([]);
|
|
147
|
+
expect(result.notes).toEqual([
|
|
148
|
+
"adaptive thinking produced no thinking content (model may skip thinking for trivial prompts)",
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reports no issues when thinking is observed", () => {
|
|
153
|
+
const model = makeModel({ id: "thinker", reasoning: true, thinkingType: "budget" });
|
|
154
|
+
const result = evaluateThinkingClaims(model, { requestedLevel: "minimal", observedThinking: true });
|
|
155
|
+
expect(result.mismatches).toEqual([]);
|
|
156
|
+
expect(result.notes).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("formatProbeReport", () => {
|
|
161
|
+
it("returns the message when no probes ran", () => {
|
|
162
|
+
const report: ProbeReport = {
|
|
163
|
+
probed: [],
|
|
164
|
+
skippedProviders: ["openai", "anthropic"],
|
|
165
|
+
message: "No providers configured.",
|
|
166
|
+
};
|
|
167
|
+
const text = formatProbeReport(report);
|
|
168
|
+
expect(text).toContain("# Model Probe Report");
|
|
169
|
+
expect(text).toContain("No providers configured.");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("formats results, mismatches, notes, and skipped providers", () => {
|
|
173
|
+
const report: ProbeReport = {
|
|
174
|
+
probed: [
|
|
175
|
+
{
|
|
176
|
+
provider: "openai",
|
|
177
|
+
modelId: "gpt-5-mini",
|
|
178
|
+
ok: true,
|
|
179
|
+
latencyMs: 812,
|
|
180
|
+
mismatches: [],
|
|
181
|
+
notes: [],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
provider: "amazon-bedrock",
|
|
185
|
+
modelId: "claude-x",
|
|
186
|
+
ok: true,
|
|
187
|
+
latencyMs: 1500,
|
|
188
|
+
reasoningProbe: { level: "minimal", observedThinking: false },
|
|
189
|
+
mismatches: ["no thinking content observed despite thinkingType=budget"],
|
|
190
|
+
notes: [],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
provider: "groq",
|
|
194
|
+
modelId: "llama-y",
|
|
195
|
+
ok: false,
|
|
196
|
+
latencyMs: 230,
|
|
197
|
+
error: "401 unauthorized",
|
|
198
|
+
mismatches: [],
|
|
199
|
+
notes: ["some note"],
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
skippedProviders: ["mistral"],
|
|
203
|
+
};
|
|
204
|
+
const text = formatProbeReport(report);
|
|
205
|
+
expect(text).toContain("Probed: 3 | OK: 2 | Failed: 1 | Mismatches: 1");
|
|
206
|
+
expect(text).toContain("- [openai] gpt-5-mini -- OK (812ms)");
|
|
207
|
+
expect(text).toContain("- [amazon-bedrock] claude-x -- OK (1500ms), thinking(minimal): not observed");
|
|
208
|
+
expect(text).toContain("- [groq] llama-y -- FAILED (230ms): 401 unauthorized");
|
|
209
|
+
expect(text).toContain("## Mismatches");
|
|
210
|
+
expect(text).toContain("- [amazon-bedrock] claude-x: no thinking content observed despite thinkingType=budget");
|
|
211
|
+
expect(text).toContain("## Notes");
|
|
212
|
+
expect(text).toContain("- [groq] llama-y: some note");
|
|
213
|
+
expect(text).toContain("## Skipped Providers (no credentials)");
|
|
214
|
+
expect(text).toContain("mistral");
|
|
215
|
+
});
|
|
216
|
+
});
|