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 +26 -14
- package/package.json +2 -1
- package/server/config/settings.json +14 -0
- package/src/brain/brain.js +1 -1
- package/src/brain/embedder.js +5 -3
- package/src/core/ai-client.js +243 -212
- package/src/core/config.js +17 -9
- package/src/core/models.js +16 -9
- package/src/logger/pricing.js +10 -0
- package/src/platform/telemetry.js +20 -0
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.
|
|
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
|
-
|
|
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 |
|
|
298
|
-
| `CODING_MODEL` | Code repair/generation |
|
|
299
|
-
| `CHAT_MODEL` | Simple text responses | No |
|
|
300
|
-
| `TOOL_MODEL` | Chat with function calling | **Yes** |
|
|
301
|
-
| `CLASSIFIER_MODEL` | SIMPLE/TOOLS/AGENT routing | No |
|
|
302
|
-
| `AUDIT_MODEL` | Injection detection (every error) | No |
|
|
303
|
-
| `COMPACTING_MODEL` | Text compression for brain | No |
|
|
304
|
-
| `RESEARCH_MODEL` | Deep research on failures | No |
|
|
305
|
-
| `TEXT_EMBEDDING_MODEL` | Brain vector embeddings | No |
|
|
306
|
-
|
|
307
|
-
|
|
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": "
|
|
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,
|
package/src/brain/brain.js
CHANGED
|
@@ -44,7 +44,7 @@ const SEED_DOCS = [
|
|
|
44
44
|
metadata: { topic: "security" },
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
|
-
text: "Wolverine model
|
|
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
|
{
|
package/src/brain/embedder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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({
|
package/src/core/ai-client.js
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
const OpenAI = require("openai");
|
|
2
|
-
const
|
|
2
|
+
const Anthropic = require("@anthropic-ai/sdk");
|
|
3
|
+
const { getModel, detectProvider } = require("./models");
|
|
3
4
|
|
|
4
|
-
let
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
38
|
+
return _openaiClient;
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/core/config.js
CHANGED
|
@@ -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 ||
|
|
32
|
-
coding: process.env.CODING_MODEL ||
|
|
33
|
-
chat: process.env.CHAT_MODEL ||
|
|
34
|
-
tool: process.env.TOOL_MODEL ||
|
|
35
|
-
classifier: process.env.CLASSIFIER_MODEL ||
|
|
36
|
-
audit: process.env.AUDIT_MODEL ||
|
|
37
|
-
compacting: process.env.COMPACTING_MODEL ||
|
|
38
|
-
research: process.env.RESEARCH_MODEL ||
|
|
39
|
-
embedding: process.env.TEXT_EMBEDDING_MODEL ||
|
|
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: {
|
package/src/core/models.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Model Configuration — centralized model selection for every AI task.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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 };
|
package/src/logger/pricing.js
CHANGED
|
@@ -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 };
|