@cloudpeers-jkl/model-router 0.2.1 → 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 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
- Not yet published. Consumed in-repo via relative imports (see
72
- `server/lib/model-router.ts`). To publish for external consumers
73
- (carepeers seams, gemini-proxy in Deno): `npm run build && npm publish`
74
- from this directory — `dist/` is what ships.
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).
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
@@ -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.model);
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.model);
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.1",
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
- "peerDependencies": {
25
- "@google/generative-ai": ">=0.24.0",
26
- "zod": ">=3.22.0"
24
+ "dependencies": {
25
+ "@google/generative-ai": "^0.24.1",
26
+ "zod": "^3.24.2"
27
27
  },
28
28
  "repository": {
29
29
  "type": "git",