lynkr 1.0.0 → 2.0.0
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/CITATIONS.bib +6 -0
- package/DEPLOYMENT.md +1001 -0
- package/README.md +215 -71
- package/docs/index.md +55 -2
- package/monitor-agents.sh +31 -0
- package/package.json +7 -3
- package/src/agents/context-manager.js +220 -0
- package/src/agents/definitions/loader.js +563 -0
- package/src/agents/executor.js +412 -0
- package/src/agents/index.js +157 -0
- package/src/agents/parallel-coordinator.js +68 -0
- package/src/agents/reflector.js +321 -0
- package/src/agents/skillbook.js +331 -0
- package/src/agents/store.js +244 -0
- package/src/api/router.js +55 -0
- package/src/clients/databricks.js +214 -17
- package/src/clients/routing.js +15 -7
- package/src/clients/standard-tools.js +341 -0
- package/src/config/index.js +41 -5
- package/src/orchestrator/index.js +254 -37
- package/src/server.js +2 -0
- package/src/tools/agent-task.js +96 -0
- package/test/azure-openai-config.test.js +203 -0
- package/test/azure-openai-error-resilience.test.js +238 -0
- package/test/azure-openai-format-conversion.test.js +354 -0
- package/test/azure-openai-integration.test.js +281 -0
- package/test/azure-openai-routing.test.js +148 -0
- package/test/azure-openai-streaming.test.js +171 -0
- package/test/format-conversion.test.js +578 -0
- package/test/hybrid-routing-integration.test.js +18 -11
- package/test/openrouter-error-resilience.test.js +418 -0
- package/test/passthrough-mode.test.js +385 -0
- package/test/routing.test.js +9 -3
- package/test/web-tools.test.js +3 -0
- package/test-agents-simple.js +43 -0
- package/test-cli-connection.sh +33 -0
- package/test-learning-unit.js +126 -0
- package/test-learning.js +112 -0
- package/test-parallel-agents.sh +124 -0
- package/test-parallel-direct.js +155 -0
- package/test-subagents.sh +117 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
const Database = require("better-sqlite3");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const logger = require("../logger");
|
|
5
|
+
|
|
6
|
+
class AgentStore {
|
|
7
|
+
constructor() {
|
|
8
|
+
// Use same database location as main app
|
|
9
|
+
const dbDir = path.join(process.cwd(), "data");
|
|
10
|
+
if (!fs.existsSync(dbDir)) {
|
|
11
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const dbPath = path.join(dbDir, "lynkr.db");
|
|
15
|
+
this.db = new Database(dbPath, {
|
|
16
|
+
verbose: process.env.DEBUG_SQL ? console.log : null,
|
|
17
|
+
fileMustExist: false
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
this.initTables();
|
|
21
|
+
this.prepareStatements();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
initTables() {
|
|
25
|
+
this.db.exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS agent_executions (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
session_id TEXT,
|
|
29
|
+
agent_type TEXT NOT NULL,
|
|
30
|
+
prompt TEXT NOT NULL,
|
|
31
|
+
model TEXT NOT NULL,
|
|
32
|
+
status TEXT NOT NULL, -- 'pending', 'running', 'completed', 'failed'
|
|
33
|
+
result TEXT,
|
|
34
|
+
error TEXT,
|
|
35
|
+
steps INTEGER DEFAULT 0,
|
|
36
|
+
duration_ms INTEGER,
|
|
37
|
+
input_tokens INTEGER DEFAULT 0,
|
|
38
|
+
output_tokens INTEGER DEFAULT 0,
|
|
39
|
+
created_at INTEGER NOT NULL,
|
|
40
|
+
completed_at INTEGER
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_agent_executions_session_id
|
|
44
|
+
ON agent_executions(session_id);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_agent_executions_agent_type
|
|
47
|
+
ON agent_executions(agent_type);
|
|
48
|
+
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_agent_executions_status
|
|
50
|
+
ON agent_executions(status);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_agent_executions_created_at
|
|
53
|
+
ON agent_executions(created_at DESC);
|
|
54
|
+
`);
|
|
55
|
+
|
|
56
|
+
logger.info("Agent store tables initialized");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
prepareStatements() {
|
|
60
|
+
this.stmts = {
|
|
61
|
+
create: this.db.prepare(`
|
|
62
|
+
INSERT INTO agent_executions (
|
|
63
|
+
session_id, agent_type, prompt, model, status, created_at
|
|
64
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
65
|
+
`),
|
|
66
|
+
|
|
67
|
+
updateStatus: this.db.prepare(`
|
|
68
|
+
UPDATE agent_executions
|
|
69
|
+
SET status = ?, completed_at = ?
|
|
70
|
+
WHERE id = ?
|
|
71
|
+
`),
|
|
72
|
+
|
|
73
|
+
complete: this.db.prepare(`
|
|
74
|
+
UPDATE agent_executions
|
|
75
|
+
SET status = 'completed',
|
|
76
|
+
result = ?,
|
|
77
|
+
steps = ?,
|
|
78
|
+
duration_ms = ?,
|
|
79
|
+
input_tokens = ?,
|
|
80
|
+
output_tokens = ?,
|
|
81
|
+
completed_at = ?
|
|
82
|
+
WHERE id = ?
|
|
83
|
+
`),
|
|
84
|
+
|
|
85
|
+
fail: this.db.prepare(`
|
|
86
|
+
UPDATE agent_executions
|
|
87
|
+
SET status = 'failed',
|
|
88
|
+
error = ?,
|
|
89
|
+
steps = ?,
|
|
90
|
+
duration_ms = ?,
|
|
91
|
+
completed_at = ?
|
|
92
|
+
WHERE id = ?
|
|
93
|
+
`),
|
|
94
|
+
|
|
95
|
+
get: this.db.prepare(`
|
|
96
|
+
SELECT * FROM agent_executions WHERE id = ?
|
|
97
|
+
`),
|
|
98
|
+
|
|
99
|
+
getBySession: this.db.prepare(`
|
|
100
|
+
SELECT * FROM agent_executions
|
|
101
|
+
WHERE session_id = ?
|
|
102
|
+
ORDER BY created_at DESC
|
|
103
|
+
`),
|
|
104
|
+
|
|
105
|
+
getRecent: this.db.prepare(`
|
|
106
|
+
SELECT * FROM agent_executions
|
|
107
|
+
ORDER BY created_at DESC
|
|
108
|
+
LIMIT ?
|
|
109
|
+
`),
|
|
110
|
+
|
|
111
|
+
stats: this.db.prepare(`
|
|
112
|
+
SELECT
|
|
113
|
+
agent_type,
|
|
114
|
+
COUNT(*) as total_executions,
|
|
115
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
116
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
|
117
|
+
AVG(CASE WHEN status = 'completed' THEN duration_ms ELSE NULL END) as avg_duration_ms,
|
|
118
|
+
SUM(input_tokens) as total_input_tokens,
|
|
119
|
+
SUM(output_tokens) as total_output_tokens
|
|
120
|
+
FROM agent_executions
|
|
121
|
+
GROUP BY agent_type
|
|
122
|
+
`)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create new agent execution
|
|
128
|
+
*/
|
|
129
|
+
createExecution({ sessionId, agentType, prompt, model }) {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const result = this.stmts.create.run(
|
|
132
|
+
sessionId || null,
|
|
133
|
+
agentType,
|
|
134
|
+
prompt,
|
|
135
|
+
model,
|
|
136
|
+
'pending',
|
|
137
|
+
now
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
logger.info({
|
|
141
|
+
executionId: result.lastInsertRowid,
|
|
142
|
+
agentType,
|
|
143
|
+
sessionId
|
|
144
|
+
}, "Created agent execution");
|
|
145
|
+
|
|
146
|
+
return result.lastInsertRowid;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Update execution status
|
|
151
|
+
*/
|
|
152
|
+
updateStatus(executionId, status) {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
this.stmts.updateStatus.run(status, now, executionId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Mark execution as completed
|
|
159
|
+
*/
|
|
160
|
+
completeExecution(executionId, result, stats = {}) {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
this.stmts.complete.run(
|
|
163
|
+
result,
|
|
164
|
+
stats.steps || 0,
|
|
165
|
+
stats.durationMs || 0,
|
|
166
|
+
stats.inputTokens || 0,
|
|
167
|
+
stats.outputTokens || 0,
|
|
168
|
+
now,
|
|
169
|
+
executionId
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
logger.info({
|
|
173
|
+
executionId,
|
|
174
|
+
steps: stats.steps,
|
|
175
|
+
durationMs: stats.durationMs
|
|
176
|
+
}, "Agent execution completed");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Mark execution as failed
|
|
181
|
+
*/
|
|
182
|
+
failExecution(executionId, error, stats = {}) {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
this.stmts.fail.run(
|
|
185
|
+
error.message || String(error),
|
|
186
|
+
stats.steps || 0,
|
|
187
|
+
stats.durationMs || 0,
|
|
188
|
+
now,
|
|
189
|
+
executionId
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
logger.warn({
|
|
193
|
+
executionId,
|
|
194
|
+
error: error.message
|
|
195
|
+
}, "Agent execution failed");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get execution by ID
|
|
200
|
+
*/
|
|
201
|
+
getExecution(executionId) {
|
|
202
|
+
return this.stmts.get.get(executionId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get executions by session
|
|
207
|
+
*/
|
|
208
|
+
getSessionExecutions(sessionId) {
|
|
209
|
+
return this.stmts.getBySession.all(sessionId);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get recent executions
|
|
214
|
+
*/
|
|
215
|
+
getRecentExecutions(limit = 100) {
|
|
216
|
+
return this.stmts.getRecent.all(limit);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get aggregate statistics
|
|
221
|
+
*/
|
|
222
|
+
getStats() {
|
|
223
|
+
return this.stmts.stats.all();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Close database connection
|
|
228
|
+
*/
|
|
229
|
+
close() {
|
|
230
|
+
this.db.close();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Singleton instance
|
|
235
|
+
let instance = null;
|
|
236
|
+
|
|
237
|
+
function getInstance() {
|
|
238
|
+
if (!instance) {
|
|
239
|
+
instance = new AgentStore();
|
|
240
|
+
}
|
|
241
|
+
return instance;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = getInstance();
|
package/src/api/router.js
CHANGED
|
@@ -250,4 +250,59 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
250
250
|
}
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
// List available agents (must come before parameterized routes)
|
|
254
|
+
router.get("/v1/agents", (req, res) => {
|
|
255
|
+
try {
|
|
256
|
+
const { listAgents } = require("../agents");
|
|
257
|
+
const agents = listAgents();
|
|
258
|
+
res.json({ agents });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
res.status(500).json({ error: error.message });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Agent stats endpoint (specific path before parameterized)
|
|
265
|
+
router.get("/v1/agents/stats", (req, res) => {
|
|
266
|
+
try {
|
|
267
|
+
const { getAgentStats } = require("../agents");
|
|
268
|
+
const stats = getAgentStats();
|
|
269
|
+
res.json({ stats });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
res.status(500).json({ error: error.message });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Read agent transcript (specific path with param before catch-all)
|
|
276
|
+
router.get("/v1/agents/:agentId/transcript", (req, res) => {
|
|
277
|
+
try {
|
|
278
|
+
const ContextManager = require("../agents/context-manager");
|
|
279
|
+
const cm = new ContextManager();
|
|
280
|
+
const transcript = cm.readTranscript(req.params.agentId);
|
|
281
|
+
|
|
282
|
+
if (!transcript) {
|
|
283
|
+
return res.status(404).json({ error: "Transcript not found" });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
res.json({ transcript });
|
|
287
|
+
} catch (error) {
|
|
288
|
+
res.status(500).json({ error: error.message });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Agent execution details (parameterized - must come last)
|
|
293
|
+
router.get("/v1/agents/:executionId", (req, res) => {
|
|
294
|
+
try {
|
|
295
|
+
const { getAgentExecution } = require("../agents");
|
|
296
|
+
const details = getAgentExecution(req.params.executionId);
|
|
297
|
+
|
|
298
|
+
if (!details) {
|
|
299
|
+
return res.status(404).json({ error: "Execution not found" });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
res.json(details);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
res.status(500).json({ error: error.message });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
253
308
|
module.exports = router;
|
|
@@ -5,11 +5,18 @@ const { withRetry } = require("./retry");
|
|
|
5
5
|
const { getCircuitBreakerRegistry } = require("./circuit-breaker");
|
|
6
6
|
const { getMetricsCollector } = require("../observability/metrics");
|
|
7
7
|
const logger = require("../logger");
|
|
8
|
+
const { STANDARD_TOOLS } = require("./standard-tools");
|
|
9
|
+
const { convertAnthropicToolsToOpenRouter } = require("./openrouter-utils");
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
8
13
|
|
|
9
14
|
if (typeof fetch !== "function") {
|
|
10
15
|
throw new Error("Node 18+ is required for the built-in fetch API.");
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
|
|
19
|
+
|
|
13
20
|
// HTTP connection pooling for better performance
|
|
14
21
|
const httpAgent = new http.Agent({
|
|
15
22
|
keepAlive: true,
|
|
@@ -117,17 +124,61 @@ async function invokeDatabricks(body) {
|
|
|
117
124
|
if (!config.databricks?.url) {
|
|
118
125
|
throw new Error("Databricks configuration is missing required URL.");
|
|
119
126
|
}
|
|
127
|
+
|
|
128
|
+
// Create a copy of body to avoid mutating the original
|
|
129
|
+
const databricksBody = { ...body };
|
|
130
|
+
|
|
131
|
+
// Inject standard tools if client didn't send any (passthrough mode)
|
|
132
|
+
if (!Array.isArray(databricksBody.tools) || databricksBody.tools.length === 0) {
|
|
133
|
+
databricksBody.tools = STANDARD_TOOLS;
|
|
134
|
+
logger.info({
|
|
135
|
+
injectedToolCount: STANDARD_TOOLS.length,
|
|
136
|
+
injectedToolNames: STANDARD_TOOLS.map(t => t.name),
|
|
137
|
+
reason: "Client did not send tools (passthrough mode)"
|
|
138
|
+
}, "=== INJECTING STANDARD TOOLS (Databricks) ===");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Convert Anthropic format tools to OpenAI format (Databricks uses OpenAI format)
|
|
142
|
+
if (Array.isArray(databricksBody.tools) && databricksBody.tools.length > 0) {
|
|
143
|
+
// Check if tools are already in OpenAI format (have type: "function")
|
|
144
|
+
const alreadyConverted = databricksBody.tools[0]?.type === "function";
|
|
145
|
+
|
|
146
|
+
if (!alreadyConverted) {
|
|
147
|
+
databricksBody.tools = convertAnthropicToolsToOpenRouter(databricksBody.tools);
|
|
148
|
+
logger.debug({
|
|
149
|
+
convertedToolCount: databricksBody.tools.length,
|
|
150
|
+
convertedToolNames: databricksBody.tools.map(t => t.function?.name),
|
|
151
|
+
}, "Converted tools to OpenAI format for Databricks");
|
|
152
|
+
} else {
|
|
153
|
+
logger.debug({
|
|
154
|
+
toolCount: databricksBody.tools.length,
|
|
155
|
+
toolNames: databricksBody.tools.map(t => t.function?.name),
|
|
156
|
+
}, "Tools already in OpenAI format, skipping conversion");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
120
160
|
const headers = {
|
|
121
161
|
Authorization: `Bearer ${config.databricks.apiKey}`,
|
|
122
162
|
"Content-Type": "application/json",
|
|
123
163
|
};
|
|
124
|
-
return performJsonRequest(config.databricks.url, { headers, body }, "Databricks");
|
|
164
|
+
return performJsonRequest(config.databricks.url, { headers, body: databricksBody }, "Databricks");
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
async function invokeAzureAnthropic(body) {
|
|
128
168
|
if (!config.azureAnthropic?.endpoint) {
|
|
129
169
|
throw new Error("Azure Anthropic endpoint is not configured.");
|
|
130
170
|
}
|
|
171
|
+
|
|
172
|
+
// Inject standard tools if client didn't send any (passthrough mode)
|
|
173
|
+
if (!Array.isArray(body.tools) || body.tools.length === 0) {
|
|
174
|
+
body.tools = STANDARD_TOOLS;
|
|
175
|
+
logger.info({
|
|
176
|
+
injectedToolCount: STANDARD_TOOLS.length,
|
|
177
|
+
injectedToolNames: STANDARD_TOOLS.map(t => t.name),
|
|
178
|
+
reason: "Client did not send tools (passthrough mode)"
|
|
179
|
+
}, "=== INJECTING STANDARD TOOLS (Azure Anthropic) ===");
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
const headers = {
|
|
132
183
|
"Content-Type": "application/json",
|
|
133
184
|
"x-api-key": config.azureAnthropic.apiKey,
|
|
@@ -180,12 +231,27 @@ async function invokeOllama(body) {
|
|
|
180
231
|
},
|
|
181
232
|
};
|
|
182
233
|
|
|
234
|
+
// Inject standard tools if client didn't send any (passthrough mode)
|
|
235
|
+
let toolsToSend = body.tools;
|
|
236
|
+
let toolsInjected = false;
|
|
237
|
+
|
|
238
|
+
if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
|
|
239
|
+
toolsToSend = STANDARD_TOOLS;
|
|
240
|
+
toolsInjected = true;
|
|
241
|
+
logger.info({
|
|
242
|
+
injectedToolCount: STANDARD_TOOLS.length,
|
|
243
|
+
injectedToolNames: STANDARD_TOOLS.map(t => t.name),
|
|
244
|
+
reason: "Client did not send tools (passthrough mode)"
|
|
245
|
+
}, "=== INJECTING STANDARD TOOLS (Ollama) ===");
|
|
246
|
+
}
|
|
247
|
+
|
|
183
248
|
// Add tools if present (for tool-capable models)
|
|
184
|
-
if (Array.isArray(
|
|
185
|
-
ollamaBody.tools = convertAnthropicToolsToOllama(
|
|
186
|
-
logger.
|
|
187
|
-
toolCount:
|
|
188
|
-
toolNames:
|
|
249
|
+
if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
|
|
250
|
+
ollamaBody.tools = convertAnthropicToolsToOllama(toolsToSend);
|
|
251
|
+
logger.info({
|
|
252
|
+
toolCount: toolsToSend.length,
|
|
253
|
+
toolNames: toolsToSend.map(t => t.name),
|
|
254
|
+
toolsInjected
|
|
189
255
|
}, "Sending tools to Ollama");
|
|
190
256
|
}
|
|
191
257
|
|
|
@@ -210,27 +276,154 @@ async function invokeOpenRouter(body) {
|
|
|
210
276
|
"X-Title": "Claude-Ollama-Proxy"
|
|
211
277
|
};
|
|
212
278
|
|
|
279
|
+
// Convert messages and handle system message
|
|
280
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
281
|
+
|
|
282
|
+
// Anthropic uses separate 'system' field, OpenAI needs it as first message
|
|
283
|
+
if (body.system) {
|
|
284
|
+
messages.unshift({
|
|
285
|
+
role: "system",
|
|
286
|
+
content: body.system
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
213
290
|
const openRouterBody = {
|
|
214
291
|
model: config.openrouter.model,
|
|
215
|
-
messages
|
|
292
|
+
messages,
|
|
216
293
|
temperature: body.temperature ?? 0.7,
|
|
217
294
|
max_tokens: body.max_tokens ?? 4096,
|
|
218
295
|
top_p: body.top_p ?? 1.0,
|
|
219
296
|
stream: body.stream ?? false
|
|
220
297
|
};
|
|
221
298
|
|
|
222
|
-
// Add tools if
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
299
|
+
// Add tools - inject standard tools if client didn't send any (passthrough mode)
|
|
300
|
+
let toolsToSend = body.tools;
|
|
301
|
+
let toolsInjected = false;
|
|
302
|
+
|
|
303
|
+
if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
|
|
304
|
+
// Client didn't send tools (likely passthrough mode) - inject standard Claude Code tools
|
|
305
|
+
toolsToSend = STANDARD_TOOLS;
|
|
306
|
+
toolsInjected = true;
|
|
307
|
+
logger.info({
|
|
308
|
+
injectedToolCount: STANDARD_TOOLS.length,
|
|
309
|
+
injectedToolNames: STANDARD_TOOLS.map(t => t.name),
|
|
310
|
+
reason: "Client did not send tools (passthrough mode)"
|
|
311
|
+
}, "=== INJECTING STANDARD TOOLS (OpenRouter) ===");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
|
|
315
|
+
openRouterBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
|
|
316
|
+
logger.info({
|
|
317
|
+
toolCount: toolsToSend.length,
|
|
318
|
+
toolNames: toolsToSend.map(t => t.name),
|
|
319
|
+
toolsInjected
|
|
228
320
|
}, "Sending tools to OpenRouter");
|
|
229
321
|
}
|
|
230
322
|
|
|
231
323
|
return performJsonRequest(endpoint, { headers, body: openRouterBody }, "OpenRouter");
|
|
232
324
|
}
|
|
233
325
|
|
|
326
|
+
function detectAzureFormat(url) {
|
|
327
|
+
if (url.includes("/openai/responses")) return "responses";
|
|
328
|
+
if (url.includes("/models/")) return "models";
|
|
329
|
+
if (url.includes("/openai/deployments")) return "deployments";
|
|
330
|
+
throw new Error("Unknown Azure OpenAI endpoint");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async function invokeAzureOpenAI(body) {
|
|
335
|
+
if (!config.azureOpenAI?.endpoint || !config.azureOpenAI?.apiKey) {
|
|
336
|
+
throw new Error("Azure OpenAI endpoint or API key is not configured.");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const {
|
|
340
|
+
convertAnthropicToolsToOpenRouter,
|
|
341
|
+
convertAnthropicMessagesToOpenRouter
|
|
342
|
+
} = require("./openrouter-utils");
|
|
343
|
+
|
|
344
|
+
// Azure OpenAI URL format
|
|
345
|
+
const endpoint = config.azureOpenAI.endpoint;
|
|
346
|
+
const format = detectAzureFormat(endpoint);
|
|
347
|
+
|
|
348
|
+
const headers = {
|
|
349
|
+
"api-key": config.azureOpenAI.apiKey, // Azure uses "api-key" not "Authorization"
|
|
350
|
+
"Content-Type": "application/json"
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Convert messages and handle system message
|
|
354
|
+
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
355
|
+
|
|
356
|
+
// Anthropic uses separate 'system' field, OpenAI needs it as first message
|
|
357
|
+
if (body.system) {
|
|
358
|
+
messages.unshift({
|
|
359
|
+
role: "system",
|
|
360
|
+
content: body.system
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const azureBody = {
|
|
365
|
+
messages,
|
|
366
|
+
temperature: body.temperature ?? 0.3, // Lower temperature for more deterministic, action-oriented behavior
|
|
367
|
+
max_tokens: Math.min(body.max_tokens ?? 4096, 16384), // Cap at Azure OpenAI's limit
|
|
368
|
+
top_p: body.top_p ?? 1.0,
|
|
369
|
+
stream: body.stream ?? false,
|
|
370
|
+
model: config.azureOpenAI.deployment
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// Add tools - inject standard tools if client didn't send any (passthrough mode)
|
|
374
|
+
let toolsToSend = body.tools;
|
|
375
|
+
let toolsInjected = false;
|
|
376
|
+
|
|
377
|
+
if (!Array.isArray(toolsToSend) || toolsToSend.length === 0) {
|
|
378
|
+
// Client didn't send tools (likely passthrough mode) - inject standard Claude Code tools
|
|
379
|
+
toolsToSend = STANDARD_TOOLS;
|
|
380
|
+
toolsInjected = true;
|
|
381
|
+
logger.info({
|
|
382
|
+
injectedToolCount: STANDARD_TOOLS.length,
|
|
383
|
+
injectedToolNames: STANDARD_TOOLS.map(t => t.name),
|
|
384
|
+
reason: "Client did not send tools (passthrough mode)"
|
|
385
|
+
}, "=== INJECTING STANDARD TOOLS ===");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (Array.isArray(toolsToSend) && toolsToSend.length > 0) {
|
|
389
|
+
azureBody.tools = convertAnthropicToolsToOpenRouter(toolsToSend);
|
|
390
|
+
azureBody.parallel_tool_calls = true; // Enable parallel tool calling for better performance
|
|
391
|
+
azureBody.tool_choice = "auto"; // Explicitly enable tool use (helps GPT models understand they should use tools)
|
|
392
|
+
logger.info({
|
|
393
|
+
toolCount: toolsToSend.length,
|
|
394
|
+
toolNames: toolsToSend.map(t => t.name),
|
|
395
|
+
toolsInjected,
|
|
396
|
+
hasSystemMessage: !!body.system,
|
|
397
|
+
messageCount: messages.length,
|
|
398
|
+
temperature: azureBody.temperature,
|
|
399
|
+
sampleTool: azureBody.tools[0] // Log first tool for inspection
|
|
400
|
+
}, "=== SENDING TOOLS TO AZURE OPENAI ===");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
logger.info({
|
|
404
|
+
endpoint,
|
|
405
|
+
hasTools: !!azureBody.tools,
|
|
406
|
+
toolCount: azureBody.tools?.length || 0,
|
|
407
|
+
temperature: azureBody.temperature,
|
|
408
|
+
max_tokens: azureBody.max_tokens,
|
|
409
|
+
tool_choice: azureBody.tool_choice
|
|
410
|
+
}, "=== AZURE OPENAI REQUEST ===");
|
|
411
|
+
|
|
412
|
+
if (format === "deployments" || format === "models") {
|
|
413
|
+
return performJsonRequest(endpoint, { headers, body: azureBody }, "Azure OpenAI");
|
|
414
|
+
}
|
|
415
|
+
else if (format === "responses") {
|
|
416
|
+
azureBody.max_completion_tokens = azureBody.max_tokens;
|
|
417
|
+
delete azureBody.max_tokens;
|
|
418
|
+
delete azureBody.temperature;
|
|
419
|
+
delete azureBody.top_p;
|
|
420
|
+
return performJsonRequest(endpoint, { headers, body: azureBody }, "Azure OpenAI");
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
throw new Error(`Unsupported Azure OpenAI endpoint format: ${format}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
234
427
|
async function invokeModel(body, options = {}) {
|
|
235
428
|
const { determineProvider, isFallbackEnabled, getFallbackProvider } = require("./routing");
|
|
236
429
|
const metricsCollector = getMetricsCollector();
|
|
@@ -262,7 +455,9 @@ async function invokeModel(body, options = {}) {
|
|
|
262
455
|
try {
|
|
263
456
|
// Try initial provider with circuit breaker
|
|
264
457
|
const result = await breaker.execute(async () => {
|
|
265
|
-
if (initialProvider === "azure-
|
|
458
|
+
if (initialProvider === "azure-openai") {
|
|
459
|
+
return await invokeAzureOpenAI(body);
|
|
460
|
+
} else if (initialProvider === "azure-anthropic") {
|
|
266
461
|
return await invokeAzureAnthropic(body);
|
|
267
462
|
} else if (initialProvider === "ollama") {
|
|
268
463
|
return await invokeOllama(body);
|
|
@@ -337,7 +532,9 @@ async function invokeModel(body, options = {}) {
|
|
|
337
532
|
|
|
338
533
|
// Execute fallback
|
|
339
534
|
const fallbackResult = await fallbackBreaker.execute(async () => {
|
|
340
|
-
if (fallbackProvider === "azure-
|
|
535
|
+
if (fallbackProvider === "azure-openai") {
|
|
536
|
+
return await invokeAzureOpenAI(body);
|
|
537
|
+
} else if (fallbackProvider === "azure-anthropic") {
|
|
341
538
|
return await invokeAzureAnthropic(body);
|
|
342
539
|
} else if (fallbackProvider === "openrouter") {
|
|
343
540
|
return await invokeOpenRouter(body);
|
|
@@ -401,8 +598,8 @@ function categorizeFailure(error) {
|
|
|
401
598
|
return "timeout";
|
|
402
599
|
}
|
|
403
600
|
if (error.message?.includes("not configured") ||
|
|
404
|
-
|
|
405
|
-
|
|
601
|
+
error.message?.includes("not available") ||
|
|
602
|
+
error.code === "ECONNREFUSED") {
|
|
406
603
|
return "service_unavailable";
|
|
407
604
|
}
|
|
408
605
|
if (error.message?.includes("tool") || error.message?.includes("function")) {
|
package/src/clients/routing.js
CHANGED
|
@@ -52,13 +52,21 @@ function determineProvider(payload) {
|
|
|
52
52
|
return "ollama";
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// Moderate tool count → OpenRouter (if configured and fallback enabled)
|
|
56
|
-
if (toolCount < maxToolsForOpenRouter && isFallbackEnabled()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
// Moderate tool count → OpenRouter or Azure OpenAI (if configured and fallback enabled)
|
|
56
|
+
if (toolCount < maxToolsForOpenRouter && isFallbackEnabled()) {
|
|
57
|
+
if (config.openrouter?.apiKey) {
|
|
58
|
+
logger.debug(
|
|
59
|
+
{ toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "openrouter" },
|
|
60
|
+
"Routing to OpenRouter (moderate tools)"
|
|
61
|
+
);
|
|
62
|
+
return "openrouter";
|
|
63
|
+
} else if (config.azureOpenAI?.apiKey) {
|
|
64
|
+
logger.debug(
|
|
65
|
+
{ toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "azure-openai" },
|
|
66
|
+
"Routing to Azure OpenAI (moderate tools)"
|
|
67
|
+
);
|
|
68
|
+
return "azure-openai";
|
|
69
|
+
}
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// Heavy tool count → cloud (only if fallback is enabled)
|