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.
- package/README.md +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +10 -4
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/openai-router.js +125 -4
- package/src/api/providers-handler.js +16 -0
- package/src/api/router.js +67 -103
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/clients/databricks.js +285 -21
- package/src/clients/gpt-utils.js +83 -7
- package/src/clients/health-probe.js +157 -0
- package/src/clients/provider-capabilities.js +27 -0
- package/src/config/index.js +13 -1
- package/src/context/compression.js +127 -19
- package/src/dashboard/api.js +1 -0
- package/src/observability/metrics.js +52 -0
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +219 -136
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +62 -3
- package/src/routing/bandit.js +39 -0
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +37 -0
- package/src/routing/index.js +202 -86
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- 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
|
|
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
|
-
}, "
|
|
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:
|
|
1326
|
-
prompt_tokens:
|
|
1327
|
-
completion_tokens:
|
|
1328
|
-
total_tokens:
|
|
1329
|
-
?? ((
|
|
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:
|
|
1333
|
-
??
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
//
|
|
2070
|
-
|
|
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,
|
package/src/clients/gpt-utils.js
CHANGED
|
@@ -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
|
|
65
|
-
// absolute paths in one repo share nearly every Jaccard
|
|
66
|
-
// (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8
|
|
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).
|
|
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
|
};
|