@cloudpeers-jkl/model-router 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/adapters/anthropic.d.ts +7 -0
- package/dist/adapters/anthropic.js +121 -0
- package/dist/adapters/gemini.d.ts +40 -0
- package/dist/adapters/gemini.js +151 -0
- package/dist/adapters/selfhosted.d.ts +14 -0
- package/dist/adapters/selfhosted.js +96 -0
- package/dist/capabilities.d.ts +18 -0
- package/dist/capabilities.js +14 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +32 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +22 -0
- package/dist/policy.d.ts +35 -0
- package/dist/policy.js +48 -0
- package/dist/router.d.ts +36 -0
- package/dist/router.js +328 -0
- package/dist/schema.d.ts +335 -0
- package/dist/schema.js +68 -0
- package/dist/types.d.ts +256 -0
- package/dist/types.js +16 -0
- package/package.json +33 -0
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy (the central control plane, distributed as deploy-time config).
|
|
3
|
+
*
|
|
4
|
+
* Tier table per the 2026-07-09 resolutions (supersedes the April spec table):
|
|
5
|
+
* Tier 0 — self-hosted in-boundary (OpenMed extraction / Nemotron pending
|
|
6
|
+
* the Q3 eval; the ONLY tier permitted for local_only once the
|
|
7
|
+
* hard gate flips)
|
|
8
|
+
* Tier 1 — Gemini Flash (fast, non-PHI; local_only only under Vertex BAA)
|
|
9
|
+
* Tier 2 — Claude (primary reasoning; local_only only under BAA)
|
|
10
|
+
* No OpenAI tier, by directive.
|
|
11
|
+
*
|
|
12
|
+
* Privacy gate — interim cloud-PHI policy (decision 2026-07-09), three stages:
|
|
13
|
+
* Stage 1 MONITOR (default): local_only → cloud logs `would_block`, proceeds.
|
|
14
|
+
* Stage 2 governed egress: providers listed in MODEL_ROUTER_BAA_PROVIDERS
|
|
15
|
+
* (set ONLY once the instrument is executed) are
|
|
16
|
+
* permitted for local_only.
|
|
17
|
+
* Stage 3 enforce: MODEL_PRIVACY_GATE_ENFORCE=true — local_only is
|
|
18
|
+
* Tier-0-or-BAA only, fail closed.
|
|
19
|
+
*/
|
|
20
|
+
import type { GateOutcome, Provider, SovereigntyClass, TaskClass, TierDef } from './types.js';
|
|
21
|
+
export declare const TIERS: Record<number, TierDef>;
|
|
22
|
+
/** Static routing table (confidence v1): tier chain per task class, in order. */
|
|
23
|
+
export declare const TASK_ROUTES: Record<TaskClass, number[]>;
|
|
24
|
+
export declare function gateEnforced(): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The privacy gate. Evaluated per tier, before any call.
|
|
27
|
+
* Never triggers for aggregate_only/externalizable (spec scope: local_only).
|
|
28
|
+
*/
|
|
29
|
+
export declare function evaluateGate(sovereigntyClass: SovereigntyClass, tier: TierDef): GateOutcome;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the tier chain for a request: the static task-class chain,
|
|
32
|
+
* optionally reordered by an advisory backend_hint (§4). The hint never adds
|
|
33
|
+
* a tier the task class wouldn't reach and never bypasses the gate.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveChain(taskClass: TaskClass, backendHint?: Provider): number[];
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const TIERS = {
|
|
2
|
+
0: { tier: 0, provider: 'selfhosted', model: process.env.MODEL_ROUTER_TIER0_MODEL || 'openmed-extraction', cloud: false },
|
|
3
|
+
1: { tier: 1, provider: 'gemini', model: process.env.MODEL_ROUTER_GEMINI_MODEL || 'gemini-2.0-flash-exp', cloud: true },
|
|
4
|
+
2: { tier: 2, provider: 'anthropic', model: process.env.MODEL_ROUTER_ANTHROPIC_MODEL || 'claude-haiku-4-5', cloud: true },
|
|
5
|
+
};
|
|
6
|
+
/** Static routing table (confidence v1): tier chain per task class, in order. */
|
|
7
|
+
export const TASK_ROUTES = {
|
|
8
|
+
extraction: [0, 1],
|
|
9
|
+
classification: [0, 1],
|
|
10
|
+
coaching: [1, 2],
|
|
11
|
+
reasoning: [2, 1],
|
|
12
|
+
synthesis: [2, 1],
|
|
13
|
+
};
|
|
14
|
+
function baaProviders() {
|
|
15
|
+
return new Set((process.env.MODEL_ROUTER_BAA_PROVIDERS ?? '')
|
|
16
|
+
.split(',')
|
|
17
|
+
.map((p) => p.trim().toLowerCase())
|
|
18
|
+
.filter(Boolean));
|
|
19
|
+
}
|
|
20
|
+
export function gateEnforced() {
|
|
21
|
+
return process.env.MODEL_PRIVACY_GATE_ENFORCE === 'true';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The privacy gate. Evaluated per tier, before any call.
|
|
25
|
+
* Never triggers for aggregate_only/externalizable (spec scope: local_only).
|
|
26
|
+
*/
|
|
27
|
+
export function evaluateGate(sovereigntyClass, tier) {
|
|
28
|
+
if (sovereigntyClass !== 'local_only' || !tier.cloud)
|
|
29
|
+
return 'allowed';
|
|
30
|
+
if (baaProviders().has(tier.provider))
|
|
31
|
+
return 'allowed'; // Stage 2 governed egress
|
|
32
|
+
return gateEnforced() ? 'blocked' : 'would_block'; // Stage 3 : Stage 1
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the tier chain for a request: the static task-class chain,
|
|
36
|
+
* optionally reordered by an advisory backend_hint (§4). The hint never adds
|
|
37
|
+
* a tier the task class wouldn't reach and never bypasses the gate.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveChain(taskClass, backendHint) {
|
|
40
|
+
const chain = TASK_ROUTES[taskClass];
|
|
41
|
+
if (!chain?.length)
|
|
42
|
+
throw new Error(`Unknown task class: ${taskClass}`);
|
|
43
|
+
if (!backendHint)
|
|
44
|
+
return chain;
|
|
45
|
+
const hinted = chain.filter((t) => TIERS[t].provider === backendHint);
|
|
46
|
+
const rest = chain.filter((t) => TIERS[t].provider !== backendHint);
|
|
47
|
+
return [...hinted, ...rest];
|
|
48
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AnthropicSSEEvent, GateOutcome, MessagesRouteResult, RouterDeps, RouterRequest, RouterResult, TierDef } from './types.js';
|
|
2
|
+
export interface RouteContext {
|
|
3
|
+
userId: string;
|
|
4
|
+
serviceId: string;
|
|
5
|
+
/** Metering label (token-tracker `operation`). */
|
|
6
|
+
operation: string;
|
|
7
|
+
metadata?: Record<string, unknown>;
|
|
8
|
+
/** Confidence v1: validate the response text; failure escalates a tier. */
|
|
9
|
+
validate?: (text: string) => boolean;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Spec API — route an Anthropic-shape request with a `cloudpeers` envelope
|
|
14
|
+
* (§4). Returns the full Anthropic response plus routing telemetry.
|
|
15
|
+
*/
|
|
16
|
+
export declare function routeMessages(input: unknown, ctx: RouteContext, deps: RouterDeps): Promise<MessagesRouteResult>;
|
|
17
|
+
export interface MessagesStreamResult {
|
|
18
|
+
provider: TierDef['provider'];
|
|
19
|
+
model: string;
|
|
20
|
+
tier: number;
|
|
21
|
+
gate: GateOutcome;
|
|
22
|
+
/** Anthropic SSE event sequence. Usage is metered when the stream completes. */
|
|
23
|
+
stream: AsyncIterable<AnthropicSSEEvent>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Streaming variant. Pre-call enforcement is identical; the first eligible
|
|
27
|
+
* streaming-capable tier serves the request (no mid-stream escalation — a
|
|
28
|
+
* failed stream surfaces to the caller).
|
|
29
|
+
*/
|
|
30
|
+
export declare function routeMessagesStream(input: unknown, ctx: RouteContext, deps: RouterDeps): Promise<MessagesStreamResult>;
|
|
31
|
+
/**
|
|
32
|
+
* v1 convenience API — prompt in, text out. Behavior-identical to
|
|
33
|
+
* ModelRouter v1 (first caller: mcp lab-coaching); implemented over
|
|
34
|
+
* routeMessages so both APIs share one enforcement path.
|
|
35
|
+
*/
|
|
36
|
+
export declare function routeModel(req: RouterRequest, deps: RouterDeps): Promise<RouterResult>;
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routing core (in-process; no data plane service).
|
|
3
|
+
*
|
|
4
|
+
* Flow per request: validate envelope (§4/§7.1 #1) → mandate + policy hooks
|
|
5
|
+
* (§7.1 #2/#4, fail closed when named but unwired) → quota → resolve the
|
|
6
|
+
* task-class tier chain → per tier: privacy gate (monitor/BAA/enforce) →
|
|
7
|
+
* adapter availability + eligibility → invoke → post-call hooks (§7.2,
|
|
8
|
+
* observation-only) → optional validation (confidence v1: failure escalates)
|
|
9
|
+
* → uniform metering via the injected meter.
|
|
10
|
+
* Fail-closed: if enforcement blocks every tier, RouterBlockedError — never a
|
|
11
|
+
* silent fallback to an unapproved path.
|
|
12
|
+
*
|
|
13
|
+
* Monitor-gate log lines ([privacy-gate-monitor] / [privacy-gate]) are a
|
|
14
|
+
* stable interface: the Stage-3 enforcement flip decision is made from them.
|
|
15
|
+
*/
|
|
16
|
+
import { RouterBlockedError } from './errors.js';
|
|
17
|
+
import { evaluateGate, resolveChain, TIERS } from './policy.js';
|
|
18
|
+
import { parseRouterRequest } from './schema.js';
|
|
19
|
+
function firstText(content) {
|
|
20
|
+
return content.find((b) => b.type === 'text')?.text ?? '';
|
|
21
|
+
}
|
|
22
|
+
function promptChars(req) {
|
|
23
|
+
let chars = req.system?.length ?? 0;
|
|
24
|
+
for (const m of req.messages) {
|
|
25
|
+
chars +=
|
|
26
|
+
typeof m.content === 'string'
|
|
27
|
+
? m.content.length
|
|
28
|
+
: m.content.reduce((n, b) => n + (b.type === 'text' ? b.text.length : JSON.stringify(b).length), 0);
|
|
29
|
+
}
|
|
30
|
+
return chars;
|
|
31
|
+
}
|
|
32
|
+
/** §4 — the envelope never appears in upstream provider traffic. */
|
|
33
|
+
function stripEnvelope(req, model) {
|
|
34
|
+
const { cloudpeers: _cloudpeers, stream: _stream, ...rest } = req;
|
|
35
|
+
return { ...rest, model };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* §7.1 #2/#4 + A1.2 — pre-call enforcement that applies to the request as a
|
|
39
|
+
* whole. Fail closed: a named instrument with no wired verifier blocks.
|
|
40
|
+
*/
|
|
41
|
+
async function enforcePreCall(env, deps) {
|
|
42
|
+
if (env.byok) {
|
|
43
|
+
throw new RouterBlockedError('BYOK routing is deferred pending policy review (Amendment A1.2)', 501, 'byok_deferred');
|
|
44
|
+
}
|
|
45
|
+
if (env.mandate_id) {
|
|
46
|
+
if (!deps.hooks?.verifyMandate) {
|
|
47
|
+
throw new RouterBlockedError(`mandate_id ${env.mandate_id} present but no mandate verifier is wired (fail closed)`, 412, 'mandate_verifier_unavailable');
|
|
48
|
+
}
|
|
49
|
+
const verdict = await deps.hooks.verifyMandate(env.mandate_id);
|
|
50
|
+
if (!verdict.valid) {
|
|
51
|
+
if (verdict.reason === 'expired')
|
|
52
|
+
throw new RouterBlockedError(`Mandate ${env.mandate_id} is expired`, 410, 'mandate_expired');
|
|
53
|
+
if (verdict.reason === 'revoked')
|
|
54
|
+
throw new RouterBlockedError(`Mandate ${env.mandate_id} is revoked`, 409, 'mandate_revoked');
|
|
55
|
+
throw new RouterBlockedError(`Mandate ${env.mandate_id} is invalid: ${verdict.reason}`, 412, 'mandate_invalid');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (env.policy_id && !deps.hooks?.applyPolicy) {
|
|
59
|
+
throw new RouterBlockedError(`policy_id ${env.policy_id} present but no policy engine is wired (fail closed)`, 412, 'policy_engine_unavailable');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function checkQuotaOnce(req, ctx, deps) {
|
|
63
|
+
const quotaCheck = await deps.quota(ctx.userId, Math.ceil(promptChars(req) / 4));
|
|
64
|
+
if (!quotaCheck.allowed) {
|
|
65
|
+
throw new Error(`Token quota exceeded. ${quotaCheck.remainingTokens} tokens remaining. Quota resets at ${quotaCheck.quotaResetAt}.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Walk the chain applying availability, the privacy gate, per-backend policy,
|
|
70
|
+
* and adapter eligibility (§3.3/§7.1 #3-4, §8.2). Yields callable candidates
|
|
71
|
+
* in order; records why each skipped tier was skipped.
|
|
72
|
+
*/
|
|
73
|
+
async function* eligibleTiers(req, ctx, deps, state) {
|
|
74
|
+
const env = req.cloudpeers;
|
|
75
|
+
const chain = resolveChain(env.task_class, env.backend_hint);
|
|
76
|
+
for (const tierNo of chain) {
|
|
77
|
+
const tier = TIERS[tierNo];
|
|
78
|
+
const adapter = deps.adapters[tier.provider];
|
|
79
|
+
if (!adapter?.available())
|
|
80
|
+
continue; // tier not deployed (e.g. Tier 0 pre-OpenMed) — next
|
|
81
|
+
const gate = evaluateGate(env.sovereignty_class, tier);
|
|
82
|
+
if (gate === 'blocked') {
|
|
83
|
+
console.warn(`[privacy-gate] BLOCKED local_only → tier${tier.tier}/${tier.provider} (op=${ctx.operation} svc=${ctx.serviceId})`);
|
|
84
|
+
continue; // fail closed on this tier
|
|
85
|
+
}
|
|
86
|
+
if (gate === 'would_block') {
|
|
87
|
+
state.requestGate = 'would_block';
|
|
88
|
+
// Stage 1 MONITOR: the log line the Stage-3 flip decision is made from.
|
|
89
|
+
console.warn(`[privacy-gate-monitor] would_block local_only → tier${tier.tier}/${tier.provider} (op=${ctx.operation} svc=${ctx.serviceId} task=${env.task_class})`);
|
|
90
|
+
}
|
|
91
|
+
if (env.policy_id && deps.hooks?.applyPolicy) {
|
|
92
|
+
const verdict = await deps.hooks.applyPolicy(env.policy_id, {
|
|
93
|
+
backend_id: tier.provider,
|
|
94
|
+
model: tier.model,
|
|
95
|
+
tier: tier.tier,
|
|
96
|
+
});
|
|
97
|
+
if (!verdict.allowed) {
|
|
98
|
+
console.warn(`[model-router] policy ${env.policy_id} denied tier${tier.tier}/${tier.provider}: ${verdict.reason}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// §8.2 — a backend that cannot satisfy the request's tool use is skipped,
|
|
103
|
+
// never silently degraded (dropping tool calls entirely is not "degraded").
|
|
104
|
+
if (req.tools?.length && !adapter.supports_tool_use) {
|
|
105
|
+
if (env.tool_use_strict)
|
|
106
|
+
state.toolStrictViolation = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const eligibility = await adapter.isEligible(req, {
|
|
110
|
+
sovereignty_class: env.sovereignty_class,
|
|
111
|
+
tier: tier.tier,
|
|
112
|
+
gate,
|
|
113
|
+
});
|
|
114
|
+
if (!eligibility.eligible) {
|
|
115
|
+
const reason = eligibility.reason ?? 'ineligible';
|
|
116
|
+
console.warn(`[model-router] tier${tier.tier}/${tier.provider} ineligible: ${reason}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
yield { tier, adapter, gate };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function exhaustedError(env, state, lastError) {
|
|
123
|
+
if (state.toolStrictViolation) {
|
|
124
|
+
return new RouterBlockedError(`tool_use_strict: no eligible backend supports the requested tool use for ${env.task_class}`, 422, 'tool_use_unsupported');
|
|
125
|
+
}
|
|
126
|
+
if (state.requestGate !== 'would_block' && env.sovereignty_class === 'local_only') {
|
|
127
|
+
return new RouterBlockedError(`No permitted tier for local_only ${env.task_class} (gate enforced and Tier 0 unavailable)`);
|
|
128
|
+
}
|
|
129
|
+
return lastError ?? new Error(`No available tier for task class ${env.task_class}`);
|
|
130
|
+
}
|
|
131
|
+
/** §7.2 post-call hooks + §10 uniform metering. Observation-only; never gates. */
|
|
132
|
+
async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps, state) {
|
|
133
|
+
const env = req.cloudpeers;
|
|
134
|
+
const usage = {
|
|
135
|
+
...candidate.adapter.reportUsage(stripped, response),
|
|
136
|
+
latency_ms: latencyMs,
|
|
137
|
+
sovereignty_class: env.sovereignty_class,
|
|
138
|
+
};
|
|
139
|
+
let outcomeMatches;
|
|
140
|
+
if (env.mandate_id && deps.hooks?.verifyOutcome) {
|
|
141
|
+
try {
|
|
142
|
+
outcomeMatches = (await deps.hooks.verifyOutcome(env.mandate_id, response)).outcome_matches_mandate;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
console.warn('[model-router] outcome verification failed (non-blocking):', err?.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (env.attribution_chain) {
|
|
149
|
+
if (deps.hooks?.writeAttribution) {
|
|
150
|
+
await deps.hooks
|
|
151
|
+
.writeAttribution([...env.attribution_chain, `model-router:${usage.backend_id}`], usage)
|
|
152
|
+
.catch((err) => console.warn('[model-router] attribution write failed (non-blocking):', err?.message));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
console.warn('[model-router] attribution_chain present but no attribution writer is wired');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const gate = state.requestGate === 'would_block' ? 'would_block' : candidate.gate;
|
|
159
|
+
await deps
|
|
160
|
+
.meter({
|
|
161
|
+
userId: ctx.userId,
|
|
162
|
+
serviceId: ctx.serviceId,
|
|
163
|
+
model: candidate.tier.model,
|
|
164
|
+
operation: ctx.operation,
|
|
165
|
+
promptTokens: usage.tokens_in,
|
|
166
|
+
completionTokens: usage.tokens_out,
|
|
167
|
+
metadata: {
|
|
168
|
+
...ctx.metadata,
|
|
169
|
+
router: {
|
|
170
|
+
provider: candidate.tier.provider,
|
|
171
|
+
tier: candidate.tier.tier,
|
|
172
|
+
taskClass: env.task_class,
|
|
173
|
+
gate,
|
|
174
|
+
...(outcomeMatches !== undefined ? { outcome_matches_mandate: outcomeMatches } : {}),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
.catch((err) => console.error('[model-router] metering failed (non-blocking):', err?.message));
|
|
179
|
+
return usage;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Spec API — route an Anthropic-shape request with a `cloudpeers` envelope
|
|
183
|
+
* (§4). Returns the full Anthropic response plus routing telemetry.
|
|
184
|
+
*/
|
|
185
|
+
export async function routeMessages(input, ctx, deps) {
|
|
186
|
+
const req = parseRouterRequest(input);
|
|
187
|
+
const env = req.cloudpeers;
|
|
188
|
+
await enforcePreCall(env, deps);
|
|
189
|
+
await checkQuotaOnce(req, ctx, deps);
|
|
190
|
+
const state = { requestGate: 'allowed', toolStrictViolation: false };
|
|
191
|
+
let escalations = 0;
|
|
192
|
+
let lastError = null;
|
|
193
|
+
let bestEffort = null;
|
|
194
|
+
for await (const candidate of eligibleTiers(req, ctx, deps, state)) {
|
|
195
|
+
const stripped = stripEnvelope(req, candidate.tier.model);
|
|
196
|
+
const started = performance.now();
|
|
197
|
+
try {
|
|
198
|
+
const response = await candidate.adapter.invoke(stripped, {
|
|
199
|
+
userId: ctx.userId,
|
|
200
|
+
serviceId: ctx.serviceId,
|
|
201
|
+
operation: ctx.operation,
|
|
202
|
+
signal: ctx.signal,
|
|
203
|
+
});
|
|
204
|
+
const usage = await postCall(candidate, stripped, response, Math.round(performance.now() - started), req, ctx, deps, state);
|
|
205
|
+
const result = {
|
|
206
|
+
response,
|
|
207
|
+
provider: candidate.tier.provider,
|
|
208
|
+
model: candidate.tier.model,
|
|
209
|
+
tier: candidate.tier.tier,
|
|
210
|
+
escalations,
|
|
211
|
+
degraded: false,
|
|
212
|
+
gate: state.requestGate === 'would_block' ? 'would_block' : candidate.gate,
|
|
213
|
+
usage,
|
|
214
|
+
};
|
|
215
|
+
if (ctx.validate && !ctx.validate(firstText(response.content))) {
|
|
216
|
+
// Confidence v1: shape validation failed — keep as best-effort, escalate.
|
|
217
|
+
bestEffort = { ...result, degraded: true };
|
|
218
|
+
escalations++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
lastError = err;
|
|
225
|
+
console.warn(`[model-router] tier${candidate.tier.tier}/${candidate.tier.provider} failed (op=${ctx.operation}): ${lastError.message}`);
|
|
226
|
+
escalations++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (bestEffort)
|
|
230
|
+
return { ...bestEffort, escalations };
|
|
231
|
+
throw exhaustedError(env, state, lastError);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Streaming variant. Pre-call enforcement is identical; the first eligible
|
|
235
|
+
* streaming-capable tier serves the request (no mid-stream escalation — a
|
|
236
|
+
* failed stream surfaces to the caller).
|
|
237
|
+
*/
|
|
238
|
+
export async function routeMessagesStream(input, ctx, deps) {
|
|
239
|
+
const req = parseRouterRequest(input);
|
|
240
|
+
const env = req.cloudpeers;
|
|
241
|
+
await enforcePreCall(env, deps);
|
|
242
|
+
await checkQuotaOnce(req, ctx, deps);
|
|
243
|
+
const state = { requestGate: 'allowed', toolStrictViolation: false };
|
|
244
|
+
for await (const candidate of eligibleTiers(req, ctx, deps, state)) {
|
|
245
|
+
if (!candidate.adapter.supports_streaming)
|
|
246
|
+
continue;
|
|
247
|
+
const stripped = stripEnvelope(req, candidate.tier.model);
|
|
248
|
+
const gate = state.requestGate === 'would_block' ? 'would_block' : candidate.gate;
|
|
249
|
+
const started = performance.now();
|
|
250
|
+
const events = candidate.adapter.invokeStream(stripped, {
|
|
251
|
+
userId: ctx.userId,
|
|
252
|
+
serviceId: ctx.serviceId,
|
|
253
|
+
operation: ctx.operation,
|
|
254
|
+
signal: ctx.signal,
|
|
255
|
+
});
|
|
256
|
+
const metered = async function* () {
|
|
257
|
+
let inputTokens = 0;
|
|
258
|
+
let outputTokens = 0;
|
|
259
|
+
let stopReason = null;
|
|
260
|
+
for await (const event of events) {
|
|
261
|
+
if (event.type === 'message_start') {
|
|
262
|
+
const usage = event.message?.usage;
|
|
263
|
+
inputTokens = usage?.input_tokens ?? 0;
|
|
264
|
+
}
|
|
265
|
+
if (event.type === 'message_delta') {
|
|
266
|
+
const usage = event.usage;
|
|
267
|
+
if (usage?.output_tokens !== undefined)
|
|
268
|
+
outputTokens = usage.output_tokens;
|
|
269
|
+
if (usage?.input_tokens !== undefined)
|
|
270
|
+
inputTokens = usage.input_tokens;
|
|
271
|
+
stopReason = event.delta?.stop_reason ?? stopReason;
|
|
272
|
+
}
|
|
273
|
+
yield event;
|
|
274
|
+
}
|
|
275
|
+
const response = {
|
|
276
|
+
id: 'streamed',
|
|
277
|
+
type: 'message',
|
|
278
|
+
role: 'assistant',
|
|
279
|
+
content: [],
|
|
280
|
+
model: candidate.tier.model,
|
|
281
|
+
stop_reason: stopReason,
|
|
282
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
283
|
+
};
|
|
284
|
+
await postCall(candidate, stripped, response, Math.round(performance.now() - started), req, ctx, deps, state);
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
provider: candidate.tier.provider,
|
|
288
|
+
model: candidate.tier.model,
|
|
289
|
+
tier: candidate.tier.tier,
|
|
290
|
+
gate,
|
|
291
|
+
stream: metered(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
throw exhaustedError(env, state, null);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* v1 convenience API — prompt in, text out. Behavior-identical to
|
|
298
|
+
* ModelRouter v1 (first caller: mcp lab-coaching); implemented over
|
|
299
|
+
* routeMessages so both APIs share one enforcement path.
|
|
300
|
+
*/
|
|
301
|
+
export async function routeModel(req, deps) {
|
|
302
|
+
const request = {
|
|
303
|
+
messages: [{ role: 'user', content: req.prompt }],
|
|
304
|
+
...(req.system !== undefined ? { system: req.system } : {}),
|
|
305
|
+
...(req.maxTokens !== undefined ? { max_tokens: req.maxTokens } : {}),
|
|
306
|
+
...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
|
|
307
|
+
cloudpeers: { v: 1, sovereignty_class: req.sovereigntyClass, task_class: req.taskClass },
|
|
308
|
+
};
|
|
309
|
+
const result = await routeMessages(request, {
|
|
310
|
+
userId: req.userId,
|
|
311
|
+
serviceId: req.serviceId,
|
|
312
|
+
operation: req.operation,
|
|
313
|
+
metadata: req.metadata,
|
|
314
|
+
validate: req.validate,
|
|
315
|
+
}, deps);
|
|
316
|
+
return {
|
|
317
|
+
text: firstText(result.response.content),
|
|
318
|
+
provider: result.provider,
|
|
319
|
+
model: result.model,
|
|
320
|
+
tier: result.tier,
|
|
321
|
+
escalations: result.escalations,
|
|
322
|
+
degraded: result.degraded,
|
|
323
|
+
gate: result.gate,
|
|
324
|
+
promptTokens: result.usage.tokens_in,
|
|
325
|
+
completionTokens: result.usage.tokens_out,
|
|
326
|
+
totalTokens: result.usage.tokens_in + result.usage.tokens_out,
|
|
327
|
+
};
|
|
328
|
+
}
|