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.
Files changed (41) hide show
  1. package/CITATIONS.bib +6 -0
  2. package/DEPLOYMENT.md +1001 -0
  3. package/README.md +215 -71
  4. package/docs/index.md +55 -2
  5. package/monitor-agents.sh +31 -0
  6. package/package.json +7 -3
  7. package/src/agents/context-manager.js +220 -0
  8. package/src/agents/definitions/loader.js +563 -0
  9. package/src/agents/executor.js +412 -0
  10. package/src/agents/index.js +157 -0
  11. package/src/agents/parallel-coordinator.js +68 -0
  12. package/src/agents/reflector.js +321 -0
  13. package/src/agents/skillbook.js +331 -0
  14. package/src/agents/store.js +244 -0
  15. package/src/api/router.js +55 -0
  16. package/src/clients/databricks.js +214 -17
  17. package/src/clients/routing.js +15 -7
  18. package/src/clients/standard-tools.js +341 -0
  19. package/src/config/index.js +41 -5
  20. package/src/orchestrator/index.js +254 -37
  21. package/src/server.js +2 -0
  22. package/src/tools/agent-task.js +96 -0
  23. package/test/azure-openai-config.test.js +203 -0
  24. package/test/azure-openai-error-resilience.test.js +238 -0
  25. package/test/azure-openai-format-conversion.test.js +354 -0
  26. package/test/azure-openai-integration.test.js +281 -0
  27. package/test/azure-openai-routing.test.js +148 -0
  28. package/test/azure-openai-streaming.test.js +171 -0
  29. package/test/format-conversion.test.js +578 -0
  30. package/test/hybrid-routing-integration.test.js +18 -11
  31. package/test/openrouter-error-resilience.test.js +418 -0
  32. package/test/passthrough-mode.test.js +385 -0
  33. package/test/routing.test.js +9 -3
  34. package/test/web-tools.test.js +3 -0
  35. package/test-agents-simple.js +43 -0
  36. package/test-cli-connection.sh +33 -0
  37. package/test-learning-unit.js +126 -0
  38. package/test-learning.js +112 -0
  39. package/test-parallel-agents.sh +124 -0
  40. package/test-parallel-direct.js +155 -0
  41. 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(body.tools) && body.tools.length > 0) {
185
- ollamaBody.tools = convertAnthropicToolsToOllama(body.tools);
186
- logger.debug({
187
- toolCount: body.tools.length,
188
- toolNames: body.tools.map(t => t.name)
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: convertAnthropicMessagesToOpenRouter(body.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 present
223
- if (Array.isArray(body.tools) && body.tools.length > 0) {
224
- openRouterBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
225
- logger.debug({
226
- toolCount: body.tools.length,
227
- toolNames: body.tools.map(t => t.name)
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-anthropic") {
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-anthropic") {
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
- error.message?.includes("not available") ||
405
- error.code === "ECONNREFUSED") {
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")) {
@@ -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() && config.openrouter?.apiKey) {
57
- logger.debug(
58
- { toolCount, maxToolsForOllama, maxToolsForOpenRouter, decision: "openrouter" },
59
- "Routing to OpenRouter (moderate tools)"
60
- );
61
- return "openrouter";
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)