@elyracode/doctor 0.9.8 → 0.9.9

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.
@@ -1,311 +1,20 @@
1
1
  /**
2
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.
3
+ * The probe engine lives in @elyracode/ai (so the coding-agent CLI can use
4
+ * it too); this module re-exports it for the doctor extension and tests.
6
5
  */
7
6
 
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
- }
7
+ export {
8
+ DEFAULT_MAX_MODELS,
9
+ DEFAULT_PROBE_TIMEOUT_MS,
10
+ evaluateThinkingClaims,
11
+ formatProbeReport,
12
+ MODELS_PER_PROVIDER,
13
+ pickReasoningLevel,
14
+ type ProbeOptions,
15
+ type ProbeReport,
16
+ type ProbeResult,
17
+ runModelProbes,
18
+ selectModelsForProvider,
19
+ type ThinkingObservation,
20
+ } from "@elyracode/ai";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyracode/doctor",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Elyra extension for project health analysis -- dependency audit, security checks, code quality, and more",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,15 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
5
+
6
+ export default defineConfig({
7
+ test: {
8
+ globals: true,
9
+ environment: "node",
10
+ testTimeout: 30000,
11
+ },
12
+ resolve: {
13
+ alias: [{ find: /^@elyracode\/ai$/, replacement: aiSrcIndex }],
14
+ },
15
+ });