lynkr 9.14.2 → 9.14.7

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 (61) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +10 -4
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/mcp-broker.js +149 -0
  17. package/src/api/middleware/budget-enforcer.js +77 -7
  18. package/src/api/middleware/budget.js +36 -2
  19. package/src/api/openai-router.js +125 -4
  20. package/src/api/providers-handler.js +16 -0
  21. package/src/api/router.js +67 -103
  22. package/src/auth-mode.js +40 -4
  23. package/src/budget/index.js +86 -0
  24. package/src/cache/embeddings.js +125 -35
  25. package/src/cache/onnx-embedder.js +92 -0
  26. package/src/clients/databricks.js +285 -21
  27. package/src/clients/gpt-utils.js +83 -7
  28. package/src/clients/health-probe.js +157 -0
  29. package/src/clients/provider-capabilities.js +27 -0
  30. package/src/config/index.js +13 -1
  31. package/src/context/compression.js +127 -19
  32. package/src/dashboard/api.js +1 -0
  33. package/src/observability/metrics.js +52 -0
  34. package/src/observability/otel.js +180 -0
  35. package/src/orchestrator/azure-responses-sse.js +223 -0
  36. package/src/orchestrator/index.js +219 -136
  37. package/src/orchestrator/passthrough-stream.js +7 -1
  38. package/src/orchestrator/sse-transformer.js +62 -3
  39. package/src/routing/bandit.js +39 -0
  40. package/src/routing/capabilities.js +88 -0
  41. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  42. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  43. package/src/routing/capability-seeds/family.js +105 -0
  44. package/src/routing/capability-seeds/models-dev.js +64 -0
  45. package/src/routing/capability-seeds/normalize.js +160 -0
  46. package/src/routing/capability-seeds/registry.js +125 -0
  47. package/src/routing/capability-seeds/sources.js +69 -0
  48. package/src/routing/capability-seeds/swebench.js +66 -0
  49. package/src/routing/decide.js +149 -0
  50. package/src/routing/deescalator.js +37 -0
  51. package/src/routing/index.js +202 -86
  52. package/src/routing/model-registry.js +25 -0
  53. package/src/routing/model-slots.js +65 -0
  54. package/src/routing/model-tiers.js +2 -0
  55. package/src/routing/ope.js +255 -0
  56. package/src/routing/openai-model-slots.js +82 -0
  57. package/src/routing/shortfall.js +290 -0
  58. package/src/routing/stuck-detector.js +127 -0
  59. package/src/routing/telemetry.js +11 -2
  60. package/src/server.js +41 -3
  61. package/src/tools/web-search-exec.js +182 -0
@@ -77,11 +77,26 @@ function _stripInternalFields(body) {
77
77
  return cleaned || body;
78
78
  }
79
79
 
80
- async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride }, providerLabel) {
80
+ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride, timeoutMs }, providerLabel) {
81
81
  const agent = url.startsWith('https:') ? httpsAgent : httpAgent;
82
82
  body = _stripInternalFields(body);
83
83
  const isStreaming = body.stream === true;
84
84
 
85
+ // Optional per-call abort timeout (opt-in via timeoutMs) — most callers
86
+ // omit it and keep the historical unbounded-fetch behavior. Added for
87
+ // llama.cpp (see invokeLlamaCpp): a dead upstream that accepts the TCP
88
+ // connection but never answers at the application layer hangs fetch()
89
+ // indefinitely — live-observed at ~75s, riding an incidental OS/tunnel
90
+ // socket timeout rather than any deliberate bound. This makes the
91
+ // already-configured (but previously unused) LLAMACPP_TIMEOUT_MS real.
92
+ // A fresh signal is created per attempt below (not hoisted here) —
93
+ // AbortSignal.timeout()'s clock starts at construction, so one signal
94
+ // shared across withRetry's attempts would leave later retries with a
95
+ // shrinking (or already-expired) budget instead of a full timeout each.
96
+ const makeSignal = () => (typeof timeoutMs === "number" && timeoutMs > 0
97
+ ? AbortSignal.timeout(timeoutMs)
98
+ : undefined);
99
+
85
100
  // Streaming requests can't be retried, so handle them directly
86
101
  if (isStreaming) {
87
102
  const response = await fetch(url, {
@@ -89,6 +104,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
89
104
  headers,
90
105
  body: JSON.stringify(body),
91
106
  agent,
107
+ signal: makeSignal(),
92
108
  });
93
109
 
94
110
  logger.debug({
@@ -122,6 +138,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv
122
138
  headers,
123
139
  body: JSON.stringify(body),
124
140
  agent,
141
+ signal: makeSignal(),
125
142
  });
126
143
  const text = await response.text();
127
144
 
@@ -829,7 +846,21 @@ async function invokeOpenRouter(body, _incomingHeaders = {}) {
829
846
  }, "Sending tools to OpenRouter");
830
847
  }
831
848
 
832
- return performJsonRequest(endpoint, { headers, body: openRouterBody }, "OpenRouter");
849
+ // Same pre-return 429 check as invokeMoonshot/invokeBaidu — see the comment
850
+ // on invokeOpenAI's equivalent check for why this matters for streaming.
851
+ const response = await performJsonRequest(endpoint, {
852
+ headers,
853
+ body: openRouterBody,
854
+ retryableStatusesOverride: [500, 502, 503, 504],
855
+ }, "OpenRouter");
856
+
857
+ if (!response.ok && response.status === 429) {
858
+ const err = new Error(`OpenRouter rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
859
+ err.status = 429;
860
+ throw err;
861
+ }
862
+
863
+ return response;
833
864
  }
834
865
 
835
866
  // Eden AI is an OpenAI-compatible gateway (provider/model naming, EU/GDPR).
@@ -1085,7 +1116,7 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
1085
1116
  originalMessageCount: (body.messages || []).length,
1086
1117
  convertedMessageCount: azureBody.messages.length,
1087
1118
  messageRoles: (body.messages || []).map(m => m.role),
1088
- }, "=== CONTINUATION REQUEST DETECTED - using minimal system prompt to avoid Azure content filter ===");
1119
+ }, "Continuation request — system prompt PRESERVED, system-reminder blocks stripped uniformly (the filter trigger was the reminders, not the prompt; see system-message handling below)");
1089
1120
  } else {
1090
1121
  logger.debug({
1091
1122
  hasToolResults: false,
@@ -1229,17 +1260,34 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
1229
1260
  // the conversation's first user message — stable across every turn of
1230
1261
  // the same task. Keep per-key traffic under ~15 req/min.
1231
1262
  ...(isGpt5 ? { prompt_cache_key: derivePromptCacheKey(body) } : {}),
1232
- stream: false
1263
+ stream: body.stream ?? false
1233
1264
  };
1234
1265
  logger.debug({
1235
1266
  format: "responses",
1236
1267
  inputCount: responsesBody.input?.length,
1237
1268
  model: responsesBody.model,
1238
- hasTools: !!responsesBody.tools
1269
+ hasTools: !!responsesBody.tools,
1270
+ streaming: responsesBody.stream,
1239
1271
  }, "Using Responses API format");
1240
1272
 
1241
1273
  const result = await performJsonRequest(endpoint, { headers, body: responsesBody }, "Azure OpenAI Responses");
1242
1274
 
1275
+ // Streaming: hand back a synthetic OpenAI-Chat-Completions-shaped SSE
1276
+ // stream (see src/orchestrator/azure-responses-sse.js for why that shape
1277
+ // rather than Anthropic SSE directly) so the orchestrator's existing
1278
+ // sseTransform.openaiToAnthropicSSE call site — already wired for
1279
+ // moonshot/baidu/openai's raw streams — handles this one identically,
1280
+ // with zero changes needed there. Skips the whole buffered
1281
+ // output-array/dedup/usage-remap conversion below entirely; that logic
1282
+ // only makes sense for a complete, already-materialized response.
1283
+ if (result.stream) {
1284
+ const { azureResponsesToOpenAIChunks } = require("../orchestrator/azure-responses-sse");
1285
+ return {
1286
+ ...result,
1287
+ stream: azureResponsesToOpenAIChunks(result.stream, { model: responsesBody.model }),
1288
+ };
1289
+ }
1290
+
1243
1291
  // Convert Responses API response to Chat Completions format
1244
1292
  if (result.ok && result.json?.output) {
1245
1293
  const outputArray = result.json.output || [];
@@ -1306,6 +1354,8 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
1306
1354
  toolCallNames: toolCalls.map(tc => tc.function.name)
1307
1355
  }, "Parsing Responses API output");
1308
1356
 
1357
+ const _rawUsage = result.json.usage;
1358
+
1309
1359
  result.json = {
1310
1360
  id: result.json.id,
1311
1361
  object: "chat.completion",
@@ -1322,15 +1372,15 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) {
1322
1372
  }],
1323
1373
  // Responses API usage is {input,output}_tokens; downstream reads
1324
1374
  // Chat Completions' {prompt,completion}_tokens.
1325
- usage: result.json.usage ? {
1326
- prompt_tokens: result.json.usage.prompt_tokens ?? result.json.usage.input_tokens ?? 0,
1327
- completion_tokens: result.json.usage.completion_tokens ?? result.json.usage.output_tokens ?? 0,
1328
- total_tokens: result.json.usage.total_tokens
1329
- ?? ((result.json.usage.input_tokens ?? 0) + (result.json.usage.output_tokens ?? 0)),
1375
+ usage: _rawUsage ? {
1376
+ prompt_tokens: _rawUsage.prompt_tokens ?? _rawUsage.input_tokens ?? 0,
1377
+ completion_tokens: _rawUsage.completion_tokens ?? _rawUsage.output_tokens ?? 0,
1378
+ total_tokens: _rawUsage.total_tokens
1379
+ ?? ((_rawUsage.input_tokens ?? 0) + (_rawUsage.output_tokens ?? 0)),
1330
1380
  // Provider-side prompt-cache hits (Responses API: input_tokens_details,
1331
1381
  // Chat Completions: prompt_tokens_details) — telemetry reads this name.
1332
- cache_read_input_tokens: result.json.usage.input_tokens_details?.cached_tokens
1333
- ?? result.json.usage.prompt_tokens_details?.cached_tokens
1382
+ cache_read_input_tokens: _rawUsage.input_tokens_details?.cached_tokens
1383
+ ?? _rawUsage.prompt_tokens_details?.cached_tokens
1334
1384
  ?? null,
1335
1385
  } : undefined
1336
1386
  };
@@ -1437,7 +1487,23 @@ async function invokeOpenAI(body, _incomingHeaders = {}) {
1437
1487
  max_tokens: openAIBody.max_tokens,
1438
1488
  }, "=== OPENAI REQUEST ===");
1439
1489
 
1440
- return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI");
1490
+ // Same pre-return 429 check as invokeMoonshot/invokeBaidu: performJsonRequest's
1491
+ // streaming branch never throws on a bad status (retry is skipped entirely for
1492
+ // stream:true), so without this a 429 was silently handed back as a "successful"
1493
+ // stream/response and tier-fallback never got a chance to climb.
1494
+ const response = await performJsonRequest(endpoint, {
1495
+ headers,
1496
+ body: openAIBody,
1497
+ retryableStatusesOverride: [500, 502, 503, 504],
1498
+ }, "OpenAI");
1499
+
1500
+ if (!response.ok && response.status === 429) {
1501
+ const err = new Error(`OpenAI rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
1502
+ err.status = 429;
1503
+ throw err;
1504
+ }
1505
+
1506
+ return response;
1441
1507
  }
1442
1508
 
1443
1509
  async function invokeAtlas(body) {
@@ -1495,12 +1561,26 @@ async function invokeAtlas(body) {
1495
1561
 
1496
1562
  // Chat completions are billable POSTs, so Atlas requests are never replayed
1497
1563
  // automatically. Callers can retry explicitly with their own idempotency policy.
1498
- return performJsonRequest(endpoint, {
1564
+ // maxRetriesOverride/retryableStatusesOverride stay at 0/[] — this does NOT
1565
+ // retry the Atlas request itself, it only decides whether invokeModel's
1566
+ // caller escalates to a DIFFERENT provider/tier on 429, same as
1567
+ // invokeMoonshot/invokeBaidu/invokeOpenAI/invokeOpenRouter already do.
1568
+ // Without this, a 429 here was silently handed back as a "successful"
1569
+ // response and tier-fallback never got a chance to climb.
1570
+ const response = await performJsonRequest(endpoint, {
1499
1571
  headers,
1500
1572
  body: atlasBody,
1501
1573
  maxRetriesOverride: 0,
1502
1574
  retryableStatusesOverride: [],
1503
1575
  }, "Atlas Cloud");
1576
+
1577
+ if (!response.ok && response.status === 429) {
1578
+ const err = new Error(`Atlas Cloud rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
1579
+ err.status = 429;
1580
+ throw err;
1581
+ }
1582
+
1583
+ return response;
1504
1584
  }
1505
1585
 
1506
1586
  async function invokeLlamaCpp(body, _incomingHeaders = {}) {
@@ -1580,10 +1660,18 @@ async function invokeLlamaCpp(body, _incomingHeaders = {}) {
1580
1660
  }, 'llama.cpp: Removed consecutive duplicate roles from message sequence');
1581
1661
  }
1582
1662
 
1663
+ // max_tokens floor: reasoning-capable local models (live-confirmed on this
1664
+ // deployment's GPT-OSS build) can spend their entire budget on
1665
+ // `reasoning_content` before writing any visible answer — a caller-
1666
+ // supplied tiny budget (e.g. the caveman probe's max_tokens:1 seen in
1667
+ // production logs) then returns finish_reason:"length" with zero visible
1668
+ // text. Same fix already shipped for Ollama as LYNKR_OLLAMA_MIN_MAX_TOKENS;
1669
+ // mirrored here rather than inventing a different mechanism.
1670
+ const llamacppMinMaxTokens = Number(process.env.LYNKR_LLAMACPP_MIN_MAX_TOKENS) || 1536;
1583
1671
  const llamacppBody = {
1584
1672
  messages: deduplicated,
1585
1673
  temperature: body.temperature ?? 0.7,
1586
- max_tokens: body.max_tokens ?? 16384,
1674
+ max_tokens: Math.max(body.max_tokens ?? 16384, llamacppMinMaxTokens),
1587
1675
  top_p: body.top_p ?? 1.0,
1588
1676
  stream: body.stream ?? false
1589
1677
  };
@@ -1633,7 +1721,11 @@ async function invokeLlamaCpp(body, _incomingHeaders = {}) {
1633
1721
  }))
1634
1722
  }, "=== LLAMA.CPP REQUEST ===");
1635
1723
 
1636
- const result = await performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
1724
+ const result = await performJsonRequest(
1725
+ endpoint,
1726
+ { headers, body: llamacppBody, timeoutMs: config.llamacpp.timeout },
1727
+ "llama.cpp",
1728
+ );
1637
1729
 
1638
1730
  // Context overflow is a CAPACITY error, not a request error: the local
1639
1731
  // model's slot is simply too small for this conversation (llama-server
@@ -2063,11 +2155,23 @@ async function invokeZai(body, _incomingHeaders = {}) {
2063
2155
  zaiBody = { ...body };
2064
2156
  zaiBody.model = mappedModel;
2065
2157
 
2066
- // Force buffered mode: with stream:true this endpoint returns ANTHROPIC
2067
- // SSE, but the downstream transformer parses OPENAI SSE — every chunk is
2068
- // unreadable and the client receives an empty completion. Buffered JSON
2069
- // converts correctly; the router synthesizes client-side SSE as usual.
2070
- zaiBody.stream = false;
2158
+ // Honor body.stream as-is here. The ONLY caller that ever sets it true
2159
+ // for this branch is passthrough-stream.js's handleNativeStream — zai
2160
+ // isn't in sse-transformer.js's DEFAULT_OPENAI_SSE_PROVIDERS, so the
2161
+ // normal buffered orchestrator (orchestrator/index.js) always sets
2162
+ // cleanPayload.stream=false before invoking any provider not on that
2163
+ // allowlist, and never reaches this branch with stream:true itself.
2164
+ // Forcing false unconditionally (as this used to do) broke exactly that
2165
+ // caller: _anthropicNative() advertises this endpoint as native-passthrough
2166
+ // eligible, handleNativeStream sets stream:true and expects real
2167
+ // Anthropic SSE bytes back, but got a buffered JSON body instead —
2168
+ // handleNativeStream then saw no `.stream` field, silently gave up, and
2169
+ // fell back to the buffered orchestrator path, which called zai AGAIN.
2170
+ // Every native-passthrough request to this endpoint was double-invoking
2171
+ // the real API. If a future caller other than handleNativeStream ever
2172
+ // needs stream:true routed through the OpenAI-SSE transformer instead,
2173
+ // that's a different bug — add zai to DEFAULT_OPENAI_SSE_PROVIDERS then,
2174
+ // don't reintroduce this override.
2071
2175
 
2072
2176
  // Inject standard tools if client didn't send any (passthrough mode)
2073
2177
  if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) {
@@ -2222,12 +2326,18 @@ async function invokeMoonshot(body, _incomingHeaders = {}) {
2222
2326
  // Only the moonshot-v1-* models accept caller-supplied values.
2223
2327
  const isKimiPinned = /^kimi-k/i.test(mappedModel);
2224
2328
 
2329
+ const { resolveThinkingParam } = require("./provider-capabilities");
2225
2330
  const moonshotBody = {
2226
2331
  model: mappedModel,
2227
2332
  messages,
2228
2333
  max_tokens: body.max_tokens || 16384,
2229
2334
  temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
2230
2335
  top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
2336
+ // kimi-k3 emits verbose reasoning_content by default, sharing the same
2337
+ // token budget as the answer — without this, reasoning alone can (and
2338
+ // did, live-confirmed) consume the whole max_tokens before any answer
2339
+ // is written. See resolveThinkingParam's doc comment for the full story.
2340
+ thinking: resolveThinkingParam(body),
2231
2341
  // Streaming honored since the Phase-2b sse-transformer landed: the raw
2232
2342
  // OpenAI SSE stream is returned below and reshaped in flight by the
2233
2343
  // orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered
@@ -2371,12 +2481,18 @@ async function invokeBaidu(body, _incomingHeaders = {}) {
2371
2481
  messages.unshift({ role: "system", content: systemContent });
2372
2482
  }
2373
2483
 
2484
+ const { resolveThinkingParam } = require("./provider-capabilities");
2374
2485
  const baiduBody = {
2375
2486
  model: mappedModel,
2376
2487
  messages,
2377
2488
  max_tokens: body.max_tokens || 16384,
2378
2489
  temperature: body.temperature ?? 0.7,
2379
2490
  top_p: body.top_p ?? 1.0,
2491
+ // glm-5.2 emits verbose reasoning_content by default, sharing the same
2492
+ // token budget as the answer — without this, reasoning alone can (and
2493
+ // did, live-confirmed) consume the whole max_tokens before any answer
2494
+ // is written. See resolveThinkingParam's doc comment for the full story.
2495
+ thinking: resolveThinkingParam(body),
2380
2496
  // Streaming honored once "baidu" is added to DEFAULT_OPENAI_SSE_PROVIDERS
2381
2497
  // (sse-transformer.js) and confirmed to match OpenAI SSE shape. Buffered
2382
2498
  // requests use the Anthropic conversion path below regardless.
@@ -2436,6 +2552,147 @@ async function invokeBaidu(body, _incomingHeaders = {}) {
2436
2552
  return response;
2437
2553
  }
2438
2554
 
2555
+ /**
2556
+ * Fireworks AI Provider (serverless inference)
2557
+ *
2558
+ * Fireworks exposes an OpenAI-compatible Chat Completions API
2559
+ * (https://api.fireworks.ai/inference/v1/chat/completions, bearer-token
2560
+ * auth). Modeled on invokeBaidu: request side reuses the shared
2561
+ * openrouter-utils converters, response is converted to Anthropic shape
2562
+ * locally so the orchestrator branch is a plain passthrough.
2563
+ *
2564
+ * Model ids are long-form serverless paths
2565
+ * (accounts/fireworks/models/<slug>, accounts/fireworks/routers/<slug>).
2566
+ * The modelMap below covers bare Anthropic names; tier-selected Fireworks
2567
+ * ids (e.g. TIER_COMPLEX=fireworks:accounts/fireworks/models/glm-5p2)
2568
+ * reach the wire unchanged.
2569
+ *
2570
+ * NOTE (E2E-unverified as of this addition, same caveat Baidu shipped
2571
+ * with): sampling quirks, reasoning_content emission on reasoning models,
2572
+ * finish_reason shape on tool calls, and 429 retryability are best-effort
2573
+ * from public docs, not yet probed against a live key. Revisit once real
2574
+ * traffic surfaces 400s — see Moonshot's kimi-k* precedent for the shape
2575
+ * such fixes take.
2576
+ */
2577
+ async function invokeFireworks(body, _incomingHeaders = {}) {
2578
+ if (!config.fireworks?.apiKey) {
2579
+ throw new Error("Fireworks API key is not configured. Set FIREWORKS_API_KEY in your .env file.");
2580
+ }
2581
+
2582
+ const {
2583
+ convertAnthropicToolsToOpenRouter,
2584
+ convertAnthropicMessagesToOpenRouter
2585
+ } = require("./openrouter-utils");
2586
+
2587
+ const endpoint = config.fireworks.endpoint || "https://api.fireworks.ai/inference/v1/chat/completions";
2588
+
2589
+ // Model mapping: Anthropic names → Fireworks serverless ids.
2590
+ const modelMap = {
2591
+ "claude-sonnet-4-5-20250929": "accounts/fireworks/models/kimi-k2-instruct-0905",
2592
+ "claude-sonnet-4-5": "accounts/fireworks/models/kimi-k2-instruct-0905",
2593
+ "claude-sonnet-4.5": "accounts/fireworks/models/kimi-k2-instruct-0905",
2594
+ "claude-3-5-sonnet": "accounts/fireworks/models/kimi-k2-instruct-0905",
2595
+ "claude-opus-4-5": "accounts/fireworks/models/glm-5p2",
2596
+ "claude-opus-4.5": "accounts/fireworks/models/glm-5p2",
2597
+ "claude-3-opus": "accounts/fireworks/models/glm-5p2",
2598
+ "claude-haiku-4-5-20251001": "accounts/fireworks/models/llama-3.1-8b-instruct",
2599
+ "claude-haiku-4-5": "accounts/fireworks/models/llama-3.1-8b-instruct",
2600
+ "claude-3-haiku": "accounts/fireworks/models/llama-3.1-8b-instruct",
2601
+ };
2602
+
2603
+ const requestedModel = body._tierModel || body.model || config.fireworks.model;
2604
+
2605
+ // Honor tier-selected Fireworks ids instead of silently swapping in the
2606
+ // .env default model. Accepts full serverless paths as well as well-known
2607
+ // open-weight family slugs hosted on Fireworks.
2608
+ const FIREWORKS_ID_RE = /^(accounts\/|kimi-|glm-|deepseek-|qwen|llama-|mistral-|mixtral-|phi-|gemma-|grok-|solar-|yi-|fireworks-)/i;
2609
+ const mappedModel = modelMap[requestedModel]
2610
+ || (FIREWORKS_ID_RE.test(requestedModel || "") ? requestedModel : null)
2611
+ || config.fireworks.model
2612
+ || "accounts/fireworks/models/kimi-k2-instruct-0905";
2613
+
2614
+ const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
2615
+
2616
+ // Fireworks supports the system role natively.
2617
+ if (body.system) {
2618
+ const systemContent = Array.isArray(body.system)
2619
+ ? body.system.map(s => s.text || s).join("\n")
2620
+ : body.system;
2621
+ messages.unshift({ role: "system", content: systemContent });
2622
+ }
2623
+
2624
+ const { resolveThinkingParam } = require("./provider-capabilities");
2625
+ const fireworksBody = {
2626
+ model: mappedModel,
2627
+ messages,
2628
+ max_tokens: body.max_tokens || 16384,
2629
+ temperature: body.temperature ?? 0.7,
2630
+ top_p: body.top_p ?? 1.0,
2631
+ // Reasoning models (R1/GLM) share the answer budget with thinking output;
2632
+ // the buffered path lifts reasoning_content into thinking blocks.
2633
+ thinking: resolveThinkingParam(body),
2634
+ // Streaming honored once "fireworks" joins DEFAULT_OPENAI_SSE_PROVIDERS
2635
+ // (sse-transformer.js). Buffered requests use the Anthropic conversion
2636
+ // path below regardless.
2637
+ stream: body.stream ?? false,
2638
+ };
2639
+
2640
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
2641
+ fireworksBody.tools = convertAnthropicToolsToOpenRouter(body.tools);
2642
+ fireworksBody.tool_choice = "auto";
2643
+ fireworksBody.parallel_tool_calls = false;
2644
+ }
2645
+
2646
+ const headers = {
2647
+ "Content-Type": "application/json",
2648
+ "Authorization": `Bearer ${config.fireworks.apiKey}`,
2649
+ };
2650
+
2651
+ logger.debug({
2652
+ endpoint,
2653
+ model: fireworksBody.model,
2654
+ originalModel: requestedModel,
2655
+ messageCount: fireworksBody.messages?.length || 0,
2656
+ hasTools: !!fireworksBody.tools,
2657
+ toolCount: fireworksBody.tools?.length || 0,
2658
+ }, "=== Fireworks REQUEST ===");
2659
+
2660
+ // No retryableStatusesOverride: Fireworks serverless 429s are standard
2661
+ // per-minute rate limits (unlike Moonshot's persistent org quotas), so the
2662
+ // default retry-with-backoff applies. A 429 that survives retries throws
2663
+ // below with status set so tier-fallback climbs instead of hanging.
2664
+ const response = await performJsonRequest(endpoint, {
2665
+ headers,
2666
+ body: fireworksBody,
2667
+ }, "Fireworks");
2668
+
2669
+ if (!response.ok && response.status === 429) {
2670
+ const err = new Error(`Fireworks rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`);
2671
+ err.status = 429;
2672
+ throw err;
2673
+ }
2674
+
2675
+ // Streaming request: hand the raw stream to the orchestrator's stream
2676
+ // branch. The Anthropic conversion below is buffered-only.
2677
+ if (response?.stream) {
2678
+ return response;
2679
+ }
2680
+
2681
+ if (response?.ok && response?.json) {
2682
+ const anthropicJson = convertOpenAIToAnthropic(response.json);
2683
+ return {
2684
+ ok: response.ok,
2685
+ status: response.status,
2686
+ json: anthropicJson,
2687
+ text: JSON.stringify(anthropicJson),
2688
+ contentType: "application/json",
2689
+ headers: response.headers,
2690
+ };
2691
+ }
2692
+
2693
+ return response;
2694
+ }
2695
+
2439
2696
  /**
2440
2697
  * Convert OpenAI response to Anthropic format
2441
2698
  */
@@ -3028,6 +3285,7 @@ const PROVIDER_INVOKERS = {
3028
3285
  moonshot: invokeMoonshot,
3029
3286
  codex: invokeCodex,
3030
3287
  baidu: invokeBaidu,
3288
+ fireworks: invokeFireworks,
3031
3289
  };
3032
3290
 
3033
3291
  function invokeProvider(provider, body, incomingHeaders) {
@@ -3289,6 +3547,7 @@ async function invokeModel(body, options = {}) {
3289
3547
  base_tier: routingResult.base_tier ?? null,
3290
3548
  escalation_source: routingResult.escalation_source ?? null,
3291
3549
  propensity: routingResult.propensity ?? null,
3550
+ context: routingResult._banditContext ?? null,
3292
3551
  candidates: routingResult.candidates ?? null,
3293
3552
  pinned: routingResult.pinned ? 1 : 0,
3294
3553
  switch_reason: routingResult.switch_reason ?? null,
@@ -3523,6 +3782,7 @@ async function invokeModel(body, options = {}) {
3523
3782
  base_tier: routingResult.base_tier ?? null,
3524
3783
  escalation_source: routingResult.escalation_source ?? null,
3525
3784
  propensity: routingResult.propensity ?? null,
3785
+ context: routingResult._banditContext ?? null,
3526
3786
  candidates: routingResult.candidates ?? null,
3527
3787
  pinned: routingResult.pinned ? 1 : 0,
3528
3788
  switch_reason: routingResult.switch_reason ?? null,
@@ -3635,6 +3895,7 @@ async function invokeModel(body, options = {}) {
3635
3895
  base_tier: routingResult.base_tier ?? null,
3636
3896
  escalation_source: routingResult.escalation_source ?? null,
3637
3897
  propensity: routingResult.propensity ?? null,
3898
+ context: routingResult._banditContext ?? null,
3638
3899
  candidates: routingResult.candidates ?? null,
3639
3900
  pinned: routingResult.pinned ? 1 : 0,
3640
3901
  switch_reason: routingResult.switch_reason ?? null,
@@ -3731,6 +3992,7 @@ async function invokeModel(body, options = {}) {
3731
3992
  base_tier: routingResult.base_tier ?? null,
3732
3993
  escalation_source: routingResult.escalation_source ?? null,
3733
3994
  propensity: routingResult.propensity ?? null,
3995
+ context: routingResult._banditContext ?? null,
3734
3996
  candidates: routingResult.candidates ?? null,
3735
3997
  pinned: routingResult.pinned ? 1 : 0,
3736
3998
  switch_reason: routingResult.switch_reason ?? null,
@@ -3791,6 +4053,7 @@ async function invokeModel(body, options = {}) {
3791
4053
  base_tier: routingResult.base_tier ?? null,
3792
4054
  escalation_source: routingResult.escalation_source ?? null,
3793
4055
  propensity: routingResult.propensity ?? null,
4056
+ context: routingResult._banditContext ?? null,
3794
4057
  candidates: routingResult.candidates ?? null,
3795
4058
  pinned: routingResult.pinned ? 1 : 0,
3796
4059
  switch_reason: routingResult.switch_reason ?? null,
@@ -3892,6 +4155,7 @@ module.exports = {
3892
4155
  invokeOllama,
3893
4156
  invokeMoonshot,
3894
4157
  invokeBaidu,
4158
+ invokeFireworks,
3895
4159
  invokeAtlas,
3896
4160
  PROVIDER_INVOKERS,
3897
4161
  stripLynkrBadges,
@@ -38,6 +38,45 @@ function stringSimilarity(s1, s2) {
38
38
  return union.size > 0 ? intersection.size / union.size : 0;
39
39
  }
40
40
 
41
+ // Common argument keys a Read-style tool might carry its target path under,
42
+ // across different clients/harnesses.
43
+ const FILE_PATH_ARG_KEYS = ['file_path', 'path', 'filePath', 'filename', 'file'];
44
+
45
+ /**
46
+ * Pull a file-path argument out of a (possibly stringified) args object,
47
+ * trying the common key spellings different clients use.
48
+ * @param {string|Object} args
49
+ * @returns {string|null}
50
+ */
51
+ function extractFilePathArg(args) {
52
+ let obj = args;
53
+ if (typeof obj === 'string') {
54
+ try { obj = JSON.parse(obj); } catch { return null; }
55
+ }
56
+ if (!obj || typeof obj !== 'object') return null;
57
+ for (const key of FILE_PATH_ARG_KEYS) {
58
+ if (typeof obj[key] === 'string' && obj[key]) return obj[key];
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Extract a read window [start, end) from read-tool args (offset/limit in
65
+ * lines, the convention Claude Code, opencode, and Cursor all share).
66
+ * Missing offset → 0; missing limit → open-ended (whole rest of the file).
67
+ * @param {string|Object} args
68
+ * @returns {{ start: number, end: number }}
69
+ */
70
+ function extractReadWindow(args) {
71
+ let obj = args;
72
+ if (typeof obj === 'string') {
73
+ try { obj = JSON.parse(obj); } catch { obj = null; }
74
+ }
75
+ const start = Number.isFinite(obj?.offset) ? obj.offset : 0;
76
+ const limit = Number.isFinite(obj?.limit) && obj.limit > 0 ? obj.limit : Infinity;
77
+ return { start, end: start + limit };
78
+ }
79
+
41
80
  /**
42
81
  * Check if two tool calls are semantically similar
43
82
  * @param {Object} call1 - First tool call {name, arguments}
@@ -59,17 +98,53 @@ function areSimilarToolCalls(call1, call2) {
59
98
 
60
99
  if (argsStr1 === argsStr2) return true;
61
100
 
101
+ const toolName = (name1 || '').toLowerCase();
102
+
103
+ // Read-style tools: NOT Jaccard-fuzzy-matched (see below for why), and NOT
104
+ // matched on path alone either. This rule has flip-flopped through two live
105
+ // incidents, so the history matters:
106
+ //
107
+ // 1. Jaccard on paths merged reads of DIFFERENT files (repo paths share
108
+ // nearly every token) → fixed by exact path equality.
109
+ // 2. Exact-args-only let an agent re-read one file at OVERLAPPING offsets
110
+ // ~10 times, restating the same conclusion, without ever tripping the
111
+ // guard → fixed by counting any same-path read as similar.
112
+ // 3. That overcorrected: paging through DISJOINT regions of one large
113
+ // file is exactly how a coding agent works on it (live incident: a
114
+ // 4,000-line settings.rs read at offsets 975/3485/3800 tripped the
115
+ // warn threshold, and the injected "STOP calling this tool" order
116
+ // killed the work turn — repeatedly).
117
+ //
118
+ // The rule that satisfies all three: same file + OVERLAPPING (or identical)
119
+ // windows → similar (re-reading content you already have); same file +
120
+ // DISJOINT windows → paging, not a loop. Whole-file reads (no offset/limit)
121
+ // have an open-ended window, so they overlap everything in that file —
122
+ // repeated whole-file re-reads still count, incident #2 stays fixed.
123
+ if (toolName.includes('read')) {
124
+ const path1 = extractFilePathArg(args1);
125
+ const path2 = extractFilePathArg(args2);
126
+ if (path1 && path2 && path1 === path2) {
127
+ const w1 = extractReadWindow(args1);
128
+ const w2 = extractReadWindow(args2);
129
+ const overlaps = w1.start < w2.end && w2.start < w1.end;
130
+ if (overlaps) {
131
+ logger.debug({ tool: name1, path: path1, w1, w2 }, "Same-file overlapping re-read detected");
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+ return false;
137
+ }
138
+
62
139
  // Only search-style tools get fuzzy matching; mutating tools with
63
140
  // near-identical args may be intentional repeats.
64
- // 'read' is deliberately NOT fuzzy-matched: its argument is a file path, and
65
- // absolute paths in one repo share nearly every Jaccard token
66
- // (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8 and
67
- // merged into one "repeated call" signature (live incident: an opencode
141
+ // 'read' is handled above, deliberately NOT via Jaccard: its argument is a
142
+ // file path, and absolute paths in one repo share nearly every Jaccard
143
+ // token (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8
144
+ // and merged into one "repeated call" signature (live incident: an opencode
68
145
  // code-trace reading server.js, openai-router.js and orchestrator/index.js
69
- // was flagged as a loop). A genuine re-read of the same file is caught by
70
- // the exact-match branch above.
146
+ // was flagged as a loop).
71
147
  const searchTools = ['grep', 'glob', 'search', 'find', 'bash', 'shell'];
72
- const toolName = (name1 || '').toLowerCase();
73
148
  const isSearchTool = searchTools.some(t => toolName.includes(t));
74
149
 
75
150
  if (isSearchTool) {
@@ -91,4 +166,5 @@ function areSimilarToolCalls(call1, call2) {
91
166
 
92
167
  module.exports = {
93
168
  areSimilarToolCalls,
169
+ extractReadWindow,
94
170
  };