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
package/src/api/openai-router.js
CHANGED
|
@@ -28,6 +28,32 @@ const clientProfiles = require("../routing/client-profiles");
|
|
|
28
28
|
|
|
29
29
|
const router = express.Router();
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Served-model decision headers for the OpenAI-compat ingress.
|
|
33
|
+
*
|
|
34
|
+
* The Anthropic ingress has carried X-Lynkr-* decision headers for a long
|
|
35
|
+
* time; this path never did — the same single-ingress gap as issue #100.
|
|
36
|
+
* X-Lynkr-Context-Window is the served model's REAL context window (the
|
|
37
|
+
* workweave pattern): behind a virtual model name, tier routing serves
|
|
38
|
+
* models with different windows per request, and this header is the
|
|
39
|
+
* client's authoritative per-turn compaction budget. Omitted (never
|
|
40
|
+
* guessed) for models the registry doesn't know.
|
|
41
|
+
*
|
|
42
|
+
* Safe on streaming paths: no-op once headers have flushed.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} res - Express response
|
|
45
|
+
* @param {string|null} servedModel - model from the orchestrator result body
|
|
46
|
+
*/
|
|
47
|
+
function setServedModelHeaders(res, servedModel) {
|
|
48
|
+
if (!servedModel || res.headersSent) return;
|
|
49
|
+
try {
|
|
50
|
+
res.set("X-Lynkr-Model", servedModel);
|
|
51
|
+
const { contextWindowFor } = require("../routing/model-registry");
|
|
52
|
+
const contextWindow = contextWindowFor(servedModel);
|
|
53
|
+
if (contextWindow) res.set("X-Lynkr-Context-Window", String(contextWindow));
|
|
54
|
+
} catch { /* headers are best-effort — never fail a response over them */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
/**
|
|
32
58
|
* Resolve the model name for OpenAI responses.
|
|
33
59
|
* In OpenClaw mode, returns the actual provider/model from routing metadata.
|
|
@@ -568,6 +594,35 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
568
594
|
logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed");
|
|
569
595
|
}
|
|
570
596
|
|
|
597
|
+
// Explicit model/effort pin: Codex's model picker (or any OpenAI-format
|
|
598
|
+
// caller) can name a real OpenAI model + reasoning.effort combo that
|
|
599
|
+
// resolveTierForOpenAIModel recognizes. Mirrors router.js's model-id-pin
|
|
600
|
+
// block for the Anthropic /v1/messages route — this one is needed
|
|
601
|
+
// separately because these OpenAI-shaped handlers call
|
|
602
|
+
// orchestrator.processMessage() directly and never go through router.js's
|
|
603
|
+
// pin/classifier code at all. See ../routing/openai-model-slots.js.
|
|
604
|
+
try {
|
|
605
|
+
const { resolveTierForOpenAIModel } = require("../routing/openai-model-slots");
|
|
606
|
+
const _effort = req.body?.reasoning?.effort ?? req.body?.reasoning_effort ?? null;
|
|
607
|
+
const _pinTier = resolveTierForOpenAIModel(req.body?.model, _effort);
|
|
608
|
+
if (_pinTier) {
|
|
609
|
+
const { getModelTierSelector } = require("../routing");
|
|
610
|
+
const _sel = getModelTierSelector().selectModel(_pinTier, null);
|
|
611
|
+
anthropicRequest._forceProvider = _sel.provider;
|
|
612
|
+
if (_sel.model) anthropicRequest._tierModel = _sel.model;
|
|
613
|
+
anthropicRequest._tierName = _pinTier;
|
|
614
|
+
anthropicRequest._forcedMethod = "openai_model_effort_pin";
|
|
615
|
+
logger.debug({
|
|
616
|
+
model: req.body?.model,
|
|
617
|
+
effort: _effort,
|
|
618
|
+
tier: _pinTier,
|
|
619
|
+
provider: _sel.provider,
|
|
620
|
+
}, "[OpenAI Router] Explicit model/effort pin — scoring bypassed");
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
logger.debug({ err: err.message }, "[OpenAI Router] model/effort pin failed");
|
|
624
|
+
}
|
|
625
|
+
|
|
571
626
|
const session = getSession(sessionId);
|
|
572
627
|
|
|
573
628
|
if (req.body.stream) {
|
|
@@ -591,9 +646,11 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
591
646
|
session: session,
|
|
592
647
|
options: {
|
|
593
648
|
maxSteps: req.body?.max_steps,
|
|
594
|
-
clientWantsStream: true
|
|
649
|
+
clientWantsStream: true,
|
|
650
|
+
tenantPolicy: res.locals?.tenantPolicy || null
|
|
595
651
|
}
|
|
596
652
|
});
|
|
653
|
+
setServedModelHeaders(res, result?.body?.model);
|
|
597
654
|
|
|
598
655
|
// Phase 2b live stream: reshape Anthropic SSE events into OpenAI
|
|
599
656
|
// chat.completion.chunk events in flight — first token reaches the
|
|
@@ -768,6 +825,25 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
768
825
|
|
|
769
826
|
logger.debug({ chunk: "finish", finishReason: openaiResponse.choices[0].finish_reason }, "Sending finish chunk");
|
|
770
827
|
res.write(`data: ${JSON.stringify(finishChunk)}\n\n`);
|
|
828
|
+
|
|
829
|
+
// Trailing usage chunk (choices: [], usage populated) — same shape
|
|
830
|
+
// as the Phase 2b live-stream path above. Previously this buffered
|
|
831
|
+
// synthesis only ever logged openaiResponse.usage (see below) and
|
|
832
|
+
// never wrote it to the client, so streaming clients on this path
|
|
833
|
+
// saw zero token/context usage too.
|
|
834
|
+
if (openaiResponse.usage) {
|
|
835
|
+
const usageChunk = {
|
|
836
|
+
id: openaiResponse.id,
|
|
837
|
+
object: "chat.completion.chunk",
|
|
838
|
+
created: openaiResponse.created,
|
|
839
|
+
model: streamModel,
|
|
840
|
+
system_fingerprint: "fp_lynkr",
|
|
841
|
+
choices: [],
|
|
842
|
+
usage: openaiResponse.usage,
|
|
843
|
+
};
|
|
844
|
+
res.write(`data: ${JSON.stringify(usageChunk)}\n\n`);
|
|
845
|
+
}
|
|
846
|
+
|
|
771
847
|
res.write("data: [DONE]\n\n");
|
|
772
848
|
|
|
773
849
|
logger.debug({ contentLength: content.length, contentPreview: content.substring(0, 50) }, "=== SSE STREAM COMPLETE ===");
|
|
@@ -811,9 +887,11 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
811
887
|
headers: req.headers,
|
|
812
888
|
session: session,
|
|
813
889
|
options: {
|
|
814
|
-
maxSteps: req.body?.max_steps
|
|
890
|
+
maxSteps: req.body?.max_steps,
|
|
891
|
+
tenantPolicy: res.locals?.tenantPolicy || null
|
|
815
892
|
}
|
|
816
893
|
});
|
|
894
|
+
setServedModelHeaders(res, result?.body?.model);
|
|
817
895
|
|
|
818
896
|
logger.debug({
|
|
819
897
|
resultKeys: Object.keys(result || {}),
|
|
@@ -1065,6 +1143,19 @@ function getConfiguredProviders() {
|
|
|
1065
1143
|
});
|
|
1066
1144
|
}
|
|
1067
1145
|
|
|
1146
|
+
if (config.fireworks?.apiKey) {
|
|
1147
|
+
providers.push({
|
|
1148
|
+
name: "fireworks",
|
|
1149
|
+
type: "fireworks-ai",
|
|
1150
|
+
models: [
|
|
1151
|
+
config.fireworks.model || "accounts/fireworks/models/kimi-k2-instruct-0905",
|
|
1152
|
+
"accounts/fireworks/models/kimi-k2-instruct-0905",
|
|
1153
|
+
"accounts/fireworks/models/glm-5p2",
|
|
1154
|
+
"accounts/fireworks/models/deepseek-v3p1"
|
|
1155
|
+
]
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1068
1159
|
if (config.vertex?.projectId) {
|
|
1069
1160
|
providers.push({
|
|
1070
1161
|
name: "vertex",
|
|
@@ -1831,6 +1922,32 @@ router.post("/responses", async (req, res) => {
|
|
|
1831
1922
|
logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed");
|
|
1832
1923
|
}
|
|
1833
1924
|
|
|
1925
|
+
// Explicit model/effort pin — see the identical block in /chat/completions
|
|
1926
|
+
// above for the full rationale. This is the route Codex Desktop actually
|
|
1927
|
+
// uses (wire_api="responses"), so this is the one that matters for its
|
|
1928
|
+
// model picker specifically.
|
|
1929
|
+
try {
|
|
1930
|
+
const { resolveTierForOpenAIModel } = require("../routing/openai-model-slots");
|
|
1931
|
+
const _effort = req.body?.reasoning?.effort ?? req.body?.reasoning_effort ?? null;
|
|
1932
|
+
const _pinTier = resolveTierForOpenAIModel(req.body?.model, _effort);
|
|
1933
|
+
if (_pinTier) {
|
|
1934
|
+
const { getModelTierSelector } = require("../routing");
|
|
1935
|
+
const _sel = getModelTierSelector().selectModel(_pinTier, null);
|
|
1936
|
+
anthropicRequest._forceProvider = _sel.provider;
|
|
1937
|
+
if (_sel.model) anthropicRequest._tierModel = _sel.model;
|
|
1938
|
+
anthropicRequest._tierName = _pinTier;
|
|
1939
|
+
anthropicRequest._forcedMethod = "openai_model_effort_pin";
|
|
1940
|
+
logger.debug({
|
|
1941
|
+
model: req.body?.model,
|
|
1942
|
+
effort: _effort,
|
|
1943
|
+
tier: _pinTier,
|
|
1944
|
+
provider: _sel.provider,
|
|
1945
|
+
}, "[OpenAI Router] Explicit model/effort pin (responses) — scoring bypassed");
|
|
1946
|
+
}
|
|
1947
|
+
} catch (err) {
|
|
1948
|
+
logger.debug({ err: err.message }, "[OpenAI Router] model/effort pin failed (responses)");
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1834
1951
|
const session = getSession(sessionId);
|
|
1835
1952
|
|
|
1836
1953
|
if (req.body.stream) {
|
|
@@ -1855,9 +1972,11 @@ router.post("/responses", async (req, res) => {
|
|
|
1855
1972
|
headers: req.headers,
|
|
1856
1973
|
session: session,
|
|
1857
1974
|
options: {
|
|
1858
|
-
maxSteps: req.body?.max_steps
|
|
1975
|
+
maxSteps: req.body?.max_steps,
|
|
1976
|
+
tenantPolicy: res.locals?.tenantPolicy || null
|
|
1859
1977
|
}
|
|
1860
1978
|
});
|
|
1979
|
+
setServedModelHeaders(res, result?.body?.model);
|
|
1861
1980
|
} finally {
|
|
1862
1981
|
clearInterval(keepalive);
|
|
1863
1982
|
}
|
|
@@ -2192,9 +2311,11 @@ router.post("/responses", async (req, res) => {
|
|
|
2192
2311
|
headers: req.headers,
|
|
2193
2312
|
session: session,
|
|
2194
2313
|
options: {
|
|
2195
|
-
maxSteps: req.body?.max_steps
|
|
2314
|
+
maxSteps: req.body?.max_steps,
|
|
2315
|
+
tenantPolicy: res.locals?.tenantPolicy || null
|
|
2196
2316
|
}
|
|
2197
2317
|
});
|
|
2318
|
+
setServedModelHeaders(res, result?.body?.model);
|
|
2198
2319
|
|
|
2199
2320
|
const chatResponse = convertAnthropicToOpenAI(result.body, resolveResponseModel(result.body, req.body.model));
|
|
2200
2321
|
const responsesResponse = convertChatToResponses(chatResponse);
|
|
@@ -237,6 +237,22 @@ function getConfiguredProviders() {
|
|
|
237
237
|
});
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// Check Fireworks AI (serverless open models)
|
|
241
|
+
if (config.fireworks?.apiKey) {
|
|
242
|
+
providers.push({
|
|
243
|
+
name: "fireworks",
|
|
244
|
+
type: "fireworks-ai",
|
|
245
|
+
baseUrl: config.fireworks.endpoint || "https://api.fireworks.ai/inference/v1",
|
|
246
|
+
enabled: true,
|
|
247
|
+
models: [
|
|
248
|
+
{ id: config.fireworks.model || "accounts/fireworks/models/kimi-k2-instruct-0905", name: "Configured Model" },
|
|
249
|
+
{ id: "accounts/fireworks/models/kimi-k2-instruct-0905", name: "Kimi K2 Instruct" },
|
|
250
|
+
{ id: "accounts/fireworks/models/glm-5p2", name: "GLM 5.2" },
|
|
251
|
+
{ id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1" },
|
|
252
|
+
]
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
240
256
|
// Check Vertex AI (Google Cloud)
|
|
241
257
|
if (config.vertex?.projectId) {
|
|
242
258
|
const region = config.vertex.region || "us-east5";
|
package/src/api/router.js
CHANGED
|
@@ -7,7 +7,9 @@ const logger = require("../logger");
|
|
|
7
7
|
const { createRateLimiter } = require("./middleware/rate-limiter");
|
|
8
8
|
const openaiRouter = require("./openai-router");
|
|
9
9
|
const providersRouter = require("./providers-handler");
|
|
10
|
+
const claudeDesktopGatewayRouter = require("./claude-desktop-gateway");
|
|
10
11
|
const { getRoutingHeaders, getRoutingStats, analyzeComplexity, getModelTierSelector, analyzeRisk, checkSessionPin, writeSessionPin, checkPinScoreDrift } = require("../routing");
|
|
12
|
+
const { resolveTierForModelId } = require("../routing/model-slots");
|
|
11
13
|
|
|
12
14
|
// Upstream streams can die without a clean end (reader.read() never
|
|
13
15
|
// resolves on a dropped socket), hanging the client forever. Every
|
|
@@ -36,7 +38,7 @@ const { detectClient } = require("../routing/client-profiles");
|
|
|
36
38
|
const { buildInteractionBlock } = require("../routing/interaction");
|
|
37
39
|
const { validateCwd } = require("../workspace");
|
|
38
40
|
const { renderText } = require("../utils/markdown-ansi");
|
|
39
|
-
const { classifyAuthMode } = require("../auth-mode");
|
|
41
|
+
const { classifyAuthMode, isFirstPartyAnthropicClient } = require("../auth-mode");
|
|
40
42
|
|
|
41
43
|
const router = express.Router();
|
|
42
44
|
|
|
@@ -306,21 +308,16 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
306
308
|
const upstream = process.env.LYNKR_OAUTH_PASSTHROUGH_URL
|
|
307
309
|
|| "https://api.anthropic.com/v1/messages";
|
|
308
310
|
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
311
|
+
// NO body mutation on the passthrough — deliberate policy (2026-09). The
|
|
312
|
+
// memory-injection option that lived here (LYNKR_OAUTH_MEMORY_INJECTION)
|
|
313
|
+
// was removed: this function's whole contract is byte-for-byte fidelity
|
|
314
|
+
// to what the first-party client sent, and any mutation of subscription
|
|
315
|
+
// traffic both breaks that claim and carries anti-abuse risk. Memory
|
|
316
|
+
// injection remains available on the orchestrator paths, where Lynkr is
|
|
317
|
+
// openly transforming requests. The only change made below is stripping
|
|
318
|
+
// Lynkr-internal underscore-prefixed fields, which Anthropic would reject
|
|
319
|
+
// as unknown keys — protocol necessity, not content mutation.
|
|
315
320
|
let bodyToSend = req.body;
|
|
316
|
-
if (process.env.LYNKR_OAUTH_MEMORY_INJECTION === 'true' && config.memory?.enabled !== false) {
|
|
317
|
-
try {
|
|
318
|
-
bodyToSend = maybeInjectMemoryIntoUserTail(req.body);
|
|
319
|
-
} catch (err) {
|
|
320
|
-
logger.debug({ err: err.message }, "Memory injection skipped (non-fatal)");
|
|
321
|
-
bodyToSend = req.body;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
321
|
|
|
325
322
|
// === Observability: start ===
|
|
326
323
|
const startedAt = Date.now();
|
|
@@ -390,8 +387,13 @@ async function handleOauthPassthrough(req, res, opts = {}) {
|
|
|
390
387
|
// Lynkr's own decision headers so callers can see which model answered.
|
|
391
388
|
res.set("X-Lynkr-Provider", "azure-anthropic-passthrough");
|
|
392
389
|
if (opts.tier?.tier) res.set("X-Lynkr-Tier", opts.tier.tier);
|
|
393
|
-
if (req.body?.model)
|
|
394
|
-
|
|
390
|
+
if (req.body?.model) {
|
|
391
|
+
res.set("X-Lynkr-Model", req.body.model);
|
|
392
|
+
const { contextWindowFor } = require("../routing/model-registry");
|
|
393
|
+
const contextWindow = contextWindowFor(req.body.model);
|
|
394
|
+
if (contextWindow) res.set("X-Lynkr-Context-Window", String(contextWindow));
|
|
395
|
+
}
|
|
396
|
+
res.set("X-Lynkr-Routing-Method", "oauth-subscription-passthrough");
|
|
395
397
|
|
|
396
398
|
// Capture the response (buffered or streamed) so we can do observability hooks
|
|
397
399
|
// on the way back without changing what the client sees.
|
|
@@ -730,86 +732,8 @@ function extractAnthropicMessageFromSSE(sseText) {
|
|
|
730
732
|
return result;
|
|
731
733
|
}
|
|
732
734
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
*
|
|
736
|
-
* Headroom's P0-1 pattern (`_append_context_to_latest_non_frozen_user_turn`).
|
|
737
|
-
* The cache hot zone (system + frozen prefix) is NEVER touched. Mutating only
|
|
738
|
-
* the latest user message — which is the request's "live zone" — keeps the
|
|
739
|
-
* prompt-cache identity stable and avoids Anthropic anti-abuse fingerprint
|
|
740
|
-
* divergence for subscription tokens.
|
|
741
|
-
*
|
|
742
|
-
* Returns the body unchanged if:
|
|
743
|
-
* - Memory is disabled
|
|
744
|
-
* - No memories retrieved
|
|
745
|
-
* - Latest message is not a user turn (could be tool_result, assistant)
|
|
746
|
-
*
|
|
747
|
-
* Returns a new body with appended context otherwise. Original body never
|
|
748
|
-
* mutated (returns a shallow-cloned messages array).
|
|
749
|
-
*/
|
|
750
|
-
function maybeInjectMemoryIntoUserTail(body) {
|
|
751
|
-
if (!body || !Array.isArray(body.messages) || body.messages.length === 0) return body;
|
|
752
|
-
|
|
753
|
-
const lastIdx = body.messages.length - 1;
|
|
754
|
-
const lastMsg = body.messages[lastIdx];
|
|
755
|
-
if (!lastMsg || lastMsg.role !== "user") return body;
|
|
756
|
-
|
|
757
|
-
const { retrieveRelevantMemories, formatMemoriesForContext, extractQueryFromMessage } =
|
|
758
|
-
require("../memory/retriever");
|
|
759
|
-
const query = extractQueryFromMessage(lastMsg);
|
|
760
|
-
if (!query || query.length < 10) return body; // too short to be a useful query
|
|
761
|
-
|
|
762
|
-
const memories = retrieveRelevantMemories(query, {
|
|
763
|
-
limit: Math.min(parseInt(process.env.MEMORY_RETRIEVAL_LIMIT, 10) || 5, 10),
|
|
764
|
-
sessionId: body._sessionId || null,
|
|
765
|
-
includeGlobal: process.env.MEMORY_INCLUDE_GLOBAL !== "false",
|
|
766
|
-
});
|
|
767
|
-
if (!memories || memories.length === 0) return body;
|
|
768
|
-
|
|
769
|
-
const formatted = formatMemoriesForContext(memories);
|
|
770
|
-
if (!formatted) return body;
|
|
771
|
-
|
|
772
|
-
const contextText = `\n\n## Relevant context from earlier sessions:\n${formatted}`;
|
|
773
|
-
|
|
774
|
-
// Bound the injection size (Headroom uses a MemoryInjectionBudget; we use
|
|
775
|
-
// a simpler char cap — ~1024 tokens * 4 chars/token = 4096 chars).
|
|
776
|
-
const MAX_INJECTION_CHARS = 4096;
|
|
777
|
-
const boundedContext = contextText.length > MAX_INJECTION_CHARS
|
|
778
|
-
? contextText.slice(0, MAX_INJECTION_CHARS) + "\n…"
|
|
779
|
-
: contextText;
|
|
780
|
-
|
|
781
|
-
// Clone messages array (shallow) so we don't mutate the caller's body.
|
|
782
|
-
const newMessages = body.messages.slice();
|
|
783
|
-
|
|
784
|
-
if (typeof lastMsg.content === "string") {
|
|
785
|
-
newMessages[lastIdx] = { ...lastMsg, content: lastMsg.content + boundedContext };
|
|
786
|
-
} else if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) {
|
|
787
|
-
// Append to the FIRST text block, preserving every other block (images,
|
|
788
|
-
// tool_use, etc.) untouched.
|
|
789
|
-
const newContent = [];
|
|
790
|
-
let appended = false;
|
|
791
|
-
for (const block of lastMsg.content) {
|
|
792
|
-
if (!appended && block && typeof block === "object" && block.type === "text") {
|
|
793
|
-
newContent.push({ ...block, text: (block.text || "") + boundedContext });
|
|
794
|
-
appended = true;
|
|
795
|
-
} else {
|
|
796
|
-
newContent.push(block);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
if (!appended) return body; // no text block to append to
|
|
800
|
-
newMessages[lastIdx] = { ...lastMsg, content: newContent };
|
|
801
|
-
} else {
|
|
802
|
-
return body;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
logger.debug({
|
|
806
|
-
memoryCount: memories.length,
|
|
807
|
-
appendedChars: boundedContext.length,
|
|
808
|
-
}, "Memory injected into last-user-message tail");
|
|
809
|
-
|
|
810
|
-
return { ...body, messages: newMessages };
|
|
811
|
-
}
|
|
812
|
-
|
|
735
|
+
// (maybeInjectMemoryIntoUserTail was removed with the passthrough
|
|
736
|
+
// memory-injection option — the passthrough is byte-for-byte by contract.)
|
|
813
737
|
/**
|
|
814
738
|
* Estimate token count for messages.
|
|
815
739
|
*
|
|
@@ -1092,7 +1016,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1092
1016
|
//
|
|
1093
1017
|
// All three paths now share window-scored intent tier picking
|
|
1094
1018
|
// (`pickTierByIntent`). Subscription still has the additional
|
|
1095
|
-
// azure-anthropic passthrough fork
|
|
1019
|
+
// azure-anthropic passthrough fork (first-party clients only); everything
|
|
1096
1020
|
// else just falls through to the orchestrator with the picked tier
|
|
1097
1021
|
// pinned via _forceProvider/_tierModel. The reason all paths share the
|
|
1098
1022
|
// scorer is that determineProviderSmart's full-body analysis inflates
|
|
@@ -1117,6 +1041,37 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1117
1041
|
// in a session skip the whole window-scored intent pipeline.
|
|
1118
1042
|
let tier;
|
|
1119
1043
|
const pinCheck = checkSessionPin(req.body);
|
|
1044
|
+
|
|
1045
|
+
// Explicit model-id pin: Claude Desktop's model picker (or any Anthropic-
|
|
1046
|
+
// format caller) can name one of the fixed ids claude-desktop-gateway.js
|
|
1047
|
+
// advertises (see ../routing/model-slots.js). Unlike every signal below,
|
|
1048
|
+
// this one is EXPLICIT user intent, not an inference — it wins over
|
|
1049
|
+
// session pins, side-request detection, and content scoring alike.
|
|
1050
|
+
// "Lynkr Auto" (claude-fable-5) and any unrecognized id resolve to null
|
|
1051
|
+
// and fall through to the existing cascade unchanged.
|
|
1052
|
+
const modelPinTier = resolveTierForModelId(req.body?.model);
|
|
1053
|
+
if (modelPinTier) {
|
|
1054
|
+
const _sel = getModelTierSelector().selectModel(modelPinTier, null);
|
|
1055
|
+
tier = {
|
|
1056
|
+
tier: modelPinTier,
|
|
1057
|
+
provider: _sel.provider,
|
|
1058
|
+
model: _sel.model || null,
|
|
1059
|
+
score: null,
|
|
1060
|
+
method: 'model_id_pin',
|
|
1061
|
+
reason: 'client_selected_model',
|
|
1062
|
+
base_tier: null,
|
|
1063
|
+
escalation_source: null,
|
|
1064
|
+
pinned: false,
|
|
1065
|
+
switch_reason: null,
|
|
1066
|
+
propensity: 1.0,
|
|
1067
|
+
candidates: [{ provider: _sel.provider, model: _sel.model || null }],
|
|
1068
|
+
};
|
|
1069
|
+
logger.debug({
|
|
1070
|
+
model: req.body.model,
|
|
1071
|
+
tier: modelPinTier,
|
|
1072
|
+
provider: _sel.provider,
|
|
1073
|
+
}, "[Routing] Explicit model-id pin — scoring bypassed");
|
|
1074
|
+
} else {
|
|
1120
1075
|
// Side-request detection. Claude Code fires internal background calls
|
|
1121
1076
|
// (title generation, summarization, memory extraction, suggestion-mode
|
|
1122
1077
|
// autocomplete) that REPLAY the conversation — so they share the
|
|
@@ -1343,11 +1298,13 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1343
1298
|
}, "OAuth intent — side request (no tools), pin write skipped");
|
|
1344
1299
|
}
|
|
1345
1300
|
}
|
|
1301
|
+
} // end else — no explicit model-id pin, scored normally above
|
|
1346
1302
|
|
|
1347
|
-
// Subscription-only fork:
|
|
1348
|
-
// tier resolves to azure-anthropic
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1303
|
+
// Subscription-only fork: byte-for-byte passthrough to api.anthropic.com
|
|
1304
|
+
// when the picked tier resolves to azure-anthropic — FIRST-PARTY
|
|
1305
|
+
// Anthropic clients only (Claude Code / Claude Desktop wrapping their
|
|
1306
|
+
// own traffic; see auth-mode.js for the policy). Gated here at the
|
|
1307
|
+
// dispatch point, not just in classification, so the two can't drift.
|
|
1351
1308
|
// Passthrough forwards the CLIENT's credentials; GUI harnesses that
|
|
1352
1309
|
// spawn claude headless inject placeholder keys ("dummy") that Anthropic
|
|
1353
1310
|
// 401s. Placeholder auth ⇒ serve REASONING via the provider's own
|
|
@@ -1357,13 +1314,14 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1357
1314
|
const _placeholderAuth =
|
|
1358
1315
|
!_clientAuthHdr &&
|
|
1359
1316
|
(['dummy', 'test', 'placeholder', 'none', 'x', 'sk-dummy'].includes(_clientApiKey) || _clientApiKey.length < 8);
|
|
1317
|
+
const _firstParty = isFirstPartyAnthropicClient(req.headers);
|
|
1360
1318
|
if (_placeholderAuth && authMode === 'subscription' && tier.provider === 'azure-anthropic') {
|
|
1361
1319
|
logger.info({
|
|
1362
1320
|
reqNumber: messagesRequestCount,
|
|
1363
1321
|
xApiKeyShape: _clientApiKey.slice(0, 12),
|
|
1364
1322
|
}, 'Placeholder client auth — skipping passthrough, serving REASONING via provider credentials');
|
|
1365
1323
|
}
|
|
1366
|
-
if (authMode === 'subscription' && tier.provider === 'azure-anthropic' && !_placeholderAuth) {
|
|
1324
|
+
if (authMode === 'subscription' && tier.provider === 'azure-anthropic' && !_placeholderAuth && _firstParty) {
|
|
1367
1325
|
logger.debug({
|
|
1368
1326
|
reqNumber: messagesRequestCount,
|
|
1369
1327
|
authMode,
|
|
@@ -2090,6 +2048,12 @@ router.get("/api/tokens/stats", (req, res) => {
|
|
|
2090
2048
|
}
|
|
2091
2049
|
});
|
|
2092
2050
|
|
|
2051
|
+
// Claude Desktop third-party gateway: intercepts GET /v1/models for
|
|
2052
|
+
// Anthropic-API clients (anthropic-version header) and advertises Lynkr's
|
|
2053
|
+
// tiers as Claude model families. Mounted BEFORE openaiRouter so the
|
|
2054
|
+
// intercept wins; non-Anthropic callers fall through unchanged.
|
|
2055
|
+
router.use("/v1", claudeDesktopGatewayRouter);
|
|
2056
|
+
|
|
2093
2057
|
// Mount OpenAI-compatible endpoints for Cursor IDE support
|
|
2094
2058
|
router.use("/v1", openaiRouter);
|
|
2095
2059
|
|
package/src/auth-mode.js
CHANGED
|
@@ -47,15 +47,34 @@
|
|
|
47
47
|
* @module auth-mode
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
+
// FIRST-PARTY Anthropic clients only (deliberate policy, 2026-09): the
|
|
51
|
+
// subscription classification — and with it the api.anthropic.com
|
|
52
|
+
// passthrough — is for Claude Code and Claude Desktop wrapping their OWN
|
|
53
|
+
// traffic. Third-party harness prefixes (codex-cli/, cursor/,
|
|
54
|
+
// github-copilot/, antigravity/) were removed: classifying them as
|
|
55
|
+
// 'subscription' handed the passthrough to exactly the harness category
|
|
56
|
+
// Anthropic's ToS enforcement targets. Those clients still work through
|
|
57
|
+
// Lynkr — they classify by token shape ('oauth'/'payg') and route through
|
|
58
|
+
// the orchestrator with the operator's configured providers.
|
|
59
|
+
//
|
|
60
|
+
// BREAKING CHANGE for anyone who had Cursor/Codex/Copilot riding the
|
|
61
|
+
// subscription path: that traffic now routes via configured tiers.
|
|
62
|
+
//
|
|
63
|
+
// If Claude Desktop's own requests carry a UA not listed here (capture one
|
|
64
|
+
// via the desktop gateway before assuming), add THAT — never a third-party
|
|
65
|
+
// harness. This is a UA check: it defines intended use, not cryptographic
|
|
66
|
+
// attestation.
|
|
50
67
|
const SUBSCRIPTION_UA_PREFIXES = [
|
|
51
68
|
'claude-cli/',
|
|
52
69
|
'claude-code/',
|
|
53
|
-
'codex-cli/',
|
|
54
|
-
'cursor/',
|
|
55
70
|
'claude-vscode/',
|
|
56
|
-
'github-copilot/',
|
|
57
71
|
'anthropic-cli/',
|
|
58
|
-
|
|
72
|
+
// Claude Desktop (Electron app). Captured from a real gateway request:
|
|
73
|
+
// "Mozilla/5.0 (...) Claude/1.46388.1 Chrome/148... Electron/42..." —
|
|
74
|
+
// the app identifies as "Claude/<version>" inside a browser-style UA.
|
|
75
|
+
// Deliberately 'claude/' (with slash): no third-party harness UA carries
|
|
76
|
+
// that token, and the first-party ids above don't collide with it.
|
|
77
|
+
'claude/',
|
|
59
78
|
];
|
|
60
79
|
|
|
61
80
|
/**
|
|
@@ -110,7 +129,24 @@ function classifyAuthMode(headers) {
|
|
|
110
129
|
return 'payg';
|
|
111
130
|
}
|
|
112
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Is this request from a first-party Anthropic client (Claude Code / Claude
|
|
134
|
+
* Desktop family)? Used as the explicit gate at the api.anthropic.com
|
|
135
|
+
* passthrough dispatch fork — the security decision lives at the point of
|
|
136
|
+
* dispatch, not only in classification, so the two can't silently drift
|
|
137
|
+
* apart. Same UA semantics as classifyAuthMode's subscription check.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} headers
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
function isFirstPartyAnthropicClient(headers) {
|
|
143
|
+
const ua = getHeader(headers, 'user-agent').toLowerCase();
|
|
144
|
+
if (!ua) return false;
|
|
145
|
+
return SUBSCRIPTION_UA_PREFIXES.some((prefix) => ua.includes(prefix));
|
|
146
|
+
}
|
|
147
|
+
|
|
113
148
|
module.exports = {
|
|
114
149
|
classifyAuthMode,
|
|
150
|
+
isFirstPartyAnthropicClient,
|
|
115
151
|
SUBSCRIPTION_UA_PREFIXES,
|
|
116
152
|
};
|
package/src/budget/index.js
CHANGED
|
@@ -75,6 +75,12 @@ class BudgetManager {
|
|
|
75
75
|
minute_window_start INTEGER,
|
|
76
76
|
hour_window_start INTEGER
|
|
77
77
|
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS token_rate (
|
|
80
|
+
user_id TEXT PRIMARY KEY,
|
|
81
|
+
tokens_minute INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
minute_window_start INTEGER NOT NULL
|
|
83
|
+
);
|
|
78
84
|
`);
|
|
79
85
|
|
|
80
86
|
this.stmts = {
|
|
@@ -114,6 +120,86 @@ class BudgetManager {
|
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Token-aware (TPM) rate limiting (ROUTING-NOTES §1 gap: request-count
|
|
125
|
+
* only). Off unless LYNKR_TPM_LIMIT is set — same "off unless configured"
|
|
126
|
+
* convention as the loop guard.
|
|
127
|
+
*
|
|
128
|
+
* Estimate → true-up pattern (the one every serious gateway converged on):
|
|
129
|
+
* the pre-flight check gates on the window's ACTUAL consumption plus the
|
|
130
|
+
* current request's cheap estimate; real usage lands in the window
|
|
131
|
+
* post-response via recordTokenUsage(). Soft admission control, not a hard
|
|
132
|
+
* ceiling — concurrent in-flight requests can overshoot by roughly one
|
|
133
|
+
* request's worth, by design.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} userId
|
|
136
|
+
* @param {number} estimatedTokens — cheap pre-flight estimate for THIS request
|
|
137
|
+
* @returns {{allowed: boolean, reason?: string, limit?: number, current?: number, resetInMs?: number}}
|
|
138
|
+
*/
|
|
139
|
+
checkTokenRate(userId, estimatedTokens = 0) {
|
|
140
|
+
if (!this.enabled) return { allowed: true };
|
|
141
|
+
const limit = Number.parseInt(process.env.LYNKR_TPM_LIMIT, 10);
|
|
142
|
+
if (!limit || limit <= 0 || Number.isNaN(limit)) return { allowed: true };
|
|
143
|
+
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const minuteWindow = 60 * 1000;
|
|
146
|
+
const row = this.db.prepare('SELECT * FROM token_rate WHERE user_id = ?').get(userId);
|
|
147
|
+
let tokensMinute = row?.tokens_minute ?? 0;
|
|
148
|
+
let windowStart = row?.minute_window_start ?? now;
|
|
149
|
+
if (now - windowStart >= minuteWindow) {
|
|
150
|
+
tokensMinute = 0;
|
|
151
|
+
windowStart = now;
|
|
152
|
+
this.db.prepare(`
|
|
153
|
+
INSERT INTO token_rate (user_id, tokens_minute, minute_window_start)
|
|
154
|
+
VALUES (?, 0, ?)
|
|
155
|
+
ON CONFLICT(user_id) DO UPDATE SET tokens_minute = 0, minute_window_start = excluded.minute_window_start
|
|
156
|
+
`).run(userId, windowStart);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (tokensMinute + estimatedTokens > limit) {
|
|
160
|
+
return {
|
|
161
|
+
allowed: false,
|
|
162
|
+
reason: 'token_rate_limit_minute',
|
|
163
|
+
limit,
|
|
164
|
+
current: tokensMinute,
|
|
165
|
+
estimated: estimatedTokens,
|
|
166
|
+
resetInMs: minuteWindow - (now - windowStart),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { allowed: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* True-up: add ACTUAL token consumption to the user's TPM window after the
|
|
174
|
+
* response completes. No-op when TPM limiting is disabled.
|
|
175
|
+
* @param {string} userId
|
|
176
|
+
* @param {number} totalTokens — actual input+output tokens consumed
|
|
177
|
+
*/
|
|
178
|
+
recordTokenUsage(userId, totalTokens) {
|
|
179
|
+
if (!this.enabled || !totalTokens || totalTokens <= 0) return;
|
|
180
|
+
const limit = Number.parseInt(process.env.LYNKR_TPM_LIMIT, 10);
|
|
181
|
+
if (!limit || limit <= 0 || Number.isNaN(limit)) return;
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const minuteWindow = 60 * 1000;
|
|
184
|
+
try {
|
|
185
|
+
const row = this.db.prepare('SELECT * FROM token_rate WHERE user_id = ?').get(userId);
|
|
186
|
+
const stale = !row || now - row.minute_window_start >= minuteWindow;
|
|
187
|
+
this.db.prepare(`
|
|
188
|
+
INSERT INTO token_rate (user_id, tokens_minute, minute_window_start)
|
|
189
|
+
VALUES (@user_id, @tokens, @window_start)
|
|
190
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
191
|
+
tokens_minute = ${stale ? '@tokens' : 'tokens_minute + @tokens'},
|
|
192
|
+
minute_window_start = @window_start
|
|
193
|
+
`).run({
|
|
194
|
+
user_id: userId,
|
|
195
|
+
tokens: totalTokens,
|
|
196
|
+
window_start: stale ? now : row.minute_window_start,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.debug({ err: err.message }, '[Budget] token usage record failed');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
117
203
|
checkRateLimit(userId) {
|
|
118
204
|
if (!this.enabled) return { allowed: true };
|
|
119
205
|
|