wolverine-ai 1.8.0 → 2.0.1

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
@@ -288,23 +288,35 @@ Secured with `WOLVERINE_ADMIN_KEY` + IP allowlist (localhost + `WOLVERINE_ADMIN_
288
288
 
289
289
  ---
290
290
 
291
- ## 10-Model Configuration
291
+ ## 10-Model Configuration (OpenAI + Anthropic)
292
292
 
293
- Every AI task has its own model slot. Customize in `.env.local`:
293
+ Every AI task has its own model slot. **Mix and match providers** — set any slot to a `claude-*` model for Anthropic or `gpt-*` for OpenAI. Provider is auto-detected from the model name.
294
294
 
295
- | Env Variable | Role | Needs Tools? | Cost Impact |
295
+ ```bash
296
+ # .env.local — use Anthropic for reasoning, OpenAI for coding
297
+ REASONING_MODEL=claude-sonnet-4-20250514
298
+ CODING_MODEL=gpt-5.3-codex
299
+ CHAT_MODEL=claude-haiku-4-20250414
300
+ AUDIT_MODEL=claude-haiku-4-20250414
301
+ ```
302
+
303
+ | Env Variable | Role | Needs Tools? | Example Models |
296
304
  |---|---|---|---|
297
- | `REASONING_MODEL` | Multi-file agent | Yes | High (agent loop) |
298
- | `CODING_MODEL` | Code repair/generation | Responses API | Medium-high |
299
- | `CHAT_MODEL` | Simple text responses | No | Low |
300
- | `TOOL_MODEL` | Chat with function calling | **Yes** | Medium |
301
- | `CLASSIFIER_MODEL` | SIMPLE/TOOLS/AGENT routing | No | ~10 tokens |
302
- | `AUDIT_MODEL` | Injection detection (every error) | No | Low |
303
- | `COMPACTING_MODEL` | Text compression for brain | No | Low |
304
- | `RESEARCH_MODEL` | Deep research on failures | No | High (rare) |
305
- | `TEXT_EMBEDDING_MODEL` | Brain vector embeddings | No | Very low |
306
-
307
- Reasoning models (`o-series`, `gpt-5-nano`) automatically get 4x token limits to accommodate chain-of-thought.
305
+ | `REASONING_MODEL` | Multi-file agent | Yes | `claude-sonnet-4`, `gpt-5.4` |
306
+ | `CODING_MODEL` | Code repair/generation | Yes | `claude-sonnet-4`, `gpt-5.3-codex` |
307
+ | `CHAT_MODEL` | Simple text responses | No | `claude-haiku-4`, `gpt-5.4-mini` |
308
+ | `TOOL_MODEL` | Chat with function calling | **Yes** | `claude-sonnet-4`, `gpt-4o-mini` |
309
+ | `CLASSIFIER_MODEL` | SIMPLE/TOOLS/AGENT routing | No | `claude-haiku-4`, `gpt-4o-mini` |
310
+ | `AUDIT_MODEL` | Injection detection (every error) | No | `claude-haiku-4`, `gpt-5.4-nano` |
311
+ | `COMPACTING_MODEL` | Text compression for brain | No | `claude-haiku-4`, `gpt-5.4-nano` |
312
+ | `RESEARCH_MODEL` | Deep research on failures | No | `claude-opus-4`, `gpt-4o` |
313
+ | `TEXT_EMBEDDING_MODEL` | Brain vector embeddings | No | `text-embedding-3-small` (OpenAI only) |
314
+
315
+ **Notes:**
316
+ - Embeddings always use OpenAI (Anthropic doesn't have an embedding API)
317
+ - Tools (all 18) work identically on both providers — normalized at the client level
318
+ - Telemetry tracks usage by model AND by provider (`openai` / `anthropic`)
319
+ - Any future model from either provider works automatically — just set the model name
308
320
 
309
321
  ---
310
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolverine-ai",
3
- "version": "1.8.0",
3
+ "version": "2.0.1",
4
4
  "description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -55,6 +55,7 @@
55
55
  "README.md"
56
56
  ],
57
57
  "dependencies": {
58
+ "@anthropic-ai/sdk": "^0.82.0",
58
59
  "chalk": "^4.1.2",
59
60
  "diff": "^7.0.0",
60
61
  "dotenv": "^16.4.7",
@@ -5,6 +5,8 @@
5
5
  "env": "development"
6
6
  },
7
7
 
8
+ "provider": "openai",
9
+
8
10
  "models": {
9
11
  "reasoning": "gpt-5.4-mini",
10
12
  "coding": "gpt-5.1-codex-mini",
@@ -17,6 +19,18 @@
17
19
  "embedding": "text-embedding-3-small"
18
20
  },
19
21
 
22
+ "_anthropic_models": {
23
+ "reasoning": "claude-sonnet-4-20250514",
24
+ "coding": "claude-sonnet-4-20250514",
25
+ "chat": "claude-haiku-4-20250414",
26
+ "tool": "claude-sonnet-4-20250514",
27
+ "classifier": "claude-haiku-4-20250414",
28
+ "audit": "claude-haiku-4-20250414",
29
+ "compacting": "claude-haiku-4-20250414",
30
+ "research": "claude-sonnet-4-20250514",
31
+ "embedding": "text-embedding-3-small"
32
+ },
33
+
20
34
  "server": {
21
35
  "port": 3000,
22
36
  "maxRetries": 3,
@@ -44,7 +44,7 @@ const SEED_DOCS = [
44
44
  metadata: { topic: "security" },
45
45
  },
46
46
  {
47
- text: "Wolverine model tiers: REASONING_MODEL for deep multi-step debugging. CODING_MODEL for code repair generation. CHAT_MODEL for explanations and summaries. AUDIT_MODEL for security scans (runs every error, keep cheap). UTILITY_MODEL for JSON formatting and thought compaction. TEXT_EMBEDDING_MODEL for brain vector embeddings.",
47
+ text: "Wolverine supports both OpenAI and Anthropic models. Provider auto-detected from model name: claude-* Anthropic, gpt-*/o1-*/o3-* → OpenAI. Mix and match per role: e.g., Anthropic for reasoning (claude-sonnet-4), OpenAI for coding (gpt-5.3-codex). 10 model slots: REASONING_MODEL, CODING_MODEL, CHAT_MODEL, TOOL_MODEL, CLASSIFIER_MODEL, AUDIT_MODEL, COMPACTING_MODEL, RESEARCH_MODEL, TEXT_EMBEDDING_MODEL (always OpenAI Anthropic has no embeddings). Configure in .env.local or settings.json. Tools work identically on both providers — ai-client.js normalizes all responses to same {content, toolCalls, usage} shape. Telemetry tracks usage byModel AND byProvider (openai/anthropic) automatically.",
48
48
  metadata: { topic: "model-config" },
49
49
  },
50
50
  {
@@ -1,4 +1,4 @@
1
- const { getClient, aiCall } = require("../core/ai-client");
1
+ const { getClient, aiCall, detectProvider } = require("../core/ai-client");
2
2
  const { getModel } = require("../core/models");
3
3
 
4
4
  /**
@@ -41,7 +41,8 @@ async function embed(text) {
41
41
  const cached = _cacheGet(text);
42
42
  if (cached) return cached;
43
43
 
44
- const openai = getClient();
44
+ // Embeddings always use OpenAI (Anthropic doesn't have an embedding API)
45
+ const openai = getClient("openai");
45
46
  const model = getModel("embedding");
46
47
 
47
48
  const response = await openai.embeddings.create({
@@ -78,7 +79,8 @@ async function embedBatch(texts) {
78
79
 
79
80
  if (uncached.length === 0) return results;
80
81
 
81
- const openai = getClient();
82
+ // Embeddings always use OpenAI (Anthropic doesn't have an embedding API)
83
+ const openai = getClient("openai");
82
84
  const model = getModel("embedding");
83
85
 
84
86
  const response = await openai.embeddings.create({
@@ -1,17 +1,13 @@
1
1
  const OpenAI = require("openai");
2
- const { getModel } = require("./models");
2
+ const Anthropic = require("@anthropic-ai/sdk");
3
+ const { getModel, detectProvider } = require("./models");
3
4
 
4
- let client = null;
5
+ let _openaiClient = null;
6
+ let _anthropicClient = null;
5
7
  let _tracker = null;
6
8
 
7
- /**
8
- * Set the global token tracker. Called once from runner on startup.
9
- */
10
9
  function setTokenTracker(tracker) { _tracker = tracker; }
11
10
 
12
- /**
13
- * Extract token counts from any OpenAI response usage object.
14
- */
15
11
  function _extractTokens(usage) {
16
12
  if (!usage) return { input: 0, output: 0 };
17
13
  return {
@@ -20,92 +16,254 @@ function _extractTokens(usage) {
20
16
  };
21
17
  }
22
18
 
23
- /**
24
- * Track a call if tracker is set.
25
- */
26
19
  function _track(model, category, usage, tool) {
27
20
  if (!_tracker) return;
28
21
  const { input, output } = _extractTokens(usage);
29
22
  _tracker.record(model, category, input, output, tool);
30
23
  }
31
24
 
32
- function getClient() {
33
- if (!client) {
25
+ // ── Client Management ──
26
+
27
+ function getClient(provider) {
28
+ if (provider === "anthropic") return _getAnthropicClient();
29
+ return _getOpenAIClient();
30
+ }
31
+
32
+ function _getOpenAIClient() {
33
+ if (!_openaiClient) {
34
34
  const apiKey = process.env.OPENAI_API_KEY;
35
- if (!apiKey) {
36
- throw new Error(
37
- "OPENAI_API_KEY is not set. Add it to .env.local or set it as an environment variable."
38
- );
39
- }
40
- client = new OpenAI({ apiKey });
35
+ if (!apiKey) throw new Error("OPENAI_API_KEY is not set. Add it to .env.local");
36
+ _openaiClient = new OpenAI({ apiKey });
41
37
  }
42
- return client;
38
+ return _openaiClient;
43
39
  }
44
40
 
45
- /**
46
- * Detect if a model uses the Responses API vs Chat Completions.
47
- * Codex models and some newer models use /v1/responses.
48
- */
49
- function isResponsesModel(model) {
50
- return /codex/i.test(model);
41
+ function _getAnthropicClient() {
42
+ if (!_anthropicClient) {
43
+ const apiKey = process.env.ANTHROPIC_API_KEY;
44
+ if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not set. Add it to .env.local");
45
+ _anthropicClient = new Anthropic({ apiKey });
46
+ }
47
+ return _anthropicClient;
51
48
  }
52
49
 
53
- /**
54
- * Detect if a model uses internal reasoning tokens (o-series, gpt-5-nano, etc.)
55
- * These models need higher token limits because reasoning consumes most of the budget.
56
- */
50
+ // ── Model Detection Helpers ──
51
+
52
+ function isResponsesModel(model) { return /codex/i.test(model); }
53
+
57
54
  function isReasoningModel(model) {
58
55
  return /^o[1-9]|^gpt-5-nano|^gpt-5\.4-nano/.test(model);
59
56
  }
60
57
 
61
- /**
62
- * Build the token limit param for Chat Completions API.
63
- * Reasoning models get 4x the limit to accommodate thinking tokens.
64
- */
58
+ function isAnthropicModel(model) { return detectProvider(model) === "anthropic"; }
59
+
65
60
  function tokenParam(model, limit) {
66
- // Reasoning models need headroom for chain-of-thought
67
61
  const effectiveLimit = isReasoningModel(model) ? Math.max(limit * 4, 4096) : limit;
68
-
69
- if (isResponsesModel(model)) {
70
- return { max_output_tokens: effectiveLimit };
71
- }
62
+ if (isResponsesModel(model)) return { max_output_tokens: effectiveLimit };
72
63
  const usesNewParam = /^(o[1-9]|gpt-5|gpt-4o)/.test(model) || model.includes("nano");
73
- if (usesNewParam) {
74
- return { max_completion_tokens: effectiveLimit };
75
- }
64
+ if (usesNewParam) return { max_completion_tokens: effectiveLimit };
76
65
  return { max_tokens: limit };
77
66
  }
78
67
 
79
- /**
80
- * Unified AI call automatically routes to Responses API or Chat Completions
81
- * based on the model name.
82
- *
83
- * @param {object} params
84
- * @param {string} params.model - Model name
85
- * @param {string} params.systemPrompt - System/instructions prompt
86
- * @param {string} params.userPrompt - User message
87
- * @param {number} params.maxTokens - Max response tokens
88
- * @param {Array} params.tools - Tool definitions (optional)
89
- * @param {string} params.toolChoice - Tool choice strategy (optional)
90
- * @returns {{ content: string, toolCalls: Array|null, usage: object }}
91
- */
68
+ // ── Unified AI Call ──
69
+ // Routes to OpenAI or Anthropic based on model name. Returns same shape regardless.
70
+
92
71
  async function aiCall({ model, systemPrompt, userPrompt, maxTokens = 2048, tools, toolChoice, category = "chat", tool }) {
93
- const openai = getClient();
72
+ const provider = detectProvider(model);
73
+ let result;
74
+
75
+ if (provider === "anthropic") {
76
+ result = await _anthropicCall({ model, systemPrompt, userPrompt, maxTokens, tools, toolChoice });
77
+ } else if (isResponsesModel(model)) {
78
+ result = await _responsesCall(_getOpenAIClient(), { model, systemPrompt, userPrompt, maxTokens, tools });
79
+ } else {
80
+ result = await _chatCall(_getOpenAIClient(), { model, systemPrompt, userPrompt, maxTokens, tools, toolChoice });
81
+ }
82
+
83
+ _track(model, category, result.usage, tool);
84
+ return result;
85
+ }
94
86
 
87
+ async function aiCallWithHistory({ model, messages, tools, maxTokens = 4096, category = "chat", tool }) {
88
+ const provider = detectProvider(model);
95
89
  let result;
96
- if (isResponsesModel(model)) {
97
- result = await _responsesCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools });
90
+
91
+ if (provider === "anthropic") {
92
+ result = await _anthropicCallWithHistory({ model, messages, tools, maxTokens });
93
+ } else if (isResponsesModel(model)) {
94
+ result = await _responsesCallWithHistory(_getOpenAIClient(), { model, messages, tools, maxTokens });
98
95
  } else {
99
- result = await _chatCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools, toolChoice });
96
+ result = await _chatCallWithHistory(_getOpenAIClient(), { model, messages, tools, maxTokens });
100
97
  }
101
98
 
102
99
  _track(model, category, result.usage, tool);
103
100
  return result;
104
101
  }
105
102
 
103
+ // ── Anthropic Implementation ──
104
+ // Normalizes Anthropic's response format to match our {content, toolCalls, usage} interface.
105
+
106
+ async function _anthropicCall({ model, systemPrompt, userPrompt, maxTokens, tools, toolChoice }) {
107
+ const client = _getAnthropicClient();
108
+
109
+ const params = {
110
+ model,
111
+ max_tokens: maxTokens,
112
+ messages: [{ role: "user", content: userPrompt }],
113
+ };
114
+
115
+ if (systemPrompt) params.system = systemPrompt;
116
+
117
+ // Convert OpenAI-style tools to Anthropic format
118
+ if (tools && tools.length > 0) {
119
+ params.tools = tools.map(_toAnthropicTool).filter(Boolean);
120
+ if (toolChoice === "required") params.tool_choice = { type: "any" };
121
+ else if (toolChoice && toolChoice !== "auto") params.tool_choice = { type: "auto" };
122
+ }
123
+
124
+ const response = await client.messages.create(params);
125
+ return _normalizeAnthropicResponse(response);
126
+ }
127
+
128
+ async function _anthropicCallWithHistory({ model, messages, tools, maxTokens }) {
129
+ const client = _getAnthropicClient();
130
+
131
+ // Extract system message and convert rest to Anthropic format
132
+ let systemPrompt = "";
133
+ const anthropicMessages = [];
134
+
135
+ for (const msg of messages) {
136
+ if (msg.role === "system") {
137
+ systemPrompt += (systemPrompt ? "\n" : "") + msg.content;
138
+ continue;
139
+ }
140
+ if (msg.role === "user") {
141
+ anthropicMessages.push({ role: "user", content: msg.content });
142
+ } else if (msg.role === "assistant") {
143
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
144
+ // Assistant message with tool calls
145
+ const content = [];
146
+ if (msg.content) content.push({ type: "text", text: msg.content });
147
+ for (const tc of msg.tool_calls) {
148
+ content.push({
149
+ type: "tool_use",
150
+ id: tc.id,
151
+ name: tc.function.name,
152
+ input: JSON.parse(tc.function.arguments || "{}"),
153
+ });
154
+ }
155
+ anthropicMessages.push({ role: "assistant", content });
156
+ } else {
157
+ anthropicMessages.push({ role: "assistant", content: msg.content || "" });
158
+ }
159
+ } else if (msg.role === "tool") {
160
+ // Tool result → Anthropic tool_result block
161
+ anthropicMessages.push({
162
+ role: "user",
163
+ content: [{
164
+ type: "tool_result",
165
+ tool_use_id: msg.tool_call_id,
166
+ content: msg.content,
167
+ }],
168
+ });
169
+ }
170
+ }
171
+
172
+ // Merge consecutive same-role messages (Anthropic requires alternating roles)
173
+ const merged = [];
174
+ for (const msg of anthropicMessages) {
175
+ if (merged.length > 0 && merged[merged.length - 1].role === msg.role) {
176
+ const prev = merged[merged.length - 1];
177
+ if (typeof prev.content === "string" && typeof msg.content === "string") {
178
+ prev.content += "\n" + msg.content;
179
+ } else {
180
+ // Convert to array format and merge
181
+ const prevArr = Array.isArray(prev.content) ? prev.content : [{ type: "text", text: prev.content }];
182
+ const msgArr = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }];
183
+ prev.content = [...prevArr, ...msgArr];
184
+ }
185
+ } else {
186
+ merged.push({ ...msg });
187
+ }
188
+ }
189
+
190
+ const params = {
191
+ model,
192
+ max_tokens: maxTokens,
193
+ messages: merged,
194
+ };
195
+
196
+ if (systemPrompt) params.system = systemPrompt;
197
+
198
+ if (tools && tools.length > 0) {
199
+ params.tools = tools.map(_toAnthropicTool).filter(Boolean);
200
+ }
201
+
202
+ const response = await client.messages.create(params);
203
+
204
+ // Return in chat-compatible format
205
+ const normalized = _normalizeAnthropicResponse(response);
206
+ const message = { role: "assistant", content: normalized.content || null };
207
+ if (normalized.toolCalls) message.tool_calls = normalized.toolCalls;
208
+
209
+ return {
210
+ choices: [{ message }],
211
+ usage: normalized.usage,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Convert OpenAI tool definition to Anthropic format.
217
+ */
218
+ function _toAnthropicTool(tool) {
219
+ if (tool.type === "function" && tool.function) {
220
+ return {
221
+ name: tool.function.name,
222
+ description: tool.function.description || "",
223
+ input_schema: tool.function.parameters || { type: "object", properties: {} },
224
+ };
225
+ }
226
+ return null;
227
+ }
228
+
106
229
  /**
107
- * Responses API call for codex and responses-only models.
230
+ * Normalize Anthropic response to our standard {content, toolCalls, usage} shape.
108
231
  */
232
+ function _normalizeAnthropicResponse(response) {
233
+ let content = "";
234
+ let toolCalls = null;
235
+
236
+ for (const block of (response.content || [])) {
237
+ if (block.type === "text") {
238
+ content += block.text;
239
+ } else if (block.type === "tool_use") {
240
+ if (!toolCalls) toolCalls = [];
241
+ toolCalls.push({
242
+ id: block.id,
243
+ type: "function",
244
+ function: {
245
+ name: block.name,
246
+ arguments: JSON.stringify(block.input || {}),
247
+ },
248
+ });
249
+ }
250
+ }
251
+
252
+ return {
253
+ content: content.trim(),
254
+ toolCalls,
255
+ usage: {
256
+ input_tokens: response.usage?.input_tokens || 0,
257
+ output_tokens: response.usage?.output_tokens || 0,
258
+ prompt_tokens: response.usage?.input_tokens || 0,
259
+ completion_tokens: response.usage?.output_tokens || 0,
260
+ },
261
+ _raw: response,
262
+ };
263
+ }
264
+
265
+ // ── OpenAI: Responses API ──
266
+
109
267
  async function _responsesCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools }) {
110
268
  const params = {
111
269
  model,
@@ -116,228 +274,101 @@ async function _responsesCall(openai, { model, systemPrompt, userPrompt, maxToke
116
274
  max_output_tokens: maxTokens,
117
275
  };
118
276
 
119
- // Convert chat-style tools to responses-style tools
120
277
  if (tools && tools.length > 0) {
121
278
  params.tools = tools.map(t => {
122
279
  if (t.type === "function" && t.function) {
123
- // Chat Completions style Responses style
124
- return {
125
- type: "function",
126
- name: t.function.name,
127
- description: t.function.description,
128
- parameters: t.function.parameters,
129
- strict: true,
130
- };
280
+ return { type: "function", name: t.function.name, description: t.function.description, parameters: t.function.parameters, strict: true };
131
281
  }
132
282
  return t;
133
283
  });
134
284
  }
135
285
 
136
286
  const response = await openai.responses.create(params);
137
-
138
- // Extract text content and tool calls from response output
139
287
  let content = "";
140
288
  let toolCalls = null;
141
289
 
142
290
  if (response.output) {
143
291
  for (const item of response.output) {
144
292
  if (item.type === "message" && item.content) {
145
- for (const block of item.content) {
146
- if (block.type === "output_text") {
147
- content += block.text;
148
- }
149
- }
293
+ for (const block of item.content) { if (block.type === "output_text") content += block.text; }
150
294
  } else if (item.type === "function_call") {
151
295
  if (!toolCalls) toolCalls = [];
152
- toolCalls.push({
153
- id: item.call_id || item.id,
154
- type: "function",
155
- function: {
156
- name: item.name,
157
- arguments: item.arguments,
158
- },
159
- });
296
+ toolCalls.push({ id: item.call_id || item.id, type: "function", function: { name: item.name, arguments: item.arguments } });
160
297
  }
161
298
  }
162
299
  }
300
+ if (!content && response.output_text) content = response.output_text;
163
301
 
164
- // Fallback: some responses have output_text directly
165
- if (!content && response.output_text) {
166
- content = response.output_text;
167
- }
168
-
169
- return {
170
- content: content.trim(),
171
- toolCalls,
172
- usage: response.usage || {},
173
- _raw: response,
174
- };
302
+ return { content: content.trim(), toolCalls, usage: response.usage || {}, _raw: response };
175
303
  }
176
304
 
177
- /**
178
- * Chat Completions API call — for standard chat models.
179
- */
305
+ // ── OpenAI: Chat Completions ──
306
+
180
307
  async function _chatCall(openai, { model, systemPrompt, userPrompt, maxTokens, tools, toolChoice }) {
181
308
  const messages = [];
182
309
  if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
183
310
  messages.push({ role: "user", content: userPrompt });
184
311
 
185
- // Some models (gpt-5-nano, o-series) don't support temperature
186
312
  const noTemp = /^(o[1-9]|gpt-5)/.test(model);
187
-
188
- const params = {
189
- model,
190
- messages,
191
- ...(!noTemp ? { temperature: 0 } : {}),
192
- ...tokenParam(model, maxTokens),
193
- };
194
-
195
- if (tools && tools.length > 0) {
196
- params.tools = tools;
197
- params.tool_choice = toolChoice || "auto";
198
- }
313
+ const params = { model, messages, ...(!noTemp ? { temperature: 0 } : {}), ...tokenParam(model, maxTokens) };
314
+ if (tools && tools.length > 0) { params.tools = tools; params.tool_choice = toolChoice || "auto"; }
199
315
 
200
316
  const response = await openai.chat.completions.create(params);
201
317
  const choice = response.choices[0];
202
-
203
- return {
204
- content: (choice.message.content || "").trim(),
205
- toolCalls: choice.message.tool_calls || null,
206
- usage: response.usage || {},
207
- _raw: response,
208
- _message: choice.message,
209
- };
318
+ return { content: (choice.message.content || "").trim(), toolCalls: choice.message.tool_calls || null, usage: response.usage || {}, _raw: response, _message: choice.message };
210
319
  }
211
320
 
212
- /**
213
- * Build a Responses API conversation continuation with tool results.
214
- * For multi-turn agent loops with codex models.
215
- */
216
- async function aiCallWithHistory({ model, messages, tools, maxTokens = 4096, category = "chat", tool }) {
217
- const openai = getClient();
218
-
219
- let result;
220
- if (isResponsesModel(model)) {
221
- result = await _responsesCallWithHistory(openai, { model, messages, tools, maxTokens });
222
- } else {
223
- result = await _chatCallWithHistory(openai, { model, messages, tools, maxTokens });
224
- }
225
-
226
- _track(model, category, result.usage, tool);
227
- return result;
228
- }
321
+ // ── OpenAI: Multi-turn (Responses + Chat) ──
229
322
 
230
323
  async function _responsesCallWithHistory(openai, { model, messages, tools, maxTokens }) {
231
- // Convert chat-style messages to responses input format
232
324
  const input = messages.map(msg => {
233
- if (msg.role === "system") {
234
- return { role: "developer", content: msg.content };
235
- }
236
- if (msg.role === "tool") {
237
- return {
238
- type: "function_call_output",
239
- call_id: msg.tool_call_id,
240
- output: msg.content,
241
- };
242
- }
325
+ if (msg.role === "system") return { role: "developer", content: msg.content };
326
+ if (msg.role === "tool") return { type: "function_call_output", call_id: msg.tool_call_id, output: msg.content };
243
327
  if (msg.role === "assistant" && msg.tool_calls) {
244
- // Emit function_call items for each tool call
245
- return msg.tool_calls.map(tc => ({
246
- type: "function_call",
247
- call_id: tc.id,
248
- name: tc.function.name,
249
- arguments: tc.function.arguments,
250
- }));
251
- }
252
- if (msg.role === "assistant") {
253
- return { role: "assistant", content: msg.content || "" };
328
+ return msg.tool_calls.map(tc => ({ type: "function_call", call_id: tc.id, name: tc.function.name, arguments: tc.function.arguments }));
254
329
  }
330
+ if (msg.role === "assistant") return { role: "assistant", content: msg.content || "" };
255
331
  return { role: msg.role, content: msg.content };
256
332
  }).flat();
257
333
 
258
- const params = {
259
- model,
260
- input,
261
- max_output_tokens: maxTokens,
262
- };
263
-
334
+ const params = { model, input, max_output_tokens: maxTokens };
264
335
  if (tools && tools.length > 0) {
265
336
  params.tools = tools.map(t => {
266
337
  if (t.type === "function" && t.function) {
267
- return {
268
- type: "function",
269
- name: t.function.name,
270
- description: t.function.description,
271
- parameters: t.function.parameters,
272
- strict: true,
273
- };
338
+ return { type: "function", name: t.function.name, description: t.function.description, parameters: t.function.parameters, strict: true };
274
339
  }
275
340
  return t;
276
341
  });
277
342
  }
278
343
 
279
344
  const response = await openai.responses.create(params);
280
-
281
- // Build a chat-compatible response
282
345
  let content = "";
283
346
  let toolCalls = null;
284
347
 
285
348
  if (response.output) {
286
349
  for (const item of response.output) {
287
- if (item.type === "message" && item.content) {
288
- for (const block of item.content) {
289
- if (block.type === "output_text") content += block.text;
290
- }
291
- } else if (item.type === "function_call") {
292
- if (!toolCalls) toolCalls = [];
293
- toolCalls.push({
294
- id: item.call_id || item.id,
295
- type: "function",
296
- function: {
297
- name: item.name,
298
- arguments: item.arguments,
299
- },
300
- });
301
- }
350
+ if (item.type === "message" && item.content) { for (const block of item.content) { if (block.type === "output_text") content += block.text; } }
351
+ else if (item.type === "function_call") { if (!toolCalls) toolCalls = []; toolCalls.push({ id: item.call_id || item.id, type: "function", function: { name: item.name, arguments: item.arguments } }); }
302
352
  }
303
353
  }
304
-
305
354
  if (!content && response.output_text) content = response.output_text;
306
355
 
307
- // Return in chat-compatible format so the agent engine doesn't need to change
308
356
  const message = { role: "assistant", content: content.trim() || null };
309
357
  if (toolCalls) message.tool_calls = toolCalls;
310
-
311
- return {
312
- choices: [{ message }],
313
- usage: response.usage || {},
314
- };
358
+ return { choices: [{ message }], usage: response.usage || {} };
315
359
  }
316
360
 
317
361
  async function _chatCallWithHistory(openai, { model, messages, tools, maxTokens }) {
318
362
  const noTemp = /^(o[1-9]|gpt-5)/.test(model);
319
- const params = {
320
- model,
321
- messages,
322
- ...(!noTemp ? { temperature: 0 } : {}),
323
- ...tokenParam(model, maxTokens),
324
- };
325
-
326
- if (tools && tools.length > 0) {
327
- params.tools = tools;
328
- params.tool_choice = "auto";
329
- }
330
-
363
+ const params = { model, messages, ...(!noTemp ? { temperature: 0 } : {}), ...tokenParam(model, maxTokens) };
364
+ if (tools && tools.length > 0) { params.tools = tools; params.tool_choice = "auto"; }
331
365
  return openai.chat.completions.create(params);
332
366
  }
333
367
 
334
- /**
335
- * Send an error context to OpenAI and get a repair patch back.
336
- * Uses CODING_MODEL — routes to correct API automatically.
337
- */
368
+ // ── Fast Path Repair ──
369
+
338
370
  async function requestRepair({ filePath, sourceCode, backupSourceCode, errorMessage, stackTrace, extraContext }) {
339
371
  const model = getModel("coding");
340
-
341
372
  const systemPrompt = "You are a Node.js debugging expert. Respond with ONLY valid JSON, no markdown fences.";
342
373
  const userPrompt = `A server crashed with the following error. Analyze and produce a fix.
343
374
 
@@ -393,4 +424,4 @@ Include both if needed, or just one.`;
393
424
  }
394
425
  }
395
426
 
396
- module.exports = { requestRepair, getClient, tokenParam, aiCall, aiCallWithHistory, isResponsesModel, setTokenTracker };
427
+ module.exports = { requestRepair, getClient, tokenParam, aiCall, aiCallWithHistory, isResponsesModel, isAnthropicModel, setTokenTracker, detectProvider };
@@ -26,17 +26,25 @@ function loadConfig() {
26
26
  }
27
27
  }
28
28
 
29
+ // Resolve model set: if provider is "anthropic", use _anthropic_models as base
30
+ const provider = process.env.WOLVERINE_PROVIDER || fileConfig.provider || "openai";
31
+ const modelSource = provider === "anthropic" && fileConfig._anthropic_models
32
+ ? fileConfig._anthropic_models
33
+ : fileConfig.models;
34
+
29
35
  _config = {
36
+ provider,
37
+
30
38
  models: {
31
- reasoning: process.env.REASONING_MODEL || fileConfig.models?.reasoning || "gpt-4o",
32
- coding: process.env.CODING_MODEL || fileConfig.models?.coding || "gpt-4o",
33
- chat: process.env.CHAT_MODEL || fileConfig.models?.chat || "gpt-4o-mini",
34
- tool: process.env.TOOL_MODEL || fileConfig.models?.tool || "gpt-4o-mini",
35
- classifier: process.env.CLASSIFIER_MODEL || fileConfig.models?.classifier || "gpt-4o-mini",
36
- audit: process.env.AUDIT_MODEL || fileConfig.models?.audit || "gpt-4o-mini",
37
- compacting: process.env.COMPACTING_MODEL || fileConfig.models?.compacting || "gpt-4o-mini",
38
- research: process.env.RESEARCH_MODEL || fileConfig.models?.research || "gpt-4o",
39
- embedding: process.env.TEXT_EMBEDDING_MODEL || fileConfig.models?.embedding || "text-embedding-3-small",
39
+ reasoning: process.env.REASONING_MODEL || modelSource?.reasoning || "gpt-4o",
40
+ coding: process.env.CODING_MODEL || modelSource?.coding || "gpt-4o",
41
+ chat: process.env.CHAT_MODEL || modelSource?.chat || "gpt-4o-mini",
42
+ tool: process.env.TOOL_MODEL || modelSource?.tool || "gpt-4o-mini",
43
+ classifier: process.env.CLASSIFIER_MODEL || modelSource?.classifier || "gpt-4o-mini",
44
+ audit: process.env.AUDIT_MODEL || modelSource?.audit || "gpt-4o-mini",
45
+ compacting: process.env.COMPACTING_MODEL || modelSource?.compacting || "gpt-4o-mini",
46
+ research: process.env.RESEARCH_MODEL || modelSource?.research || "gpt-4o",
47
+ embedding: process.env.TEXT_EMBEDDING_MODEL || modelSource?.embedding || "text-embedding-3-small",
40
48
  },
41
49
 
42
50
  server: {
@@ -1,17 +1,24 @@
1
1
  /**
2
2
  * Model Configuration — centralized model selection for every AI task.
3
3
  *
4
- * Users configure models in .env.local to optimize spend:
4
+ * Supports both OpenAI and Anthropic models. Provider is auto-detected from model name:
5
+ * claude-* → Anthropic
6
+ * gpt-*, o1-*, o3-*, text-embedding-* → OpenAI
5
7
  *
6
- * REASONING_MODEL — Deep analysis, complex debugging (most expensive, most capable)
7
- * CODING_MODEL — Code repair generation (important, needs strong coding ability)
8
- * CHAT_MODEL — Explanations, summaries (good but cheaper)
9
- * AUDIT_MODEL — Security scans, injection detection (runs on every error)
10
- * UTILITY_MODEL — JSON formatting, regex validation, simple classification (cheapest)
11
- *
12
- * Defaults use gpt-4o tiers. Users can swap in any OpenAI-compatible model.
8
+ * Users configure models in .env.local or server/config/settings.json.
9
+ * Mix and match providers per role (e.g., Anthropic for reasoning, OpenAI for coding).
13
10
  */
14
11
 
12
+ /**
13
+ * Detect provider from model name.
14
+ * @returns {"anthropic"|"openai"}
15
+ */
16
+ function detectProvider(model) {
17
+ if (!model) return "openai";
18
+ if (/^claude/i.test(model)) return "anthropic";
19
+ return "openai";
20
+ }
21
+
15
22
  const MODEL_ROLES = {
16
23
  // Deep reasoning — used for multi-step debugging when a simple fix fails
17
24
  reasoning: {
@@ -129,4 +136,4 @@ function logModelConfig(chalk) {
129
136
  }
130
137
  }
131
138
 
132
- module.exports = { getModel, getModelConfig, logModelConfig, MODEL_ROLES };
139
+ module.exports = { getModel, getModelConfig, logModelConfig, MODEL_ROLES, detectProvider };
@@ -36,6 +36,16 @@ const DEFAULT_PRICING = {
36
36
  "text-embedding-3-small": { input: 0.02, output: 0.00 },
37
37
  "text-embedding-3-large": { input: 0.13, output: 0.00 },
38
38
 
39
+ // Anthropic Claude family
40
+ "claude-opus-4": { input: 15.00, output: 75.00 },
41
+ "claude-sonnet-4": { input: 3.00, output: 15.00 },
42
+ "claude-haiku-4": { input: 0.80, output: 4.00 },
43
+ "claude-3-5-sonnet": { input: 3.00, output: 15.00 },
44
+ "claude-3-5-haiku": { input: 0.80, output: 4.00 },
45
+ "claude-3-opus": { input: 15.00, output: 75.00 },
46
+ "claude-3-sonnet": { input: 3.00, output: 15.00 },
47
+ "claude-3-haiku": { input: 0.25, output: 1.25 },
48
+
39
49
  // Fallback for unknown models
40
50
  "_default": { input: 1.00, output: 4.00 },
41
51
  };
@@ -54,6 +54,7 @@ function collectHeartbeat(subsystems) {
54
54
  byCategory: usage?.byCategory || {},
55
55
  byModel: usage?.byModel || {},
56
56
  byTool: usage?.byTool || {},
57
+ byProvider: _aggregateByProvider(usage?.byModel || {}),
57
58
  },
58
59
 
59
60
  brain: { totalMemories: brain?.getStats()?.totalEntries || 0 },
@@ -77,4 +78,23 @@ function collectHeartbeat(subsystems) {
77
78
  return redactObj(payload);
78
79
  }
79
80
 
81
+ /**
82
+ * Aggregate usage by provider (openai vs anthropic) from byModel data.
83
+ * Any new model/provider automatically flows through — no code changes needed.
84
+ */
85
+ function _aggregateByProvider(byModel) {
86
+ const { detectProvider } = require("../core/models");
87
+ const byProvider = {};
88
+ for (const [model, stats] of Object.entries(byModel || {})) {
89
+ const provider = detectProvider(model);
90
+ if (!byProvider[provider]) byProvider[provider] = { input: 0, output: 0, total: 0, calls: 0, cost: 0 };
91
+ byProvider[provider].input += stats.input || 0;
92
+ byProvider[provider].output += stats.output || 0;
93
+ byProvider[provider].total += stats.total || 0;
94
+ byProvider[provider].calls += stats.calls || 0;
95
+ byProvider[provider].cost += stats.cost || 0;
96
+ }
97
+ return byProvider;
98
+ }
99
+
80
100
  module.exports = { collectHeartbeat, INSTANCE_ID };