lynkr 9.14.11 → 9.14.13
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 +1 -0
- package/bin/cli.js +1 -0
- package/bin/lynkr-connect-orcarouter.js +246 -0
- package/bin/lynkr-init.js +15 -1
- package/config/difficulty-anchors.json +12 -2
- package/config/model-capability-seeds.json +5 -0
- package/package.json +3 -3
- package/scripts/validate-intent-anchors.js +2 -2
- package/src/api/openai-router.js +22 -4
- package/src/api/providers-handler.js +33 -10
- package/src/api/router.js +370 -16
- package/src/clients/databricks.js +196 -22
- package/src/clients/openai-format.js +37 -2
- package/src/clients/openrouter-utils.js +20 -0
- package/src/clients/orcarouter-catalog.js +330 -0
- package/src/clients/orcarouter-credentials.js +428 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/config/index.js +63 -2
- package/src/dashboard/api.js +1 -0
- package/src/orchestrator/index.js +49 -10
- package/src/orchestrator/sse-transformer.js +3 -0
- package/src/prompts/system.js +41 -49
- package/src/routing/affinity-store.js +47 -21
- package/src/routing/cache-economics.js +1 -0
- package/src/routing/cache-switch-cost.js +46 -2
- package/src/routing/complexity-analyzer.js +21 -4
- package/src/routing/index.js +123 -1
- package/src/routing/intent-score.js +48 -13
- package/src/routing/jev-router.js +272 -0
- package/src/routing/model-tiers.js +15 -0
- package/src/routing/passthrough-route.js +286 -0
- package/src/routing/quota-ledger.js +110 -0
- package/src/routing/risk-analyzer.js +17 -1
- package/src/routing/telemetry.js +47 -2
package/src/prompts/system.js
CHANGED
|
@@ -73,61 +73,53 @@ function compressToolDescriptions(tools, mode = null) {
|
|
|
73
73
|
// Normalize to Anthropic shape: OpenAI-format callers (OpenWorker, aisuite,
|
|
74
74
|
// any OpenAI SDK) send {type:"function", function:{name, description, parameters}}.
|
|
75
75
|
// Unwrap once here so the compression logic below can assume input_schema exists.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return tools.map(tool => {
|
|
87
|
-
const input_schema = tool.input_schema || { type: "object", properties: {} };
|
|
88
|
-
const compressed = {
|
|
89
|
-
name: tool.name,
|
|
90
|
-
input_schema: {
|
|
91
|
-
type: input_schema.type || "object",
|
|
92
|
-
properties: {},
|
|
93
|
-
required: input_schema.required || [],
|
|
94
|
-
}
|
|
76
|
+
// Every other key on the function object (strict, and any future keywords)
|
|
77
|
+
// passes through untouched — an allowlist here silently drops enforceable
|
|
78
|
+
// constraints (issue #116).
|
|
79
|
+
tools = tools.map(tool => {
|
|
80
|
+
if (tool?.type !== "function" || !tool.function) return tool;
|
|
81
|
+
const { parameters, ...kept } = tool.function;
|
|
82
|
+
return {
|
|
83
|
+
...kept,
|
|
84
|
+
input_schema: parameters || { type: "object", properties: {} },
|
|
95
85
|
};
|
|
86
|
+
});
|
|
96
87
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
return tools.map(tool => {
|
|
89
|
+
// Copy everything through; compression below targets description TEXT
|
|
90
|
+
// only, never schema shape (issue #116: an allowlist rebuild silently
|
|
91
|
+
// dropped strict/pattern/const/oneOf and misleveled additionalProperties).
|
|
92
|
+
const out = { ...tool };
|
|
93
|
+
if (typeof out.description === 'string' && out.description) {
|
|
94
|
+
out.description = compressText(out.description, 50);
|
|
100
95
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (input_schema.properties) {
|
|
104
|
-
for (const [key, value] of Object.entries(input_schema.properties)) {
|
|
105
|
-
compressed.input_schema.properties[key] = {
|
|
106
|
-
type: value.type,
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
// Only include description if it's critical
|
|
110
|
-
if (value.description && !isObviousFromName(key)) {
|
|
111
|
-
compressed.input_schema.properties[key].description = compressText(value.description, 30);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Preserve enum, format, and other critical constraints
|
|
115
|
-
if (value.enum) compressed.input_schema.properties[key].enum = value.enum;
|
|
116
|
-
if (value.format) compressed.input_schema.properties[key].format = value.format;
|
|
117
|
-
if (value.items) compressed.input_schema.properties[key].items = value.items;
|
|
118
|
-
if (value.additionalProperties !== undefined) {
|
|
119
|
-
compressed.input_schema.properties[key].additionalProperties = value.additionalProperties;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
96
|
+
if (out.input_schema && typeof out.input_schema === 'object') {
|
|
97
|
+
out.input_schema = compressSchemaDescriptions(out.input_schema, null);
|
|
122
98
|
}
|
|
99
|
+
return out;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
123
102
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Recursively compress `description` strings in a JSON Schema, preserving
|
|
105
|
+
* every other keyword byte-for-byte in shape. Descriptions obvious from the
|
|
106
|
+
* property name are dropped, the rest truncated — matching the historical
|
|
107
|
+
* minimal-mode behavior without an allowlist that lags the spec.
|
|
108
|
+
*/
|
|
109
|
+
function compressSchemaDescriptions(node, key) {
|
|
110
|
+
if (Array.isArray(node)) return node.map((v) => compressSchemaDescriptions(v, key));
|
|
111
|
+
if (node && typeof node === 'object') {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [k, v] of Object.entries(node)) {
|
|
114
|
+
if (k === 'description' && typeof v === 'string') {
|
|
115
|
+
if (!isObviousFromName(key)) out[k] = compressText(v, 30);
|
|
116
|
+
} else {
|
|
117
|
+
out[k] = compressSchemaDescriptions(v, k);
|
|
118
|
+
}
|
|
127
119
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
return node;
|
|
131
123
|
}
|
|
132
124
|
|
|
133
125
|
/**
|
|
@@ -59,9 +59,25 @@ function _db() {
|
|
|
59
59
|
// Additive migration for cache-aware routing (Phase 1): JSON blob
|
|
60
60
|
// holding {warmPrefixTokens, provider, model, lastRequestAt, ttlMs},
|
|
61
61
|
// updated after every upstream response that reports cache usage.
|
|
62
|
+
// NOTE (2026-09-18): superseded by the dedicated session_cache_state
|
|
63
|
+
// table below. The column stays (and old blobs stay readable nowhere —
|
|
64
|
+
// loadCacheState reads the new table), but NOTHING writes it anymore:
|
|
65
|
+
// sharing the pins table let cache-only rows (no tier) be served as
|
|
66
|
+
// routing pins, permanently locking sessions with no escalation path.
|
|
62
67
|
if (!cols.has("cache_state")) {
|
|
63
68
|
db.exec("ALTER TABLE session_pins ADD COLUMN cache_state TEXT");
|
|
64
69
|
}
|
|
70
|
+
db.exec(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS session_cache_state (
|
|
72
|
+
session_id TEXT PRIMARY KEY,
|
|
73
|
+
warm_prefix_tokens INTEGER NOT NULL,
|
|
74
|
+
provider TEXT NOT NULL,
|
|
75
|
+
model TEXT,
|
|
76
|
+
last_request_at INTEGER NOT NULL,
|
|
77
|
+
ttl_ms INTEGER NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_cache_state_ts ON session_cache_state(last_request_at);
|
|
80
|
+
`);
|
|
65
81
|
schemaEnsured = true;
|
|
66
82
|
} catch (err) {
|
|
67
83
|
degradation.record("feedback", err);
|
|
@@ -168,9 +184,10 @@ function save(sessionId, pin) {
|
|
|
168
184
|
|
|
169
185
|
/**
|
|
170
186
|
* Persist per-session prompt-cache state (Phase 1, cache-aware routing).
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
187
|
+
* Lives in its OWN table (session_cache_state), deliberately separate from
|
|
188
|
+
* session_pins: the old piggyback design wrote tier-less rows that got
|
|
189
|
+
* served as routing pins, locking sessions with no escalation path.
|
|
190
|
+
* Best-effort like everything else in this module.
|
|
174
191
|
*
|
|
175
192
|
* @param {string} sessionId
|
|
176
193
|
* @param {{warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number}} state
|
|
@@ -180,21 +197,25 @@ function saveCacheState(sessionId, state) {
|
|
|
180
197
|
const db = _db();
|
|
181
198
|
if (!db) return;
|
|
182
199
|
try {
|
|
183
|
-
|
|
184
|
-
const res = _stmt(
|
|
200
|
+
_stmt(
|
|
185
201
|
db,
|
|
186
|
-
"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
"cache_state_upsert",
|
|
203
|
+
`INSERT INTO session_cache_state (session_id, warm_prefix_tokens, provider, model, last_request_at, ttl_ms)
|
|
204
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
205
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
206
|
+
warm_prefix_tokens = excluded.warm_prefix_tokens,
|
|
207
|
+
provider = excluded.provider,
|
|
208
|
+
model = excluded.model,
|
|
209
|
+
last_request_at = excluded.last_request_at,
|
|
210
|
+
ttl_ms = excluded.ttl_ms`
|
|
211
|
+
).run(
|
|
212
|
+
sessionId,
|
|
213
|
+
state.warmPrefixTokens ?? 0,
|
|
214
|
+
state.provider,
|
|
215
|
+
state.model ?? null,
|
|
216
|
+
state.lastRequestAt ?? Date.now(),
|
|
217
|
+
state.ttlMs ?? 5 * 60 * 1000
|
|
218
|
+
);
|
|
198
219
|
} catch (err) {
|
|
199
220
|
degradation.record("feedback", err);
|
|
200
221
|
}
|
|
@@ -216,11 +237,16 @@ function loadCacheState(sessionId) {
|
|
|
216
237
|
const row = _stmt(
|
|
217
238
|
db,
|
|
218
239
|
"cache_state_load",
|
|
219
|
-
"SELECT
|
|
240
|
+
"SELECT warm_prefix_tokens, provider, model, last_request_at, ttl_ms FROM session_cache_state WHERE session_id = ?"
|
|
220
241
|
).get(sessionId);
|
|
221
|
-
if (!row
|
|
222
|
-
|
|
223
|
-
|
|
242
|
+
if (!row) return null;
|
|
243
|
+
return {
|
|
244
|
+
warmPrefixTokens: Number(row.warm_prefix_tokens) || 0,
|
|
245
|
+
provider: row.provider,
|
|
246
|
+
model: row.model ?? null,
|
|
247
|
+
lastRequestAt: Number(row.last_request_at) || 0,
|
|
248
|
+
ttlMs: Number(row.ttl_ms) || 5 * 60 * 1000,
|
|
249
|
+
};
|
|
224
250
|
} catch (err) {
|
|
225
251
|
degradation.record("feedback", err);
|
|
226
252
|
return null;
|
|
@@ -47,6 +47,7 @@ const PROVIDER_CACHE_DEFAULTS = {
|
|
|
47
47
|
moonshot: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
48
48
|
openrouter: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
49
49
|
edenai: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
50
|
+
orcarouter: { readMult: 0.1, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
50
51
|
// DeepSeek direct (via aggregators today, kept for model-level matches):
|
|
51
52
|
deepseek: { readMult: 0.02, writeMult: 0, ttlMs: 5 * 60 * 1000, mechanism: 'automatic' },
|
|
52
53
|
|
|
@@ -61,12 +61,23 @@ function _cfg() {
|
|
|
61
61
|
* @param {number} [args.outputTokensPerTurn] - est. output tokens per turn.
|
|
62
62
|
* @param {number|null} [args.expectedRemainingTurns] - median remaining
|
|
63
63
|
* turns; falls back to the conservative config default when null.
|
|
64
|
+
* @param {number|null} [args.sessionBurnPressure] - 0..1 quota pressure from
|
|
65
|
+
* quota-ledger.js (sustained expensive serving on a flat subscription).
|
|
66
|
+
* Enables a parallel QUOTA break-even leg: while dollar math prices the
|
|
67
|
+
* rebuild in USD, quota math prices it in ledger units (model weight ×
|
|
68
|
+
* tokens, Haiku = 1.0). Either leg clearing allows the switch, so high
|
|
69
|
+
* burn opens descents the dollar leg alone would hold. Null/0 disables
|
|
70
|
+
* the quota leg entirely — pure dollar behavior, byte-for-byte backward
|
|
71
|
+
* compatible. Upgrades never route through this gate, so pressure can
|
|
72
|
+
* only accelerate descents, never block capability.
|
|
64
73
|
* @param {Object} [args.deps] - test injection: {resolveCacheEconomics}.
|
|
65
74
|
* @returns {{
|
|
66
75
|
* switchAllowed: boolean,
|
|
67
76
|
* reason: string,
|
|
68
77
|
* breakEvenTurns: number|null,
|
|
69
78
|
* expectedRemainingTurns: number,
|
|
79
|
+
* quotaBreakEvenTurns: number|null,
|
|
80
|
+
* burnPressureApplied: number,
|
|
70
81
|
* warmPrefixTokens: number,
|
|
71
82
|
* switchOnceUsd: number|null,
|
|
72
83
|
* stayPerTurnUsd: number|null,
|
|
@@ -81,18 +92,24 @@ function evaluateSwitch({
|
|
|
81
92
|
newTokensPerTurn,
|
|
82
93
|
outputTokensPerTurn,
|
|
83
94
|
expectedRemainingTurns,
|
|
95
|
+
sessionBurnPressure = null,
|
|
84
96
|
deps = {},
|
|
85
97
|
} = {}) {
|
|
86
98
|
const cfg = _cfg();
|
|
87
99
|
const remaining = Number.isFinite(expectedRemainingTurns) && expectedRemainingTurns > 0
|
|
88
100
|
? expectedRemainingTurns
|
|
89
101
|
: cfg.defaultRemainingTurns;
|
|
102
|
+
const burnPressure = Number.isFinite(sessionBurnPressure)
|
|
103
|
+
? Math.max(0, Math.min(1, sessionBurnPressure))
|
|
104
|
+
: 0;
|
|
90
105
|
|
|
91
106
|
const base = {
|
|
92
107
|
switchAllowed: true,
|
|
93
108
|
reason: 'no_cache_state',
|
|
94
109
|
breakEvenTurns: null,
|
|
95
110
|
expectedRemainingTurns: remaining,
|
|
111
|
+
quotaBreakEvenTurns: null,
|
|
112
|
+
burnPressureApplied: burnPressure,
|
|
96
113
|
warmPrefixTokens: cacheState?.warmPrefixTokens ?? 0,
|
|
97
114
|
switchOnceUsd: null,
|
|
98
115
|
stayPerTurnUsd: null,
|
|
@@ -174,19 +191,46 @@ function evaluateSwitch({
|
|
|
174
191
|
const breakEvenTurns = switchOnce / savingsPerTurn;
|
|
175
192
|
const allowed = breakEvenTurns <= remaining;
|
|
176
193
|
|
|
194
|
+
// Quota leg (flat subscriptions): re-run the same break-even in ledger
|
|
195
|
+
// units when the session is burning. Model weight = (input+output $/M) /
|
|
196
|
+
// Haiku baseline — quota burn tracks price ratios, which is exactly what
|
|
197
|
+
// the ledger records. A rebuild that never pays in dollars can still pay
|
|
198
|
+
// in runway when every held turn burns 5x the units a descent would.
|
|
199
|
+
let quotaBreakEvenTurns = null;
|
|
200
|
+
let quotaAllowed = false;
|
|
201
|
+
if (burnPressure > 0) {
|
|
202
|
+
const wCur = (econCur.inputPerM + econCur.outputPerM) / 6;
|
|
203
|
+
const wTgt = (econTgt.inputPerM + econTgt.outputPerM) / 6;
|
|
204
|
+
if (wCur > wTgt) {
|
|
205
|
+
const perTurnTokens = (W + N + O) / 1000;
|
|
206
|
+
const quotaSavingsPerTurn = perTurnTokens * (wCur - wTgt);
|
|
207
|
+
if (quotaSavingsPerTurn > 0) {
|
|
208
|
+
quotaBreakEvenTurns = ((W + N) / 1000) * wTgt / quotaSavingsPerTurn;
|
|
209
|
+
quotaAllowed = quotaBreakEvenTurns <= remaining;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const finalAllowed = allowed || quotaAllowed;
|
|
214
|
+
|
|
177
215
|
logger.debug({
|
|
178
216
|
current: `${current.provider}:${current.model}`,
|
|
179
217
|
target: `${target.provider}:${target.model}`,
|
|
180
218
|
warmPrefixTokens: W,
|
|
181
219
|
breakEvenTurns: Number(breakEvenTurns.toFixed(2)),
|
|
182
220
|
expectedRemainingTurns: remaining,
|
|
221
|
+
quotaBreakEvenTurns: quotaBreakEvenTurns === null ? null : Number(quotaBreakEvenTurns.toFixed(2)),
|
|
222
|
+
burnPressureApplied: burnPressure,
|
|
183
223
|
allowed,
|
|
224
|
+
quotaAllowed,
|
|
184
225
|
}, '[CacheSwitchCost] break-even evaluated');
|
|
185
226
|
|
|
186
227
|
return {
|
|
187
228
|
...priced,
|
|
188
|
-
|
|
189
|
-
|
|
229
|
+
quotaBreakEvenTurns,
|
|
230
|
+
switchAllowed: finalAllowed,
|
|
231
|
+
reason: finalAllowed
|
|
232
|
+
? (allowed ? 'break_even_cleared' : 'quota_break_even_cleared')
|
|
233
|
+
: 'break_even_blocked',
|
|
190
234
|
breakEvenTurns,
|
|
191
235
|
};
|
|
192
236
|
}
|
|
@@ -104,16 +104,28 @@ const FORCE_REASONING_PATTERNS = [
|
|
|
104
104
|
/\b(prove|proof|formal\s+proof|verify|verification)\b/i,
|
|
105
105
|
/\b(from\s+first\s+principles)\b/i,
|
|
106
106
|
/\b(reason\s+(through|about|from)\s+(the|this))/i,
|
|
107
|
+
// Frontier planning: asking FOR a plan/strategy/design of technical work
|
|
108
|
+
// (not asking about one). Two-sided lookahead — a plan word AND a technical
|
|
109
|
+
// scope word must both be present, so "plan my vacation", "lesson plan"
|
|
110
|
+
// and "business plan" stay quiet while "give me a plan to refactor X",
|
|
111
|
+
// "draft an RFC to redesign Y" and "migration strategy for Z" escalate.
|
|
112
|
+
// Deliberately excludes bare "give me a plan" (nothing technical to plan)
|
|
113
|
+
// and generic nouns (module/api/database) that misfire on non-technical
|
|
114
|
+
// asks. Known overlap: "explain the deployment plan" fires — accepted,
|
|
115
|
+
// deployment planning benefits from the strong model anyway.
|
|
116
|
+
/(?=.*\b(plans?|roadmaps?|strateg(y|ies)|blueprints?|proposals?|rfcs?|design\s+docs?)\b)(?=.*\b(refactor\w*|implement\w*|migrat\w+|architect\w+|redesign\w*|restructur\w+|rewrit\w+|overhaul\w*|codebases?|infrastructure|pipelines?|deploy\w*|systems?)\b)/i,
|
|
107
117
|
];
|
|
108
118
|
|
|
109
119
|
// Force COMPLEX patterns - route to COMPLEX (GLM in config B) regardless of
|
|
110
120
|
// score. Architecture/refactor/code-review are substantial but not
|
|
111
121
|
// security-critical, so GLM is appropriate.
|
|
112
122
|
const FORCE_CLOUD_PATTERNS = [
|
|
113
|
-
/\b(architect
|
|
123
|
+
/\b(architect\w*\s+(review|design|diagram)|system\s+design)\b/i,
|
|
114
124
|
/\b(refactor\s+(entire|whole|all|the\s+entire)|complete\s+rewrite)\b/i,
|
|
115
125
|
/\b(code\s+review|pr\s+review|pull\s+request\s+review)\b/i,
|
|
116
|
-
|
|
126
|
+
// "debug this tricky race" reads naturally but the adjectives rarely sit
|
|
127
|
+
// adjacent to the verb, so up to two words may intervene.
|
|
128
|
+
/\bdebug(?:ging)?\s+(?:\w+\s+){0,2}(complex|difficult|hard|tricky)\b/i,
|
|
117
129
|
/\b(production\s+(issue|bug|incident|outage))\b/i,
|
|
118
130
|
];
|
|
119
131
|
|
|
@@ -655,8 +667,13 @@ function calculateWeightedScore(payload, content) {
|
|
|
655
667
|
const avgLength = content.length / Math.max(sentences.length, 1);
|
|
656
668
|
dimensions.promptComplexity = Math.min(avgLength / 2, 100);
|
|
657
669
|
|
|
658
|
-
// 3. Technical depth (keyword density)
|
|
659
|
-
|
|
670
|
+
// 3. Technical depth (keyword density). NOTE: uses a local global-flag
|
|
671
|
+
// copy — the shared PATTERNS.technical must stay flag-less because .test()
|
|
672
|
+
// callers above depend on stateless matching (a shared /g regex carries
|
|
673
|
+
// lastIndex between calls). String.match with a non-global regex returns
|
|
674
|
+
// at most one hit, which used to cap this dimension at 15 regardless of
|
|
675
|
+
// density and starved COMPLEX-tier scores of their strongest signal.
|
|
676
|
+
const techMatches = (content.match(new RegExp(PATTERNS.technical.source, 'gi')) || []).length;
|
|
660
677
|
dimensions.technicalDepth = Math.min(techMatches * 15, 100);
|
|
661
678
|
|
|
662
679
|
// 4. Domain specificity (how many domains are touched)
|
package/src/routing/index.js
CHANGED
|
@@ -94,6 +94,7 @@ function _enabledProviders() {
|
|
|
94
94
|
if (config.openai?.apiKey) out.push('openai');
|
|
95
95
|
if (config.atlas?.apiKey) out.push('atlas');
|
|
96
96
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) out.push('azure-openai');
|
|
97
|
+
if (config.orcarouter?.apiKey) out.push('orcarouter');
|
|
97
98
|
if (config.ollama?.endpoint) out.push('ollama');
|
|
98
99
|
if (config.llamacpp?.endpoint) out.push('llamacpp');
|
|
99
100
|
if (config.lmstudio?.endpoint) out.push('lmstudio');
|
|
@@ -137,6 +138,7 @@ function getBestCloudProvider() {
|
|
|
137
138
|
if (config.openai?.apiKey) return 'openai';
|
|
138
139
|
if (config.atlas?.apiKey) return 'atlas';
|
|
139
140
|
if (config.azureOpenAI?.endpoint && config.azureOpenAI?.apiKey) return 'azure-openai';
|
|
141
|
+
if (config.orcarouter?.apiKey) return 'orcarouter';
|
|
140
142
|
|
|
141
143
|
return getFallbackProvider();
|
|
142
144
|
}
|
|
@@ -803,6 +805,17 @@ function checkSessionPin(payload, options = {}) {
|
|
|
803
805
|
const guards = _runPinGuards(payload, pin);
|
|
804
806
|
if (!guards.ok) return { serve: false, pin, sessionId, reason: guards.reason };
|
|
805
807
|
|
|
808
|
+
// Tier-less pins (legacy cache-only rows predating the session_cache_state
|
|
809
|
+
// split) must not drive tier decisions: drift math on a null tier never
|
|
810
|
+
// fires, so serving them locks the session with no escalation path.
|
|
811
|
+
// Exempted when ANY tool blocks exist anywhere in the payload: an
|
|
812
|
+
// unresolved tool_use breaks if the provider switches, so provider
|
|
813
|
+
// stickiness wins there (same safety rationale as the tool_history path
|
|
814
|
+
// above, which returns before reaching this check).
|
|
815
|
+
if (!pin.tier && !_payloadHasAnyToolBlocks(payload)) {
|
|
816
|
+
return { serve: false, pin, sessionId, reason: 'tierless_pin' };
|
|
817
|
+
}
|
|
818
|
+
|
|
806
819
|
if (refreshOk) {
|
|
807
820
|
sessionAffinity.setPin(sessionId, pin, {
|
|
808
821
|
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
@@ -1093,6 +1106,11 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1093
1106
|
analysis.score = intent.score;
|
|
1094
1107
|
analysis.scoreMode = intent.mode; // 'anchor' | 'lexical' (clean-text fallback)
|
|
1095
1108
|
analysis.anchorClass = intent.class ?? null;
|
|
1109
|
+
// Jev verdict rides alongside the score so the shortfall floor,
|
|
1110
|
+
// risk corroboration and telemetry can consume it downstream.
|
|
1111
|
+
// Null unless the Jev leg ran (pinned serves, side frames and
|
|
1112
|
+
// classifier skips leave no verdict — same as classifierResult).
|
|
1113
|
+
if (intent.jev && typeof intent.jev === 'object') analysis.jev = intent.jev;
|
|
1096
1114
|
intentScored = true;
|
|
1097
1115
|
logger.debug({
|
|
1098
1116
|
score: intent.score,
|
|
@@ -1211,12 +1229,47 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1211
1229
|
// (agentic minTier, context, vision, kNN-ambiguous) for telemetry.
|
|
1212
1230
|
let baseTier = null;
|
|
1213
1231
|
const escalations = [];
|
|
1232
|
+
let _jevOverrode = false;
|
|
1214
1233
|
if (config.modelTiers?.enabled) {
|
|
1215
1234
|
try {
|
|
1216
1235
|
const selector = getModelTierSelector();
|
|
1217
1236
|
tier = selector.getTier(analysis.score);
|
|
1218
1237
|
baseTier = tier;
|
|
1219
1238
|
|
|
1239
|
+
// Jev tier override: a high-confidence measured verdict replaces the
|
|
1240
|
+
// score-band tier in either direction (score bands stay the fallback
|
|
1241
|
+
// for Jev-null/low-confidence). Runs BEFORE agentic floors and risk
|
|
1242
|
+
// lifts so structural guarantees can still lift above the verdict —
|
|
1243
|
+
// never below it. Score is set to the tier midpoint so drift math,
|
|
1244
|
+
// badges and telemetry stay coherent with the decided tier.
|
|
1245
|
+
try {
|
|
1246
|
+
const { jevTierOverride } = require('./jev-router');
|
|
1247
|
+
const _ov = jevTierOverride({
|
|
1248
|
+
baseTier,
|
|
1249
|
+
jevTier: analysis.jev?.tier,
|
|
1250
|
+
jevConfidence: analysis.jev?.confidence,
|
|
1251
|
+
});
|
|
1252
|
+
if (_ov) {
|
|
1253
|
+
escalations.push({
|
|
1254
|
+
source: 'jev_override',
|
|
1255
|
+
fromTier: tier,
|
|
1256
|
+
toTier: _ov.tier,
|
|
1257
|
+
fromModel: null,
|
|
1258
|
+
toModel: null,
|
|
1259
|
+
});
|
|
1260
|
+
logger.debug({
|
|
1261
|
+
fromTier: tier,
|
|
1262
|
+
toTier: _ov.tier,
|
|
1263
|
+
jevConfidence: analysis.jev?.confidence ?? null,
|
|
1264
|
+
}, '[Routing] Jev tier override');
|
|
1265
|
+
tier = _ov.tier;
|
|
1266
|
+
if (typeof _ov.score === 'number') analysis.score = _ov.score;
|
|
1267
|
+
_jevOverrode = true;
|
|
1268
|
+
}
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
degradation.record('tier_select', err);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1220
1273
|
// Check if agentic detection requires a higher tier
|
|
1221
1274
|
if (agenticResult?.minTier) {
|
|
1222
1275
|
const agenticTierPriority = TIER_DEFINITIONS[agenticResult.minTier]?.priority || 0;
|
|
@@ -1235,6 +1288,30 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1235
1288
|
}
|
|
1236
1289
|
}
|
|
1237
1290
|
|
|
1291
|
+
// Jev risk corroboration (escalate-only): a high Noul on auth /
|
|
1292
|
+
// secrets / production / destructive content lifts SIMPLE/MEDIUM one
|
|
1293
|
+
// band when the keyword analyzer stayed quiet. Never clears a keyword
|
|
1294
|
+
// hit (that path returned far above), never jumps two bands, never
|
|
1295
|
+
// fires without a verdict (fail-soft null skips silently). Mirrors the
|
|
1296
|
+
// kNN-ambiguous escalate pattern with a ledger entry.
|
|
1297
|
+
try {
|
|
1298
|
+
const { jevRiskLift } = require('./jev-router');
|
|
1299
|
+
const _lifted = jevRiskLift({ tier, risky: analysis.jev?.risky, riskLevel: risk?.level });
|
|
1300
|
+
if (_lifted) {
|
|
1301
|
+
escalations.push({
|
|
1302
|
+
source: 'jev_risk',
|
|
1303
|
+
fromTier: tier,
|
|
1304
|
+
toTier: _lifted,
|
|
1305
|
+
fromModel: null,
|
|
1306
|
+
toModel: null,
|
|
1307
|
+
});
|
|
1308
|
+
logger.debug({ fromTier: tier, toTier: _lifted, risky: analysis.jev.risky }, '[Routing] Jev risk corroboration — one-band lift');
|
|
1309
|
+
tier = _lifted;
|
|
1310
|
+
}
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
degradation.record('tier_select', err);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1238
1315
|
// Select model for the tier (will be applied after provider selection)
|
|
1239
1316
|
analysis.tier = tier;
|
|
1240
1317
|
} catch (err) {
|
|
@@ -1245,7 +1322,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1245
1322
|
// Apply routing decision based on tier config (TIER_* env vars take precedence
|
|
1246
1323
|
// but Phase 1.2 lets the cost-optimizer pick a cheaper qualifying model when safe).
|
|
1247
1324
|
let provider;
|
|
1248
|
-
let method = 'tier_config';
|
|
1325
|
+
let method = _jevOverrode ? 'tier_config+jev_override' : 'tier_config';
|
|
1249
1326
|
let costOptimized = false;
|
|
1250
1327
|
|
|
1251
1328
|
const selector = getModelTierSelector();
|
|
@@ -1340,6 +1417,51 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1340
1417
|
degradation.record('shortfall', err);
|
|
1341
1418
|
}
|
|
1342
1419
|
|
|
1420
|
+
// Jev shortfall floor: a high-confidence Jev tier is a measured verdict,
|
|
1421
|
+
// not a heuristic guess — cheapest-covering must not demote below it.
|
|
1422
|
+
// Floor target is the stronger of (scored legacy pick, Jev verdict):
|
|
1423
|
+
// score-derived highs are never talked down by the judge, and judged
|
|
1424
|
+
// highs are never talked down by the price optimizer (see jevFloorTarget).
|
|
1425
|
+
// De-escalation below still runs after this, so evidence gates apply like
|
|
1426
|
+
// any other decision.
|
|
1427
|
+
try {
|
|
1428
|
+
const { jevFloorTarget } = require('./jev-router');
|
|
1429
|
+
const _floorTier = shortfallInfo && shortfallInfo.legacy
|
|
1430
|
+
? jevFloorTarget({
|
|
1431
|
+
selectedTier: tier,
|
|
1432
|
+
legacyTier: shortfallInfo.legacy.tier,
|
|
1433
|
+
jevTier: analysis.jev?.tier,
|
|
1434
|
+
jevConfidence: analysis.jev?.confidence,
|
|
1435
|
+
})
|
|
1436
|
+
: null;
|
|
1437
|
+
if (_floorTier) {
|
|
1438
|
+
const _floorSel = selector.selectModel(_floorTier, null);
|
|
1439
|
+
if (_floorSel && _floorSel.provider && _floorSel.model) {
|
|
1440
|
+
const _fromTier = tier;
|
|
1441
|
+
const _fromModel = selectedModel;
|
|
1442
|
+
provider = _floorSel.provider;
|
|
1443
|
+
selectedModel = _floorSel.model;
|
|
1444
|
+
tier = _floorTier;
|
|
1445
|
+
analysis.tier = tier;
|
|
1446
|
+
method = method + '+jev_floor_hold';
|
|
1447
|
+
escalations.push({
|
|
1448
|
+
source: 'jev_floor',
|
|
1449
|
+
fromTier: _fromTier,
|
|
1450
|
+
toTier: tier,
|
|
1451
|
+
fromModel: _fromModel,
|
|
1452
|
+
toModel: selectedModel,
|
|
1453
|
+
});
|
|
1454
|
+
logger.info({
|
|
1455
|
+
from: `${_fromTier}:${_fromModel}`,
|
|
1456
|
+
to: `${tier}:${selectedModel}`,
|
|
1457
|
+
jevConfidence: analysis.jev?.confidence ?? null,
|
|
1458
|
+
}, '[Routing] Jev floor hold — shortfall demotion refused');
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
degradation.record('shortfall', err);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1343
1465
|
// WS2.3 — evidence-based de-escalation.
|
|
1344
1466
|
//
|
|
1345
1467
|
// The check is intentionally gated by evidence, not a feature flag: the
|
|
@@ -338,9 +338,8 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
|
|
|
338
338
|
const anchorIdx = TIER_ORDER.indexOf(anchorTier);
|
|
339
339
|
const classifierIdx = TIER_ORDER.indexOf(classifierTier);
|
|
340
340
|
|
|
341
|
-
// Midpoints
|
|
342
|
-
|
|
343
|
-
const TIER_MIDPOINT = { SIMPLE: 10, MEDIUM: 35, COMPLEX: 63, REASONING: 88 };
|
|
341
|
+
// Midpoints live in model-tiers.js (single source of truth).
|
|
342
|
+
const { TIER_MIDPOINT } = require('./model-tiers');
|
|
344
343
|
|
|
345
344
|
if (classifierIdx < anchorIdx) {
|
|
346
345
|
// Classifier says LOWER tier — trust it. Fixes over-routing.
|
|
@@ -410,24 +409,47 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
410
409
|
const [lo, hi] = CLASS_BANDS[cls];
|
|
411
410
|
const anchorScore = Math.max(lo, Math.min(hi, blendScore(sims)));
|
|
412
411
|
|
|
413
|
-
//
|
|
414
|
-
// to keep unit tests hermetic. Runs live
|
|
412
|
+
// Jev routing judge — second opinion. Skipped in tests (opts.skipClassifier)
|
|
413
|
+
// to keep unit tests hermetic (or stubbed via opts.jevFetchFn). Runs live
|
|
414
|
+
// otherwise. Replaces the qwen difficulty classifier in the same result
|
|
415
|
+
// shape ({tier, confidence}) so _reconcile semantics are unchanged;
|
|
416
|
+
// Jev extras (probabilities, risky, model, criteriaHash) ride along
|
|
417
|
+
// for telemetry and the shortfall floor. Fail-soft null preserves the
|
|
418
|
+
// anchor-only path exactly.
|
|
419
|
+
//
|
|
420
|
+
// Hermeticity note: suites asserting deterministic routing scrub
|
|
421
|
+
// TYPESAFE_API_KEY (no key → Jev leg returns null before any fetch),
|
|
422
|
+
// the same posture as the repo's live-Ollama tolerance. Never gate
|
|
423
|
+
// on NODE_ENV — .env pins it to production in every context.
|
|
415
424
|
let classifierResult = null;
|
|
425
|
+
let jevResult = null;
|
|
416
426
|
if (!opts.skipClassifier) {
|
|
417
427
|
try {
|
|
418
|
-
const {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
428
|
+
const { classifyJev } = require('./jev-router');
|
|
429
|
+
const msgs = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
430
|
+
const toolDefs = Array.isArray(payload?.tools) ? payload.tools : [];
|
|
431
|
+
const toolResults = msgs.filter((m) =>
|
|
432
|
+
m?.role === 'user' && Array.isArray(m.content) && m.content.some((c) => c?.type === 'tool_result')
|
|
433
|
+
).length;
|
|
434
|
+
const jevFetchFn = opts.jevFetchFn;
|
|
435
|
+
jevResult = await classifyJev(text, {
|
|
425
436
|
context: typeof payload?._conversationContext === 'string'
|
|
426
437
|
? payload._conversationContext
|
|
427
438
|
: null,
|
|
439
|
+
signals: {
|
|
440
|
+
message_count: msgs.length,
|
|
441
|
+
tools_attached: toolDefs.length,
|
|
442
|
+
effective_tools: toolDefs.length,
|
|
443
|
+
has_tool_history: toolResults > 0,
|
|
444
|
+
session_turn: Math.max(1, Math.ceil(msgs.length / 2)),
|
|
445
|
+
},
|
|
446
|
+
fetchFn: jevFetchFn,
|
|
428
447
|
});
|
|
448
|
+
if (jevResult) {
|
|
449
|
+
classifierResult = { tier: jevResult.tier, confidence: jevResult.confidence };
|
|
450
|
+
}
|
|
429
451
|
} catch (err) {
|
|
430
|
-
logger.debug({ err: err.message }, '[IntentScore] classifier failed — anchor only');
|
|
452
|
+
logger.debug({ err: err.message }, '[IntentScore] Jev classifier failed — anchor only');
|
|
431
453
|
}
|
|
432
454
|
}
|
|
433
455
|
|
|
@@ -453,6 +475,19 @@ async function scoreIntent(payload, opts = {}) {
|
|
|
453
475
|
classifierTier: classifierResult?.tier ?? null,
|
|
454
476
|
classifierConfidence: classifierResult?.confidence ?? null,
|
|
455
477
|
reconciled,
|
|
478
|
+
// Jev verdict details for telemetry + shortfall floor downstream.
|
|
479
|
+
// Null unless the Jev leg ran (same conditions as classifierResult,
|
|
480
|
+
// plus LRU-cached repeats which still carry the original extras).
|
|
481
|
+
jev: jevResult ? {
|
|
482
|
+
tier: jevResult.tier,
|
|
483
|
+
confidence: jevResult.confidence,
|
|
484
|
+
probabilities: jevResult.probabilities ?? null,
|
|
485
|
+
risky: jevResult.risky ?? null,
|
|
486
|
+
model: jevResult.model ?? null,
|
|
487
|
+
criteriaHash: jevResult.criteriaHash ?? null,
|
|
488
|
+
latencyMs: jevResult.latencyMs ?? null,
|
|
489
|
+
cached: !!jevResult.cached,
|
|
490
|
+
} : null,
|
|
456
491
|
});
|
|
457
492
|
}
|
|
458
493
|
}
|