@cloudpeers-jkl/model-router 0.2.1 → 0.3.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 +6 -4
- package/dist/adapters/anthropic-credentials.d.ts +53 -0
- package/dist/adapters/anthropic-credentials.js +122 -0
- package/dist/adapters/anthropic.js +9 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/policy.d.ts +10 -20
- package/dist/policy.js +99 -12
- package/dist/router.js +43 -12
- package/dist/types.d.ts +22 -0
- package/package.json +5 -4
- package/routing-policy.json +68 -0
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).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic credentials for the router adapter — Workload Identity Federation first,
|
|
3
|
+
* static API key as the fallback.
|
|
4
|
+
*
|
|
5
|
+
* Federation (docs: platform.claude.com/docs/en/manage-claude/wif-providers/gcp): the Cloud Run
|
|
6
|
+
* service account's Google-signed identity token (metadata server, audience
|
|
7
|
+
* https://api.anthropic.com) is exchanged at POST /v1/oauth/token (RFC 7523 jwt-bearer) for a
|
|
8
|
+
* short-lived `sk-ant-oat01-…` access token bound to an Anthropic service account. No static
|
|
9
|
+
* secret exists to mint, store, rotate, or leak. Register: docs/MODEL_PROVIDER_POSTURE_REGISTER.
|
|
10
|
+
*
|
|
11
|
+
* Precedence follows the Anthropic SDKs: ANTHROPIC_API_KEY, when set, wins over federation.
|
|
12
|
+
* Migration therefore ends by UNSETTING the key on the service (docs § Migrate from API keys).
|
|
13
|
+
*
|
|
14
|
+
* Federation env (all four required; the workspace id is required because our rules cover
|
|
15
|
+
* more than one workspace):
|
|
16
|
+
* ANTHROPIC_FEDERATION_RULE_ID fdrl_…
|
|
17
|
+
* ANTHROPIC_ORGANIZATION_ID org uuid
|
|
18
|
+
* ANTHROPIC_SERVICE_ACCOUNT_ID svac_…
|
|
19
|
+
* ANTHROPIC_WORKSPACE_ID wrkspc_…
|
|
20
|
+
* Optional: ANTHROPIC_IDENTITY_TOKEN_FILE (a projected token file instead of the metadata server).
|
|
21
|
+
*/
|
|
22
|
+
export type AuthMode = 'api_key' | 'federation' | 'none';
|
|
23
|
+
export interface FederationConfig {
|
|
24
|
+
ruleId: string;
|
|
25
|
+
organizationId: string;
|
|
26
|
+
serviceAccountId: string;
|
|
27
|
+
workspaceId?: string;
|
|
28
|
+
identityTokenFile?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function federationConfig(env?: NodeJS.ProcessEnv): FederationConfig | null;
|
|
31
|
+
/** Which credential source the adapter will use — mirrors the SDK precedence (key above federation). */
|
|
32
|
+
export declare function authMode(env?: NodeJS.ProcessEnv): AuthMode;
|
|
33
|
+
export interface CredentialDeps {
|
|
34
|
+
fetch: typeof fetch;
|
|
35
|
+
now: () => number;
|
|
36
|
+
readFile?: (path: string) => Promise<string>;
|
|
37
|
+
}
|
|
38
|
+
export declare class AnthropicCredentials {
|
|
39
|
+
private readonly env;
|
|
40
|
+
private readonly deps;
|
|
41
|
+
private cached;
|
|
42
|
+
private inflight;
|
|
43
|
+
constructor(env?: NodeJS.ProcessEnv, deps?: CredentialDeps);
|
|
44
|
+
mode(): AuthMode;
|
|
45
|
+
/** Headers to send on a Messages call. Throws when neither credential source is configured. */
|
|
46
|
+
headers(): Promise<Record<string, string>>;
|
|
47
|
+
private accessToken;
|
|
48
|
+
private identityToken;
|
|
49
|
+
private exchange;
|
|
50
|
+
}
|
|
51
|
+
export declare function sharedCredentials(): AnthropicCredentials;
|
|
52
|
+
/** Test hook. */
|
|
53
|
+
export declare function __resetSharedCredentials(): void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic credentials for the router adapter — Workload Identity Federation first,
|
|
3
|
+
* static API key as the fallback.
|
|
4
|
+
*
|
|
5
|
+
* Federation (docs: platform.claude.com/docs/en/manage-claude/wif-providers/gcp): the Cloud Run
|
|
6
|
+
* service account's Google-signed identity token (metadata server, audience
|
|
7
|
+
* https://api.anthropic.com) is exchanged at POST /v1/oauth/token (RFC 7523 jwt-bearer) for a
|
|
8
|
+
* short-lived `sk-ant-oat01-…` access token bound to an Anthropic service account. No static
|
|
9
|
+
* secret exists to mint, store, rotate, or leak. Register: docs/MODEL_PROVIDER_POSTURE_REGISTER.
|
|
10
|
+
*
|
|
11
|
+
* Precedence follows the Anthropic SDKs: ANTHROPIC_API_KEY, when set, wins over federation.
|
|
12
|
+
* Migration therefore ends by UNSETTING the key on the service (docs § Migrate from API keys).
|
|
13
|
+
*
|
|
14
|
+
* Federation env (all four required; the workspace id is required because our rules cover
|
|
15
|
+
* more than one workspace):
|
|
16
|
+
* ANTHROPIC_FEDERATION_RULE_ID fdrl_…
|
|
17
|
+
* ANTHROPIC_ORGANIZATION_ID org uuid
|
|
18
|
+
* ANTHROPIC_SERVICE_ACCOUNT_ID svac_…
|
|
19
|
+
* ANTHROPIC_WORKSPACE_ID wrkspc_…
|
|
20
|
+
* Optional: ANTHROPIC_IDENTITY_TOKEN_FILE (a projected token file instead of the metadata server).
|
|
21
|
+
*/
|
|
22
|
+
const TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
|
|
23
|
+
const AUDIENCE = 'https://api.anthropic.com';
|
|
24
|
+
const METADATA_URL = `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${encodeURIComponent(AUDIENCE)}&format=full`;
|
|
25
|
+
const ADVISORY_REFRESH_MS = 120000; // refresh two minutes before expiry (SDK schedule)
|
|
26
|
+
export function federationConfig(env = process.env) {
|
|
27
|
+
const ruleId = env.ANTHROPIC_FEDERATION_RULE_ID;
|
|
28
|
+
const organizationId = env.ANTHROPIC_ORGANIZATION_ID;
|
|
29
|
+
const serviceAccountId = env.ANTHROPIC_SERVICE_ACCOUNT_ID;
|
|
30
|
+
if (!ruleId || !organizationId || !serviceAccountId)
|
|
31
|
+
return null;
|
|
32
|
+
return { ruleId, organizationId, serviceAccountId, workspaceId: env.ANTHROPIC_WORKSPACE_ID, identityTokenFile: env.ANTHROPIC_IDENTITY_TOKEN_FILE };
|
|
33
|
+
}
|
|
34
|
+
/** Which credential source the adapter will use — mirrors the SDK precedence (key above federation). */
|
|
35
|
+
export function authMode(env = process.env) {
|
|
36
|
+
if (env.ANTHROPIC_API_KEY)
|
|
37
|
+
return 'api_key';
|
|
38
|
+
if (federationConfig(env))
|
|
39
|
+
return 'federation';
|
|
40
|
+
return 'none';
|
|
41
|
+
}
|
|
42
|
+
export class AnthropicCredentials {
|
|
43
|
+
constructor(env = process.env, deps = { fetch: (...a) => globalThis.fetch(...a), now: () => Date.now() }) {
|
|
44
|
+
this.env = env;
|
|
45
|
+
this.deps = deps;
|
|
46
|
+
this.cached = null;
|
|
47
|
+
this.inflight = null;
|
|
48
|
+
}
|
|
49
|
+
mode() { return authMode(this.env); }
|
|
50
|
+
/** Headers to send on a Messages call. Throws when neither credential source is configured. */
|
|
51
|
+
async headers() {
|
|
52
|
+
const mode = this.mode();
|
|
53
|
+
if (mode === 'api_key')
|
|
54
|
+
return { 'x-api-key': this.env.ANTHROPIC_API_KEY };
|
|
55
|
+
if (mode === 'federation')
|
|
56
|
+
return { Authorization: `Bearer ${await this.accessToken()}` };
|
|
57
|
+
throw new Error('Anthropic credentials not configured (set ANTHROPIC_API_KEY or the ANTHROPIC_FEDERATION_* variables)');
|
|
58
|
+
}
|
|
59
|
+
async accessToken() {
|
|
60
|
+
const now = this.deps.now();
|
|
61
|
+
if (this.cached && this.cached.expiresAt - ADVISORY_REFRESH_MS > now)
|
|
62
|
+
return this.cached.accessToken;
|
|
63
|
+
if (!this.inflight) {
|
|
64
|
+
this.inflight = this.exchange().finally(() => { this.inflight = null; });
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const fresh = await this.inflight;
|
|
68
|
+
this.cached = fresh;
|
|
69
|
+
return fresh.accessToken;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
// Advisory window: a still-valid cached token is better than failing the request.
|
|
73
|
+
if (this.cached && this.cached.expiresAt - 30000 > now)
|
|
74
|
+
return this.cached.accessToken;
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async identityToken(cfg) {
|
|
79
|
+
if (cfg.identityTokenFile) {
|
|
80
|
+
const read = this.deps.readFile ?? (async (p) => (await import('node:fs/promises')).readFile(p, 'utf8'));
|
|
81
|
+
return (await read(cfg.identityTokenFile)).trim();
|
|
82
|
+
}
|
|
83
|
+
const res = await this.deps.fetch(METADATA_URL, { headers: { 'Metadata-Flavor': 'Google' } });
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw new Error(`Google metadata identity token ${res.status} (is this workload on Cloud Run with a service account?)`);
|
|
86
|
+
return (await res.text()).trim();
|
|
87
|
+
}
|
|
88
|
+
async exchange() {
|
|
89
|
+
const cfg = federationConfig(this.env);
|
|
90
|
+
if (!cfg)
|
|
91
|
+
throw new Error('federation not configured');
|
|
92
|
+
const assertion = await this.identityToken(cfg);
|
|
93
|
+
const body = {
|
|
94
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
95
|
+
assertion,
|
|
96
|
+
federation_rule_id: cfg.ruleId,
|
|
97
|
+
organization_id: cfg.organizationId,
|
|
98
|
+
service_account_id: cfg.serviceAccountId,
|
|
99
|
+
};
|
|
100
|
+
if (cfg.workspaceId)
|
|
101
|
+
body.workspace_id = cfg.workspaceId;
|
|
102
|
+
const res = await this.deps.fetch(TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
// The exchange returns an opaque 401 on deny; the reason is on the console's authentication history page.
|
|
105
|
+
throw new Error(`Anthropic token exchange ${res.status} (see platform.claude.com/settings/workload-identity-federation?tab=history)`);
|
|
106
|
+
}
|
|
107
|
+
const data = (await res.json());
|
|
108
|
+
if (!data.access_token)
|
|
109
|
+
throw new Error('Anthropic token exchange returned no access_token');
|
|
110
|
+
const ttlMs = Math.max(60, data.expires_in ?? 600) * 1000;
|
|
111
|
+
return { accessToken: data.access_token, expiresAt: this.deps.now() + ttlMs };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Process-wide instance for the adapter (one cache per process). */
|
|
115
|
+
let shared = null;
|
|
116
|
+
export function sharedCredentials() {
|
|
117
|
+
if (!shared)
|
|
118
|
+
shared = new AnthropicCredentials();
|
|
119
|
+
return shared;
|
|
120
|
+
}
|
|
121
|
+
/** Test hook. */
|
|
122
|
+
export function __resetSharedCredentials() { shared = null; }
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { sharedCredentials } from './anthropic-credentials.js';
|
|
1
2
|
const API_URL = 'https://api.anthropic.com/v1/messages';
|
|
2
3
|
const API_VERSION = '2023-06-01';
|
|
3
4
|
const TIMEOUT_MS = 30000;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// Credentials: Workload Identity Federation (short-lived, per-service identity) with the static
|
|
6
|
+
// key as fallback — see ./anthropic-credentials.ts. The adapter never reads the key directly.
|
|
7
|
+
const creds = () => sharedCredentials();
|
|
7
8
|
function buildBody(req, stream) {
|
|
8
9
|
return JSON.stringify({
|
|
9
10
|
model: req.model,
|
|
@@ -31,10 +32,10 @@ export function anthropicAdapter() {
|
|
|
31
32
|
supports_streaming_tool_use: true,
|
|
32
33
|
max_tools_per_request: null,
|
|
33
34
|
},
|
|
34
|
-
available: () =>
|
|
35
|
+
available: () => creds().mode() !== 'none',
|
|
35
36
|
async isEligible(_req, _ctx) {
|
|
36
|
-
if (
|
|
37
|
-
return { eligible: false, reason: '
|
|
37
|
+
if (creds().mode() === 'none')
|
|
38
|
+
return { eligible: false, reason: 'Anthropic credentials not configured (ANTHROPIC_API_KEY or ANTHROPIC_FEDERATION_*)' };
|
|
38
39
|
return { eligible: true, estimated_latency_ms: 2000, estimated_cost_cents: 0 };
|
|
39
40
|
},
|
|
40
41
|
async invoke(req, ctx) {
|
|
@@ -44,7 +45,7 @@ export function anthropicAdapter() {
|
|
|
44
45
|
try {
|
|
45
46
|
const res = await fetch(API_URL, {
|
|
46
47
|
method: 'POST',
|
|
47
|
-
headers: { 'Content-Type': 'application/json', '
|
|
48
|
+
headers: { 'Content-Type': 'application/json', 'anthropic-version': API_VERSION, ...(await creds().headers()) },
|
|
48
49
|
body: buildBody(req, false),
|
|
49
50
|
signal: controller.signal,
|
|
50
51
|
});
|
|
@@ -70,7 +71,7 @@ export function anthropicAdapter() {
|
|
|
70
71
|
try {
|
|
71
72
|
res = await fetch(API_URL, {
|
|
72
73
|
method: 'POST',
|
|
73
|
-
headers: { 'Content-Type': 'application/json', '
|
|
74
|
+
headers: { 'Content-Type': 'application/json', 'anthropic-version': API_VERSION, ...(await creds().headers()) },
|
|
74
75
|
body: buildBody(req, true),
|
|
75
76
|
signal: controller.signal,
|
|
76
77
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export * from './types.js';
|
|
8
8
|
export { RouterError, RouterValidationError, RouterBlockedError } from './errors.js';
|
|
9
9
|
export { parseRouterRequest, routerMessagesRequestSchema, cloudpeersEnvelopeSchema } from './schema.js';
|
|
10
|
-
export { TIERS, TASK_ROUTES, evaluateGate, gateEnforced, resolveChain } from './policy.js';
|
|
10
|
+
export { TIERS, TASK_ROUTES, evaluateGate, gateEnforced, resolveChain, routingPolicyArtifact } from './policy.js';
|
|
11
11
|
export { routeModel, routeMessages, routeMessagesStream } from './router.js';
|
|
12
12
|
export type { RouteContext, MessagesStreamResult } from './router.js';
|
|
13
13
|
export { capabilityMatrix } from './capabilities.js';
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export * from './types.js';
|
|
8
8
|
export { RouterError, RouterValidationError, RouterBlockedError } from './errors.js';
|
|
9
9
|
export { parseRouterRequest, routerMessagesRequestSchema, cloudpeersEnvelopeSchema } from './schema.js';
|
|
10
|
-
export { TIERS, TASK_ROUTES, evaluateGate, gateEnforced, resolveChain } from './policy.js';
|
|
10
|
+
export { TIERS, TASK_ROUTES, evaluateGate, gateEnforced, resolveChain, routingPolicyArtifact } from './policy.js';
|
|
11
11
|
export { routeModel, routeMessages, routeMessagesStream } from './router.js';
|
|
12
12
|
export { capabilityMatrix } from './capabilities.js';
|
|
13
13
|
export { anthropicAdapter } from './adapters/anthropic.js';
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
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
1
|
import type { GateOutcome, Provider, SovereigntyClass, TaskClass, TierDef } from './types.js';
|
|
2
|
+
/** The §N+1.1 artifact as loaded (or the baked fallback) — projection input, read-only. */
|
|
3
|
+
export declare function routingPolicyArtifact(): Record<string, unknown>;
|
|
21
4
|
export declare const TIERS: Record<number, TierDef>;
|
|
22
|
-
/** Static routing table (confidence v1): tier chain per task class, in order. */
|
|
5
|
+
/** Static routing table (confidence v1): tier chain per task class, in order. Loaded from routing-policy.json. */
|
|
23
6
|
export declare const TASK_ROUTES: Record<TaskClass, number[]>;
|
|
24
7
|
export declare function gateEnforced(): boolean;
|
|
25
8
|
/**
|
|
@@ -27,6 +10,13 @@ export declare function gateEnforced(): boolean;
|
|
|
27
10
|
* Never triggers for aggregate_only/externalizable (spec scope: local_only).
|
|
28
11
|
*/
|
|
29
12
|
export declare function evaluateGate(sovereigntyClass: SovereigntyClass, tier: TierDef): GateOutcome;
|
|
13
|
+
/**
|
|
14
|
+
* §4 — the caller's `model` is honored when it belongs to the selected
|
|
15
|
+
* tier's provider family (a gemini caller may pick flash vs pro); otherwise
|
|
16
|
+
* the tier's policy model serves. Tier 0 always serves its own model — the
|
|
17
|
+
* self-hosted endpoint decides what it hosts, not the caller.
|
|
18
|
+
*/
|
|
19
|
+
export declare function modelMatchesProvider(model: string, provider: Provider): boolean;
|
|
30
20
|
/**
|
|
31
21
|
* Resolve the tier chain for a request: the static task-class chain,
|
|
32
22
|
* optionally reordered by an advisory backend_hint (§4). The hint never adds
|
package/dist/policy.js
CHANGED
|
@@ -1,16 +1,90 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 { readFileSync } from 'node:fs';
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
/**
|
|
23
|
+
* §N+1.1 — routing-policy.json (package root) is the governance source of
|
|
24
|
+
* truth the router executes from. Baked-in defaults below exist only as the
|
|
25
|
+
* fail-safe when the artifact is missing or invalid (fail-safe, not
|
|
26
|
+
* fail-open: same values, loudly logged).
|
|
27
|
+
*/
|
|
28
|
+
const artifactTierSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
tier: z.number().int().min(0),
|
|
31
|
+
provider: z.enum(['selfhosted', 'gemini', 'anthropic']),
|
|
32
|
+
default_model: z.string().min(1),
|
|
33
|
+
model_env_override: z.string().min(1),
|
|
34
|
+
cloud: z.boolean(),
|
|
35
|
+
})
|
|
36
|
+
.passthrough();
|
|
37
|
+
const artifactSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
version: z.literal(1),
|
|
40
|
+
tiers: z.array(artifactTierSchema).min(1),
|
|
41
|
+
task_routes: z.record(z.string(), z.array(z.number().int()).min(1)),
|
|
42
|
+
})
|
|
43
|
+
.passthrough();
|
|
44
|
+
const TASK_CLASSES = ['extraction', 'classification', 'coaching', 'reasoning', 'synthesis'];
|
|
45
|
+
const BAKED = {
|
|
46
|
+
version: 1,
|
|
47
|
+
tiers: [
|
|
48
|
+
{ tier: 0, provider: 'selfhosted', default_model: 'openmed-extraction', model_env_override: 'MODEL_ROUTER_TIER0_MODEL', cloud: false },
|
|
49
|
+
{ tier: 1, provider: 'gemini', default_model: 'gemini-2.5-flash', model_env_override: 'MODEL_ROUTER_GEMINI_MODEL', cloud: true },
|
|
50
|
+
{ tier: 2, provider: 'anthropic', default_model: 'claude-haiku-4-5', model_env_override: 'MODEL_ROUTER_ANTHROPIC_MODEL', cloud: true },
|
|
51
|
+
],
|
|
52
|
+
task_routes: { extraction: [0, 1], classification: [0, 1], coaching: [1, 2], reasoning: [2, 1], synthesis: [2, 1] },
|
|
13
53
|
};
|
|
54
|
+
/** Full parsed artifact (for the governance projection); baked shape on fallback. */
|
|
55
|
+
let loadedArtifact = BAKED;
|
|
56
|
+
function loadArtifact() {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(new URL('../routing-policy.json', import.meta.url), 'utf8');
|
|
59
|
+
const parsed = artifactSchema.parse(JSON.parse(raw));
|
|
60
|
+
const tierNos = new Set(parsed.tiers.map((t) => t.tier));
|
|
61
|
+
for (const tc of TASK_CLASSES) {
|
|
62
|
+
const chain = parsed.task_routes[tc];
|
|
63
|
+
if (!chain)
|
|
64
|
+
throw new Error(`task_routes missing task class "${tc}"`);
|
|
65
|
+
for (const n of chain)
|
|
66
|
+
if (!tierNos.has(n))
|
|
67
|
+
throw new Error(`task_routes.${tc} references undefined tier ${n}`);
|
|
68
|
+
}
|
|
69
|
+
loadedArtifact = parsed;
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
console.error(`[routing-policy] failed to load routing-policy.json — falling back to baked-in defaults: ${err.message}`);
|
|
74
|
+
return BAKED;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const artifact = loadArtifact();
|
|
78
|
+
/** The §N+1.1 artifact as loaded (or the baked fallback) — projection input, read-only. */
|
|
79
|
+
export function routingPolicyArtifact() {
|
|
80
|
+
return loadedArtifact;
|
|
81
|
+
}
|
|
82
|
+
export const TIERS = Object.fromEntries(artifact.tiers.map((t) => [
|
|
83
|
+
t.tier,
|
|
84
|
+
{ tier: t.tier, provider: t.provider, model: process.env[t.model_env_override] || t.default_model, cloud: t.cloud },
|
|
85
|
+
]));
|
|
86
|
+
/** Static routing table (confidence v1): tier chain per task class, in order. Loaded from routing-policy.json. */
|
|
87
|
+
export const TASK_ROUTES = Object.fromEntries(TASK_CLASSES.map((tc) => [tc, artifact.task_routes[tc]]));
|
|
14
88
|
function baaProviders() {
|
|
15
89
|
return new Set((process.env.MODEL_ROUTER_BAA_PROVIDERS ?? '')
|
|
16
90
|
.split(',')
|
|
@@ -31,6 +105,19 @@ export function evaluateGate(sovereigntyClass, tier) {
|
|
|
31
105
|
return 'allowed'; // Stage 2 governed egress
|
|
32
106
|
return gateEnforced() ? 'blocked' : 'would_block'; // Stage 3 : Stage 1
|
|
33
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* §4 — the caller's `model` is honored when it belongs to the selected
|
|
110
|
+
* tier's provider family (a gemini caller may pick flash vs pro); otherwise
|
|
111
|
+
* the tier's policy model serves. Tier 0 always serves its own model — the
|
|
112
|
+
* self-hosted endpoint decides what it hosts, not the caller.
|
|
113
|
+
*/
|
|
114
|
+
export function modelMatchesProvider(model, provider) {
|
|
115
|
+
if (provider === 'gemini')
|
|
116
|
+
return /^(models\/)?gemini/i.test(model);
|
|
117
|
+
if (provider === 'anthropic')
|
|
118
|
+
return /^claude/i.test(model);
|
|
119
|
+
return false; // selfhosted: never caller-selected
|
|
120
|
+
}
|
|
34
121
|
/**
|
|
35
122
|
* Resolve the tier chain for a request: the static task-class chain,
|
|
36
123
|
* 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, gateEnforced, 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.
|
|
@@ -65,6 +69,25 @@ async function checkQuotaOnce(req, ctx, deps) {
|
|
|
65
69
|
throw new Error(`Token quota exceeded. ${quotaCheck.remainingTokens} tokens remaining. Quota resets at ${quotaCheck.quotaResetAt}.`);
|
|
66
70
|
}
|
|
67
71
|
}
|
|
72
|
+
function tierKey(tier) {
|
|
73
|
+
return `tier${tier.tier}/${tier.provider}`;
|
|
74
|
+
}
|
|
75
|
+
/** §N.5 — build the Stage-1 decision record for this request. */
|
|
76
|
+
function stageOneDecision(env, state, chosen) {
|
|
77
|
+
const decision = {
|
|
78
|
+
sovereignty_class: env.sovereignty_class,
|
|
79
|
+
eligible_set: [...state.walked],
|
|
80
|
+
chosen_backend: chosen ? tierKey(chosen.tier) : null,
|
|
81
|
+
};
|
|
82
|
+
if (env.sovereignty_class === 'local_only') {
|
|
83
|
+
decision.no_egress_proof = {
|
|
84
|
+
gate_enforced: gateEnforced(),
|
|
85
|
+
cloud_tiers_blocked: [...state.blockedCloud],
|
|
86
|
+
chosen_backend_cloud: chosen?.tier.cloud ?? false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return decision;
|
|
90
|
+
}
|
|
68
91
|
/**
|
|
69
92
|
* Walk the chain applying availability, the privacy gate, per-backend policy,
|
|
70
93
|
* and adapter eligibility (§3.3/§7.1 #3-4, §8.2). Yields callable candidates
|
|
@@ -80,6 +103,7 @@ async function* eligibleTiers(req, ctx, deps, state) {
|
|
|
80
103
|
continue; // tier not deployed (e.g. Tier 0 pre-OpenMed) — next
|
|
81
104
|
const gate = evaluateGate(env.sovereignty_class, tier);
|
|
82
105
|
if (gate === 'blocked') {
|
|
106
|
+
state.blockedCloud.push(tierKey(tier));
|
|
83
107
|
console.warn(`[privacy-gate] BLOCKED local_only → tier${tier.tier}/${tier.provider} (op=${ctx.operation} svc=${ctx.serviceId})`);
|
|
84
108
|
continue; // fail closed on this tier
|
|
85
109
|
}
|
|
@@ -116,6 +140,7 @@ async function* eligibleTiers(req, ctx, deps, state) {
|
|
|
116
140
|
console.warn(`[model-router] tier${tier.tier}/${tier.provider} ineligible: ${reason}`);
|
|
117
141
|
continue;
|
|
118
142
|
}
|
|
143
|
+
state.walked.push(tierKey(tier));
|
|
119
144
|
yield { tier, adapter, gate };
|
|
120
145
|
}
|
|
121
146
|
}
|
|
@@ -129,7 +154,7 @@ function exhaustedError(env, state, lastError) {
|
|
|
129
154
|
return lastError ?? new Error(`No available tier for task class ${env.task_class}`);
|
|
130
155
|
}
|
|
131
156
|
/** §7.2 post-call hooks + §10 uniform metering. Observation-only; never gates. */
|
|
132
|
-
async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps, state) {
|
|
157
|
+
async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps, state, decision) {
|
|
133
158
|
const env = req.cloudpeers;
|
|
134
159
|
const usage = {
|
|
135
160
|
...candidate.adapter.reportUsage(stripped, response),
|
|
@@ -160,7 +185,7 @@ async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps
|
|
|
160
185
|
.meter({
|
|
161
186
|
userId: ctx.userId,
|
|
162
187
|
serviceId: ctx.serviceId,
|
|
163
|
-
model: candidate.tier.model,
|
|
188
|
+
model: stripped.model ?? candidate.tier.model,
|
|
164
189
|
operation: ctx.operation,
|
|
165
190
|
promptTokens: usage.tokens_in,
|
|
166
191
|
completionTokens: usage.tokens_out,
|
|
@@ -171,6 +196,7 @@ async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps
|
|
|
171
196
|
tier: candidate.tier.tier,
|
|
172
197
|
taskClass: env.task_class,
|
|
173
198
|
gate,
|
|
199
|
+
decision,
|
|
174
200
|
...(outcomeMatches !== undefined ? { outcome_matches_mandate: outcomeMatches } : {}),
|
|
175
201
|
},
|
|
176
202
|
},
|
|
@@ -187,12 +213,12 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
187
213
|
const env = req.cloudpeers;
|
|
188
214
|
await enforcePreCall(env, deps);
|
|
189
215
|
await checkQuotaOnce(req, ctx, deps);
|
|
190
|
-
const state = { requestGate: 'allowed', toolStrictViolation: false };
|
|
216
|
+
const state = { requestGate: 'allowed', toolStrictViolation: false, walked: [], blockedCloud: [] };
|
|
191
217
|
let escalations = 0;
|
|
192
218
|
let lastError = null;
|
|
193
219
|
let bestEffort = null;
|
|
194
220
|
for await (const candidate of eligibleTiers(req, ctx, deps, state)) {
|
|
195
|
-
const stripped = stripEnvelope(req, candidate.tier
|
|
221
|
+
const stripped = stripEnvelope(req, servedModel(req, candidate.tier));
|
|
196
222
|
const started = performance.now();
|
|
197
223
|
try {
|
|
198
224
|
const response = await candidate.adapter.invoke(stripped, {
|
|
@@ -201,16 +227,18 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
201
227
|
operation: ctx.operation,
|
|
202
228
|
signal: ctx.signal,
|
|
203
229
|
});
|
|
204
|
-
const
|
|
230
|
+
const decision = stageOneDecision(env, state, candidate);
|
|
231
|
+
const usage = await postCall(candidate, stripped, response, Math.round(performance.now() - started), req, ctx, deps, state, decision);
|
|
205
232
|
const result = {
|
|
206
233
|
response,
|
|
207
234
|
provider: candidate.tier.provider,
|
|
208
|
-
model: candidate.tier.model,
|
|
235
|
+
model: stripped.model ?? candidate.tier.model,
|
|
209
236
|
tier: candidate.tier.tier,
|
|
210
237
|
escalations,
|
|
211
238
|
degraded: false,
|
|
212
239
|
gate: state.requestGate === 'would_block' ? 'would_block' : candidate.gate,
|
|
213
240
|
usage,
|
|
241
|
+
decision,
|
|
214
242
|
};
|
|
215
243
|
if (ctx.validate && !ctx.validate(firstText(response.content))) {
|
|
216
244
|
// Confidence v1: shape validation failed — keep as best-effort, escalate.
|
|
@@ -228,6 +256,8 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
228
256
|
}
|
|
229
257
|
if (bestEffort)
|
|
230
258
|
return { ...bestEffort, escalations };
|
|
259
|
+
// §N.5 — the denial is a Stage-1 decision too; stable structured line, one per 412.
|
|
260
|
+
console.warn(`[stage1-decision] ${JSON.stringify(stageOneDecision(env, state, null))}`);
|
|
231
261
|
throw exhaustedError(env, state, lastError);
|
|
232
262
|
}
|
|
233
263
|
/**
|
|
@@ -240,11 +270,11 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
240
270
|
const env = req.cloudpeers;
|
|
241
271
|
await enforcePreCall(env, deps);
|
|
242
272
|
await checkQuotaOnce(req, ctx, deps);
|
|
243
|
-
const state = { requestGate: 'allowed', toolStrictViolation: false };
|
|
273
|
+
const state = { requestGate: 'allowed', toolStrictViolation: false, walked: [], blockedCloud: [] };
|
|
244
274
|
for await (const candidate of eligibleTiers(req, ctx, deps, state)) {
|
|
245
275
|
if (!candidate.adapter.supports_streaming)
|
|
246
276
|
continue;
|
|
247
|
-
const stripped = stripEnvelope(req, candidate.tier
|
|
277
|
+
const stripped = stripEnvelope(req, servedModel(req, candidate.tier));
|
|
248
278
|
const gate = state.requestGate === 'would_block' ? 'would_block' : candidate.gate;
|
|
249
279
|
const started = performance.now();
|
|
250
280
|
const events = candidate.adapter.invokeStream(stripped, {
|
|
@@ -277,20 +307,21 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
277
307
|
type: 'message',
|
|
278
308
|
role: 'assistant',
|
|
279
309
|
content: [],
|
|
280
|
-
model: candidate.tier.model,
|
|
310
|
+
model: stripped.model ?? candidate.tier.model,
|
|
281
311
|
stop_reason: stopReason,
|
|
282
312
|
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
283
313
|
};
|
|
284
|
-
await postCall(candidate, stripped, response, Math.round(performance.now() - started), req, ctx, deps, state);
|
|
314
|
+
await postCall(candidate, stripped, response, Math.round(performance.now() - started), req, ctx, deps, state, stageOneDecision(env, state, candidate));
|
|
285
315
|
};
|
|
286
316
|
return {
|
|
287
317
|
provider: candidate.tier.provider,
|
|
288
|
-
model: candidate.tier.model,
|
|
318
|
+
model: stripped.model ?? candidate.tier.model,
|
|
289
319
|
tier: candidate.tier.tier,
|
|
290
320
|
gate,
|
|
291
321
|
stream: metered(),
|
|
292
322
|
};
|
|
293
323
|
}
|
|
324
|
+
console.warn(`[stage1-decision] ${JSON.stringify(stageOneDecision(env, state, null))}`);
|
|
294
325
|
throw exhaustedError(env, state, null);
|
|
295
326
|
}
|
|
296
327
|
/**
|
package/dist/types.d.ts
CHANGED
|
@@ -237,6 +237,27 @@ export interface RouterResult {
|
|
|
237
237
|
totalTokens: number;
|
|
238
238
|
}
|
|
239
239
|
/** Spec-API result: full Anthropic response + the routing telemetry. */
|
|
240
|
+
/**
|
|
241
|
+
* §N.5 Stage-1 decision record — one per routing decision, success or 412.
|
|
242
|
+
* `no_egress_proof` is present only for `local_only`: the router's claim about
|
|
243
|
+
* cloud egress for this request. The empirical proof lives outside the library
|
|
244
|
+
* (gateway harness zero-canary assertion, joined by trace); this record is the
|
|
245
|
+
* claim and the join key's payload.
|
|
246
|
+
*/
|
|
247
|
+
export interface StageOneDecision {
|
|
248
|
+
sovereignty_class: CloudpeersEnvelope['sovereignty_class'];
|
|
249
|
+
/** `tier{n}/{provider}` candidates that passed the gate walk, in chain order. */
|
|
250
|
+
eligible_set: string[];
|
|
251
|
+
/** null on a 412 (empty eligible set). */
|
|
252
|
+
chosen_backend: string | null;
|
|
253
|
+
no_egress_proof?: {
|
|
254
|
+
gate_enforced: boolean;
|
|
255
|
+
/** Cloud tiers the gate failed closed on. */
|
|
256
|
+
cloud_tiers_blocked: string[];
|
|
257
|
+
/** true only when a cloud tier served the request (BAA-governed or monitor mode). */
|
|
258
|
+
chosen_backend_cloud: boolean;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
240
261
|
export interface MessagesRouteResult {
|
|
241
262
|
response: AnthropicMessagesResponse;
|
|
242
263
|
provider: Provider;
|
|
@@ -246,6 +267,7 @@ export interface MessagesRouteResult {
|
|
|
246
267
|
degraded: boolean;
|
|
247
268
|
gate: GateOutcome;
|
|
248
269
|
usage: UsageEvent;
|
|
270
|
+
decision: StageOneDecision;
|
|
249
271
|
}
|
|
250
272
|
export interface TierDef {
|
|
251
273
|
tier: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudpeers-jkl/model-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"routing-policy.json",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
@@ -21,9 +22,9 @@
|
|
|
21
22
|
"test": "vitest run",
|
|
22
23
|
"prepublishOnly": "npm run build"
|
|
23
24
|
},
|
|
24
|
-
"
|
|
25
|
-
"@google/generative-ai": "
|
|
26
|
-
"zod": "
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@google/generative-ai": "^0.24.1",
|
|
27
|
+
"zod": "^3.24.2"
|
|
27
28
|
},
|
|
28
29
|
"repository": {
|
|
29
30
|
"type": "git",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "§N+1.1 governance source of truth — the file the router EXECUTES from, not a description of it. tiers + task_routes are loaded by src/policy.ts at module init (fail-safe: baked-in defaults on parse failure). A credential-less human reading this one artifact can enumerate every routing rule, every backend, what fails closed, and current status. The /.well-known/ucp/inference-router projection is generated from this file and the live adapter matrix — never hand-reconciled. Edit here; never edit the projection.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"updated": "2026-07-29",
|
|
5
|
+
"tiers": [
|
|
6
|
+
{
|
|
7
|
+
"tier": 0,
|
|
8
|
+
"provider": "selfhosted",
|
|
9
|
+
"default_model": "openmed-extraction",
|
|
10
|
+
"model_env_override": "MODEL_ROUTER_TIER0_MODEL",
|
|
11
|
+
"cloud": false,
|
|
12
|
+
"build_status": "not deployed — MODEL_ROUTER_TIER0_URL unset; OpenMed extraction / Nemotron pending the Q3 eval. The only tier permitted for local_only under the enforced gate (absent a BAA provider)."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"tier": 1,
|
|
16
|
+
"provider": "gemini",
|
|
17
|
+
"default_model": "gemini-2.5-flash",
|
|
18
|
+
"model_env_override": "MODEL_ROUTER_GEMINI_MODEL",
|
|
19
|
+
"cloud": true,
|
|
20
|
+
"build_status": "live"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"tier": 2,
|
|
24
|
+
"provider": "anthropic",
|
|
25
|
+
"default_model": "claude-haiku-4-5",
|
|
26
|
+
"model_env_override": "MODEL_ROUTER_ANTHROPIC_MODEL",
|
|
27
|
+
"cloud": true,
|
|
28
|
+
"build_status": "live — Claude primary (ahead of EoQ3)"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"no_providers": ["openai"],
|
|
32
|
+
"task_routes": {
|
|
33
|
+
"extraction": [0, 1],
|
|
34
|
+
"classification": [0, 1],
|
|
35
|
+
"coaching": [1, 2],
|
|
36
|
+
"reasoning": [2, 1],
|
|
37
|
+
"synthesis": [2, 1]
|
|
38
|
+
},
|
|
39
|
+
"sovereignty": {
|
|
40
|
+
"local_only": "non-cloud tiers only, or a provider listed in MODEL_ROUTER_BAA_PROVIDERS (executed instrument required). Never silently routed to cloud.",
|
|
41
|
+
"aggregate_only": "any tier on the task route",
|
|
42
|
+
"externalizable": "any tier on the task route"
|
|
43
|
+
},
|
|
44
|
+
"privacy_gate": {
|
|
45
|
+
"stages": [
|
|
46
|
+
"Stage 1 MONITOR (historical default): local_only → cloud logs would_block, proceeds",
|
|
47
|
+
"Stage 2 governed egress: MODEL_ROUTER_BAA_PROVIDERS allowlist",
|
|
48
|
+
"Stage 3 ENFORCE: MODEL_PRIVACY_GATE_ENFORCE=true — local_only is Tier-0-or-BAA only"
|
|
49
|
+
],
|
|
50
|
+
"current_stage": 3,
|
|
51
|
+
"enforcing_since": "2026-07-23",
|
|
52
|
+
"decision_record": "§N.5 — {sovereignty_class, eligible_set, chosen_backend, no_egress_proof} emitted per decision (meter metadata on success, [stage1-decision] log line on 412)"
|
|
53
|
+
},
|
|
54
|
+
"fail_closed_rules": [
|
|
55
|
+
"Empty eligible set → 412 routing_blocked. Never a silent fallback to an unapproved path.",
|
|
56
|
+
"mandate_id present but no mandate verifier wired → 412 mandate_verifier_unavailable",
|
|
57
|
+
"policy_id present but no policy engine wired → 412 policy_engine_unavailable",
|
|
58
|
+
"tool_use_strict with no tool-capable eligible backend → 422 tool_use_unsupported",
|
|
59
|
+
"Caller-supplied model outside the serving tier's provider family is ignored, never escalated"
|
|
60
|
+
],
|
|
61
|
+
"roadmap": [
|
|
62
|
+
{ "phase": "shipped 2026-07-15", "item": "embedded-library router (Amendment A1) live in mcp + carepeers" },
|
|
63
|
+
{ "phase": "shipped 2026-07-23", "item": "privacy gate Stage-3 ENFORCE (a16d397)" },
|
|
64
|
+
{ "phase": "in progress", "item": "agentgateway v1.4.0 shadow adoption; Stage-1 seam moves between services (CEL + NemoClaw webhook) — AGENTGATEWAY_ADOPTION_ADR_2026-07-29" },
|
|
65
|
+
{ "phase": "Q3 2026", "item": "Tier 0 self-hosted deployment (OpenMed/Nemotron) — closes the local_only execution gap" },
|
|
66
|
+
{ "phase": "Q4 2026", "item": "gemini-proxy sunset completes (§15.5); Stage-2 latency/cost/quality selection behind the gateway" }
|
|
67
|
+
]
|
|
68
|
+
}
|