@elyracode/doctor 0.9.7 → 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.
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
@@ -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,20 @@
1
+ /**
2
+ * Live model probes for elyra doctor.
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.
5
+ */
6
+
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.7",
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": [
@@ -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
+ });
@@ -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
+ });