@cloudpeers-jkl/model-router 0.2.0 → 0.2.2
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 +6 -4
- package/dist/adapters/anthropic.js +4 -2
- package/dist/policy.d.ts +7 -0
- package/dist/policy.js +14 -1
- package/dist/router.js +11 -7
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -68,7 +68,9 @@ plus the provider keys (`ANTHROPIC_API_KEY`, `GEMINI_SERVER_API_KEY`/`GEMINI_API
|
|
|
68
68
|
|
|
69
69
|
## Publishing
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
Published on npm since 2026-07-15 (`@cloudpeers-jkl/model-router`, current
|
|
72
|
+
0.2.1); carepeers consumes the npm package. mcp itself consumes this source
|
|
73
|
+
in-repo via relative imports (see `server/lib/model-router.ts`) — source and
|
|
74
|
+
package must not drift, so publish after any change here:
|
|
75
|
+
`npm publish --access public` from this directory (`prepublishOnly` builds
|
|
76
|
+
`dist/`; requires JKL's npm browser 2FA).
|
|
@@ -48,8 +48,10 @@ export function anthropicAdapter() {
|
|
|
48
48
|
body: buildBody(req, false),
|
|
49
49
|
signal: controller.signal,
|
|
50
50
|
});
|
|
51
|
-
if (!res.ok)
|
|
52
|
-
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const detail = await res.text().then((t) => t.slice(0, 300)).catch(() => '');
|
|
53
|
+
throw new Error(`Anthropic ${res.status}${detail ? `: ${detail}` : ''}`);
|
|
54
|
+
}
|
|
53
55
|
const data = (await res.json());
|
|
54
56
|
if (!data.content?.length)
|
|
55
57
|
throw new Error('Anthropic returned empty content');
|
package/dist/policy.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export declare function gateEnforced(): boolean;
|
|
|
27
27
|
* Never triggers for aggregate_only/externalizable (spec scope: local_only).
|
|
28
28
|
*/
|
|
29
29
|
export declare function evaluateGate(sovereigntyClass: SovereigntyClass, tier: TierDef): GateOutcome;
|
|
30
|
+
/**
|
|
31
|
+
* §4 — the caller's `model` is honored when it belongs to the selected
|
|
32
|
+
* tier's provider family (a gemini caller may pick flash vs pro); otherwise
|
|
33
|
+
* the tier's policy model serves. Tier 0 always serves its own model — the
|
|
34
|
+
* self-hosted endpoint decides what it hosts, not the caller.
|
|
35
|
+
*/
|
|
36
|
+
export declare function modelMatchesProvider(model: string, provider: Provider): boolean;
|
|
30
37
|
/**
|
|
31
38
|
* Resolve the tier chain for a request: the static task-class chain,
|
|
32
39
|
* optionally reordered by an advisory backend_hint (§4). The hint never adds
|
package/dist/policy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const TIERS = {
|
|
2
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.
|
|
3
|
+
1: { tier: 1, provider: 'gemini', model: process.env.MODEL_ROUTER_GEMINI_MODEL || 'gemini-2.5-flash', cloud: true },
|
|
4
4
|
2: { tier: 2, provider: 'anthropic', model: process.env.MODEL_ROUTER_ANTHROPIC_MODEL || 'claude-haiku-4-5', cloud: true },
|
|
5
5
|
};
|
|
6
6
|
/** Static routing table (confidence v1): tier chain per task class, in order. */
|
|
@@ -31,6 +31,19 @@ export function evaluateGate(sovereigntyClass, tier) {
|
|
|
31
31
|
return 'allowed'; // Stage 2 governed egress
|
|
32
32
|
return gateEnforced() ? 'blocked' : 'would_block'; // Stage 3 : Stage 1
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* §4 — the caller's `model` is honored when it belongs to the selected
|
|
36
|
+
* tier's provider family (a gemini caller may pick flash vs pro); otherwise
|
|
37
|
+
* the tier's policy model serves. Tier 0 always serves its own model — the
|
|
38
|
+
* self-hosted endpoint decides what it hosts, not the caller.
|
|
39
|
+
*/
|
|
40
|
+
export function modelMatchesProvider(model, provider) {
|
|
41
|
+
if (provider === 'gemini')
|
|
42
|
+
return /^(models\/)?gemini/i.test(model);
|
|
43
|
+
if (provider === 'anthropic')
|
|
44
|
+
return /^claude/i.test(model);
|
|
45
|
+
return false; // selfhosted: never caller-selected
|
|
46
|
+
}
|
|
34
47
|
/**
|
|
35
48
|
* Resolve the tier chain for a request: the static task-class chain,
|
|
36
49
|
* optionally reordered by an advisory backend_hint (§4). The hint never adds
|
package/dist/router.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* stable interface: the Stage-3 enforcement flip decision is made from them.
|
|
15
15
|
*/
|
|
16
16
|
import { RouterBlockedError } from './errors.js';
|
|
17
|
-
import { evaluateGate, resolveChain, TIERS } from './policy.js';
|
|
17
|
+
import { evaluateGate, modelMatchesProvider, resolveChain, TIERS } from './policy.js';
|
|
18
18
|
import { parseRouterRequest } from './schema.js';
|
|
19
19
|
function firstText(content) {
|
|
20
20
|
return content.find((b) => b.type === 'text')?.text ?? '';
|
|
@@ -34,6 +34,10 @@ function stripEnvelope(req, model) {
|
|
|
34
34
|
const { cloudpeers: _cloudpeers, stream: _stream, ...rest } = req;
|
|
35
35
|
return { ...rest, model };
|
|
36
36
|
}
|
|
37
|
+
/** §4 — caller model honored when it belongs to the tier's provider family. */
|
|
38
|
+
function servedModel(req, tier) {
|
|
39
|
+
return req.model && modelMatchesProvider(req.model, tier.provider) ? req.model : tier.model;
|
|
40
|
+
}
|
|
37
41
|
/**
|
|
38
42
|
* §7.1 #2/#4 + A1.2 — pre-call enforcement that applies to the request as a
|
|
39
43
|
* whole. Fail closed: a named instrument with no wired verifier blocks.
|
|
@@ -160,7 +164,7 @@ async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps
|
|
|
160
164
|
.meter({
|
|
161
165
|
userId: ctx.userId,
|
|
162
166
|
serviceId: ctx.serviceId,
|
|
163
|
-
model: candidate.tier.model,
|
|
167
|
+
model: stripped.model ?? candidate.tier.model,
|
|
164
168
|
operation: ctx.operation,
|
|
165
169
|
promptTokens: usage.tokens_in,
|
|
166
170
|
completionTokens: usage.tokens_out,
|
|
@@ -192,7 +196,7 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
192
196
|
let lastError = null;
|
|
193
197
|
let bestEffort = null;
|
|
194
198
|
for await (const candidate of eligibleTiers(req, ctx, deps, state)) {
|
|
195
|
-
const stripped = stripEnvelope(req, candidate.tier
|
|
199
|
+
const stripped = stripEnvelope(req, servedModel(req, candidate.tier));
|
|
196
200
|
const started = performance.now();
|
|
197
201
|
try {
|
|
198
202
|
const response = await candidate.adapter.invoke(stripped, {
|
|
@@ -205,7 +209,7 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
205
209
|
const result = {
|
|
206
210
|
response,
|
|
207
211
|
provider: candidate.tier.provider,
|
|
208
|
-
model: candidate.tier.model,
|
|
212
|
+
model: stripped.model ?? candidate.tier.model,
|
|
209
213
|
tier: candidate.tier.tier,
|
|
210
214
|
escalations,
|
|
211
215
|
degraded: false,
|
|
@@ -244,7 +248,7 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
244
248
|
for await (const candidate of eligibleTiers(req, ctx, deps, state)) {
|
|
245
249
|
if (!candidate.adapter.supports_streaming)
|
|
246
250
|
continue;
|
|
247
|
-
const stripped = stripEnvelope(req, candidate.tier
|
|
251
|
+
const stripped = stripEnvelope(req, servedModel(req, candidate.tier));
|
|
248
252
|
const gate = state.requestGate === 'would_block' ? 'would_block' : candidate.gate;
|
|
249
253
|
const started = performance.now();
|
|
250
254
|
const events = candidate.adapter.invokeStream(stripped, {
|
|
@@ -277,7 +281,7 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
277
281
|
type: 'message',
|
|
278
282
|
role: 'assistant',
|
|
279
283
|
content: [],
|
|
280
|
-
model: candidate.tier.model,
|
|
284
|
+
model: stripped.model ?? candidate.tier.model,
|
|
281
285
|
stop_reason: stopReason,
|
|
282
286
|
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
283
287
|
};
|
|
@@ -285,7 +289,7 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
285
289
|
};
|
|
286
290
|
return {
|
|
287
291
|
provider: candidate.tier.provider,
|
|
288
|
-
model: candidate.tier.model,
|
|
292
|
+
model: stripped.model ?? candidate.tier.model,
|
|
289
293
|
tier: candidate.tier.tier,
|
|
290
294
|
gate,
|
|
291
295
|
stream: metered(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudpeers-jkl/model-router",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "cloudpeers inference router — embedded library (no central data plane). Sovereignty privacy gate, static task-class routing, Anthropic Messages lingua franca, uniform metering.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"test": "vitest run",
|
|
22
22
|
"prepublishOnly": "npm run build"
|
|
23
23
|
},
|
|
24
|
-
"
|
|
25
|
-
"@google/generative-ai": "
|
|
26
|
-
"zod": "
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@google/generative-ai": "^0.24.1",
|
|
26
|
+
"zod": "^3.24.2"
|
|
27
27
|
},
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|