@cloudpeers-jkl/model-router 0.2.2 → 0.4.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 +22 -0
- 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/adapters/edge.d.ts +26 -0
- package/dist/adapters/edge.js +16 -0
- package/dist/capabilities.d.ts +3 -3
- package/dist/embed.d.ts +66 -0
- package/dist/embed.js +176 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/policy.d.ts +5 -20
- package/dist/policy.js +92 -14
- package/dist/router.js +33 -6
- package/dist/schema.d.ts +8 -8
- package/dist/types.d.ts +77 -8
- package/package.json +2 -1
- package/routing-policy.json +68 -0
package/README.md
CHANGED
|
@@ -59,6 +59,28 @@ In mcp, don't construct deps yourself — import the prewired composition from
|
|
|
59
59
|
- **Uniform metering:** every call reports through the injected meter with a
|
|
60
60
|
`router` metadata block — provider, tier, taskClass, gate.
|
|
61
61
|
|
|
62
|
+
## Embed contract (0.4.0)
|
|
63
|
+
|
|
64
|
+
A separate path from generate — `routeMessages` and `BackendAdapter` are
|
|
65
|
+
unchanged. `routeEmbed({ texts, cloudpeers: { sovereignty_class } }, ctx,
|
|
66
|
+
{ adapters: EmbedAdapter[], meter })` walks the adapters in order and returns
|
|
67
|
+
`{ vectors, dims, model, provider, surface, gate, decision }`.
|
|
68
|
+
|
|
69
|
+
- **Gate:** the shared `gateRule` (the same rule as `evaluateGate`), always
|
|
70
|
+
enforced (no monitor stage): `local_only` reaches only `edge` / `self_hosted`
|
|
71
|
+
surfaces, or a cloud provider listed in `MODEL_ROUTER_BAA_PROVIDERS`. No
|
|
72
|
+
permitted adapter → `RouterBlockedError` 412.
|
|
73
|
+
- **Terminal errors:** no available adapter → `RouterError` 503
|
|
74
|
+
`no_embed_backend`. Every permitted adapter failed → 503
|
|
75
|
+
`embed_backends_failed`, with the last adapter error as `cause`. Exceptions:
|
|
76
|
+
when every failure was a 4xx `RouterError`, the last one is rethrown
|
|
77
|
+
unchanged; an aborted `ctx.signal` throws an `AbortError`.
|
|
78
|
+
- **Edge:** `edgeEmbedDescriptor()` is an honest stub (`available()` false,
|
|
79
|
+
`embed()` throws). Edge embedding runs in the caller's browser
|
|
80
|
+
(cloudpeers-github `lib/edge/embeddings.ts`); the server never invokes it.
|
|
81
|
+
- **Metering:** one `deps.meter` call per embed, `metadata.router` =
|
|
82
|
+
`{ op: 'embed', provider, surface, gate, dims, count, decision }`.
|
|
83
|
+
|
|
62
84
|
## Env (deploy-time control plane)
|
|
63
85
|
|
|
64
86
|
`MODEL_ROUTER_TIER0_MODEL` / `MODEL_ROUTER_TIER0_URL` (Tier 0 self-hosted),
|
|
@@ -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: (input, init) => globalThis.fetch(input, init), 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
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge (on-device) embed descriptor — surface: edge. Honest stub.
|
|
3
|
+
*
|
|
4
|
+
* The real edge execution runs in the visitor's browser (cloudpeers-github
|
|
5
|
+
* `lib/edge/embeddings.ts`: ONNX Runtime Web, all-MiniLM-L6-v2 q8, 384 dims,
|
|
6
|
+
* weights served same-origin). The text never reaches a server, so the
|
|
7
|
+
* server never invokes this adapter: `available()` is false and `embed()`
|
|
8
|
+
* throws. It exists so the capability surface and the gate can name the edge
|
|
9
|
+
* surface truthfully — `cloud:false`, therefore permitted for local_only —
|
|
10
|
+
* and so browser-side metering rows can carry a provider the router knows.
|
|
11
|
+
*
|
|
12
|
+
* A client-side embedder (browser/app) may construct its own EmbedAdapter
|
|
13
|
+
* with id 'edge' and surface 'edge' that does run locally, and pass it to
|
|
14
|
+
* routeEmbed in that runtime.
|
|
15
|
+
*/
|
|
16
|
+
import type { EmbedAdapter } from '../types.js';
|
|
17
|
+
/**
|
|
18
|
+
* The model the browser edge embedder ships today.
|
|
19
|
+
* Keep in sync with cloudpeers-github `lib/edge/embeddings.ts` EDGE_MODEL (id, dims).
|
|
20
|
+
*/
|
|
21
|
+
export declare const EDGE_EMBED_MODEL: {
|
|
22
|
+
readonly id: "Xenova/all-MiniLM-L6-v2";
|
|
23
|
+
readonly dims: 384;
|
|
24
|
+
readonly license: "Apache-2.0";
|
|
25
|
+
};
|
|
26
|
+
export declare function edgeEmbedDescriptor(): EmbedAdapter;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model the browser edge embedder ships today.
|
|
3
|
+
* Keep in sync with cloudpeers-github `lib/edge/embeddings.ts` EDGE_MODEL (id, dims).
|
|
4
|
+
*/
|
|
5
|
+
export const EDGE_EMBED_MODEL = { id: 'Xenova/all-MiniLM-L6-v2', dims: 384, license: 'Apache-2.0' };
|
|
6
|
+
export function edgeEmbedDescriptor() {
|
|
7
|
+
return {
|
|
8
|
+
id: 'edge',
|
|
9
|
+
display_name: 'on-device embedder (runs in the caller runtime, never on a server)',
|
|
10
|
+
surface: 'edge',
|
|
11
|
+
available: () => false,
|
|
12
|
+
async embed(_texts, _ctx) {
|
|
13
|
+
throw new Error('edge embedding executes on the caller device; the server never invokes it');
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* reflects truth"). Embedded-library v1 exposes this as a function; the
|
|
5
5
|
* mcp server surfaces it on its discovery endpoints.
|
|
6
6
|
*/
|
|
7
|
-
import type { BackendAdapter, BackendSurface,
|
|
7
|
+
import type { BackendAdapter, BackendSurface, GenerateProvider, ToolUseCapability } from './types.js';
|
|
8
8
|
export interface BackendCapabilities {
|
|
9
|
-
id:
|
|
9
|
+
id: GenerateProvider;
|
|
10
10
|
display_name: string;
|
|
11
11
|
surface: BackendSurface;
|
|
12
12
|
available: boolean;
|
|
@@ -15,4 +15,4 @@ export interface BackendCapabilities {
|
|
|
15
15
|
supports_prompt_caching: boolean;
|
|
16
16
|
tool_use: ToolUseCapability;
|
|
17
17
|
}
|
|
18
|
-
export declare function capabilityMatrix(adapters: Partial<Record<
|
|
18
|
+
export declare function capabilityMatrix(adapters: Partial<Record<GenerateProvider, BackendAdapter>>): BackendCapabilities[];
|
package/dist/embed.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed contract — `routeEmbed` (edge-inference Phase 1, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from the generate path (routeMessages / BackendAdapter
|
|
5
|
+
* / tier chain), which is untouched. Flow: validate (400) → order adapters
|
|
6
|
+
* (advisory backend_hint) → per adapter: availability → privacy gate →
|
|
7
|
+
* embed → shape check (a malformed result escalates to the next adapter) →
|
|
8
|
+
* uniform metering through the injected meter.
|
|
9
|
+
*
|
|
10
|
+
* Gate: the same rule as `evaluateGate` — for local_only, cloud surfaces are
|
|
11
|
+
* permitted only through a MODEL_ROUTER_BAA_PROVIDERS provider; edge and
|
|
12
|
+
* self_hosted (cloud:false) are always permitted — but always in ENFORCE
|
|
13
|
+
* mode. There is no monitor stage for embed: this path has no legacy callers
|
|
14
|
+
* to keep working, so it fails closed from its first release. A local_only
|
|
15
|
+
* embed with no permitted adapter throws RouterBlockedError (412).
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import type { EmbedAdapter, EmbedDeps, EmbedRequest, EmbedRouteResult, GateOutcome, SovereigntyClass } from './types.js';
|
|
19
|
+
export declare const MAX_EMBED_TEXTS = 256;
|
|
20
|
+
export declare const MAX_EMBED_TEXT_CHARS = 8192;
|
|
21
|
+
export declare const embedRequestSchema: z.ZodObject<{
|
|
22
|
+
texts: z.ZodArray<z.ZodString, "many">;
|
|
23
|
+
cloudpeers: z.ZodObject<{
|
|
24
|
+
v: z.ZodOptional<z.ZodLiteral<1>>;
|
|
25
|
+
sovereignty_class: z.ZodEnum<["local_only", "aggregate_only", "externalizable"]>;
|
|
26
|
+
backend_hint: z.ZodOptional<z.ZodEnum<["selfhosted", "gemini", "anthropic", "edge"]>>;
|
|
27
|
+
}, "strict", z.ZodTypeAny, {
|
|
28
|
+
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
29
|
+
v?: 1 | undefined;
|
|
30
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | "edge" | undefined;
|
|
31
|
+
}, {
|
|
32
|
+
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
33
|
+
v?: 1 | undefined;
|
|
34
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | "edge" | undefined;
|
|
35
|
+
}>;
|
|
36
|
+
}, "strict", z.ZodTypeAny, {
|
|
37
|
+
texts: string[];
|
|
38
|
+
cloudpeers: {
|
|
39
|
+
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
40
|
+
v?: 1 | undefined;
|
|
41
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | "edge" | undefined;
|
|
42
|
+
};
|
|
43
|
+
}, {
|
|
44
|
+
texts: string[];
|
|
45
|
+
cloudpeers: {
|
|
46
|
+
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
47
|
+
v?: 1 | undefined;
|
|
48
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | "edge" | undefined;
|
|
49
|
+
};
|
|
50
|
+
}>;
|
|
51
|
+
/** Parse + narrow. Throws RouterValidationError (statusCode 400) on shape errors. */
|
|
52
|
+
export declare function parseEmbedRequest(input: unknown): EmbedRequest;
|
|
53
|
+
export interface EmbedContext {
|
|
54
|
+
userId: string;
|
|
55
|
+
serviceId: string;
|
|
56
|
+
/** Metering label (token-tracker `operation`). */
|
|
57
|
+
operation: string;
|
|
58
|
+
metadata?: Record<string, unknown>;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The embed gate: evaluateGate's rule with the monitor stage removed.
|
|
63
|
+
* `would_block` (monitor) is promoted to `blocked`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function evaluateEmbedGate(sovereigntyClass: SovereigntyClass, adapter: EmbedAdapter): GateOutcome;
|
|
66
|
+
export declare function routeEmbed(input: unknown, ctx: EmbedContext, deps: EmbedDeps): Promise<EmbedRouteResult>;
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed contract — `routeEmbed` (edge-inference Phase 1, 2026-09-24).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from the generate path (routeMessages / BackendAdapter
|
|
5
|
+
* / tier chain), which is untouched. Flow: validate (400) → order adapters
|
|
6
|
+
* (advisory backend_hint) → per adapter: availability → privacy gate →
|
|
7
|
+
* embed → shape check (a malformed result escalates to the next adapter) →
|
|
8
|
+
* uniform metering through the injected meter.
|
|
9
|
+
*
|
|
10
|
+
* Gate: the same rule as `evaluateGate` — for local_only, cloud surfaces are
|
|
11
|
+
* permitted only through a MODEL_ROUTER_BAA_PROVIDERS provider; edge and
|
|
12
|
+
* self_hosted (cloud:false) are always permitted — but always in ENFORCE
|
|
13
|
+
* mode. There is no monitor stage for embed: this path has no legacy callers
|
|
14
|
+
* to keep working, so it fails closed from its first release. A local_only
|
|
15
|
+
* embed with no permitted adapter throws RouterBlockedError (412).
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { RouterBlockedError, RouterError, RouterValidationError } from './errors.js';
|
|
19
|
+
import { gateRule } from './policy.js';
|
|
20
|
+
export const MAX_EMBED_TEXTS = 256;
|
|
21
|
+
export const MAX_EMBED_TEXT_CHARS = 8192;
|
|
22
|
+
const PROVIDERS = ['selfhosted', 'gemini', 'anthropic', 'edge'];
|
|
23
|
+
export const embedRequestSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
texts: z.array(z.string().min(1).max(MAX_EMBED_TEXT_CHARS)).min(1).max(MAX_EMBED_TEXTS),
|
|
26
|
+
cloudpeers: z
|
|
27
|
+
.object({
|
|
28
|
+
v: z.literal(1).optional(),
|
|
29
|
+
sovereignty_class: z.enum(['local_only', 'aggregate_only', 'externalizable']),
|
|
30
|
+
backend_hint: z.enum(PROVIDERS).optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict(),
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
35
|
+
/** Parse + narrow. Throws RouterValidationError (statusCode 400) on shape errors. */
|
|
36
|
+
export function parseEmbedRequest(input) {
|
|
37
|
+
const parsed = embedRequestSchema.safeParse(input);
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
|
|
40
|
+
throw new RouterValidationError(`Invalid embed request: ${detail}`);
|
|
41
|
+
}
|
|
42
|
+
return parsed.data;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The embed gate: evaluateGate's rule with the monitor stage removed.
|
|
46
|
+
* `would_block` (monitor) is promoted to `blocked`.
|
|
47
|
+
*/
|
|
48
|
+
export function evaluateEmbedGate(sovereigntyClass, adapter) {
|
|
49
|
+
const outcome = gateRule(sovereigntyClass, adapter.surface === 'cloud', adapter.id);
|
|
50
|
+
return outcome === 'would_block' ? 'blocked' : outcome;
|
|
51
|
+
}
|
|
52
|
+
function key(a) {
|
|
53
|
+
return `embed/${a.id}`;
|
|
54
|
+
}
|
|
55
|
+
function ordered(adapters, hint) {
|
|
56
|
+
if (!hint)
|
|
57
|
+
return adapters;
|
|
58
|
+
return [...adapters.filter((a) => a.id === hint), ...adapters.filter((a) => a.id !== hint)];
|
|
59
|
+
}
|
|
60
|
+
function wellFormed(out, n) {
|
|
61
|
+
return (Array.isArray(out.vectors) &&
|
|
62
|
+
out.vectors.length === n &&
|
|
63
|
+
Number.isInteger(out.dims) &&
|
|
64
|
+
out.dims > 0 &&
|
|
65
|
+
out.vectors.every((v) => v.length === out.dims));
|
|
66
|
+
}
|
|
67
|
+
function abortError() {
|
|
68
|
+
const aborted = new Error('embed request aborted');
|
|
69
|
+
aborted.name = 'AbortError';
|
|
70
|
+
return aborted;
|
|
71
|
+
}
|
|
72
|
+
export async function routeEmbed(input, ctx, deps) {
|
|
73
|
+
const req = parseEmbedRequest(input);
|
|
74
|
+
const sov = req.cloudpeers.sovereignty_class;
|
|
75
|
+
const walked = [];
|
|
76
|
+
const blockedCloud = [];
|
|
77
|
+
let escalations = 0;
|
|
78
|
+
let lastError = null;
|
|
79
|
+
/** True while every adapter failure so far has been a caller-side (4xx) RouterError. */
|
|
80
|
+
let allClientErrors = true;
|
|
81
|
+
const decisionFor = (chosen) => {
|
|
82
|
+
const d = {
|
|
83
|
+
sovereignty_class: sov,
|
|
84
|
+
eligible_set: [...walked],
|
|
85
|
+
chosen_backend: chosen ? key(chosen) : null,
|
|
86
|
+
};
|
|
87
|
+
if (sov === 'local_only') {
|
|
88
|
+
d.no_egress_proof = {
|
|
89
|
+
gate_enforced: true,
|
|
90
|
+
cloud_tiers_blocked: [...blockedCloud],
|
|
91
|
+
chosen_backend_cloud: chosen ? chosen.surface === 'cloud' : false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return d;
|
|
95
|
+
};
|
|
96
|
+
for (const adapter of ordered(deps.adapters, req.cloudpeers.backend_hint)) {
|
|
97
|
+
if (ctx.signal?.aborted)
|
|
98
|
+
throw abortError();
|
|
99
|
+
if (!adapter.available())
|
|
100
|
+
continue;
|
|
101
|
+
const gate = evaluateEmbedGate(sov, adapter);
|
|
102
|
+
if (gate === 'blocked') {
|
|
103
|
+
blockedCloud.push(key(adapter));
|
|
104
|
+
console.warn(`[privacy-gate] BLOCKED local_only → ${key(adapter)} (op=${ctx.operation} svc=${ctx.serviceId})`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
walked.push(key(adapter));
|
|
108
|
+
const started = performance.now();
|
|
109
|
+
try {
|
|
110
|
+
const out = await adapter.embed(req.texts, {
|
|
111
|
+
userId: ctx.userId,
|
|
112
|
+
serviceId: ctx.serviceId,
|
|
113
|
+
operation: ctx.operation,
|
|
114
|
+
signal: ctx.signal,
|
|
115
|
+
});
|
|
116
|
+
if (!wellFormed(out, req.texts.length))
|
|
117
|
+
throw new Error('malformed embed result (vector count or dims)');
|
|
118
|
+
const latency_ms = Math.round(performance.now() - started);
|
|
119
|
+
const decision = decisionFor(adapter);
|
|
120
|
+
await deps
|
|
121
|
+
.meter({
|
|
122
|
+
userId: ctx.userId,
|
|
123
|
+
serviceId: ctx.serviceId,
|
|
124
|
+
model: out.model,
|
|
125
|
+
operation: ctx.operation,
|
|
126
|
+
promptTokens: out.tokens_in ?? 0,
|
|
127
|
+
completionTokens: 0,
|
|
128
|
+
metadata: {
|
|
129
|
+
...ctx.metadata,
|
|
130
|
+
router: {
|
|
131
|
+
op: 'embed',
|
|
132
|
+
provider: adapter.id,
|
|
133
|
+
surface: adapter.surface,
|
|
134
|
+
gate,
|
|
135
|
+
dims: out.dims,
|
|
136
|
+
count: out.vectors.length,
|
|
137
|
+
decision,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
.catch((err) => console.error('[model-router] embed metering failed (non-blocking):', err?.message));
|
|
142
|
+
return {
|
|
143
|
+
...out,
|
|
144
|
+
provider: adapter.id,
|
|
145
|
+
surface: adapter.surface,
|
|
146
|
+
gate,
|
|
147
|
+
latency_ms,
|
|
148
|
+
escalations,
|
|
149
|
+
decision,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
154
|
+
if (!(lastError instanceof RouterError && lastError.statusCode >= 400 && lastError.statusCode < 500)) {
|
|
155
|
+
allClientErrors = false;
|
|
156
|
+
}
|
|
157
|
+
escalations++;
|
|
158
|
+
console.warn(`[model-router] ${key(adapter)} failed (op=${ctx.operation}): ${lastError.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
console.warn(`[stage1-decision] ${JSON.stringify(decisionFor(null))}`);
|
|
162
|
+
if (sov === 'local_only' && walked.length === 0) {
|
|
163
|
+
throw new RouterBlockedError('No permitted embed backend for local_only (edge/self_hosted unavailable, no BAA provider)', 412, 'routing_blocked');
|
|
164
|
+
}
|
|
165
|
+
if (ctx.signal?.aborted)
|
|
166
|
+
throw abortError();
|
|
167
|
+
if (lastError) {
|
|
168
|
+
// Every failure was the caller's (4xx RouterError): surface it unchanged, not as an outage.
|
|
169
|
+
if (allClientErrors)
|
|
170
|
+
throw lastError;
|
|
171
|
+
const unavailable = new RouterError(`Every permitted embed backend failed; last: ${lastError.message}`, 503, 'embed_backends_failed');
|
|
172
|
+
unavailable.cause = lastError;
|
|
173
|
+
throw unavailable;
|
|
174
|
+
}
|
|
175
|
+
throw new RouterError('No available embed backend', 503, 'no_embed_backend');
|
|
176
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
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
|
+
export { routeEmbed, parseEmbedRequest, embedRequestSchema, evaluateEmbedGate } from './embed.js';
|
|
13
|
+
export type { EmbedContext } from './embed.js';
|
|
14
|
+
export { edgeEmbedDescriptor, EDGE_EMBED_MODEL } from './adapters/edge.js';
|
|
12
15
|
export type { RouteContext, MessagesStreamResult } from './router.js';
|
|
13
16
|
export { capabilityMatrix } from './capabilities.js';
|
|
14
17
|
export type { BackendCapabilities } from './capabilities.js';
|
|
15
18
|
export { anthropicAdapter } from './adapters/anthropic.js';
|
|
16
19
|
export { geminiAdapter, anthropicToGeminiContents, geminiResponseToAnthropic, geminiChunksToSSE, geminiFinishToStopReason, } from './adapters/gemini.js';
|
|
17
20
|
export { selfhostedAdapter, messagesToPrompt } from './adapters/selfhosted.js';
|
|
18
|
-
import type { BackendAdapter,
|
|
21
|
+
import type { BackendAdapter, GenerateProvider } from './types.js';
|
|
19
22
|
/** The approved backend set (A1.2): selfhosted, gemini, anthropic. No OpenAI. */
|
|
20
|
-
export declare function defaultBackendAdapters(): Record<
|
|
23
|
+
export declare function defaultBackendAdapters(): Record<GenerateProvider, BackendAdapter>;
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
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
|
+
export { routeEmbed, parseEmbedRequest, embedRequestSchema, evaluateEmbedGate } from './embed.js';
|
|
13
|
+
export { edgeEmbedDescriptor, EDGE_EMBED_MODEL } from './adapters/edge.js';
|
|
12
14
|
export { capabilityMatrix } from './capabilities.js';
|
|
13
15
|
export { anthropicAdapter } from './adapters/anthropic.js';
|
|
14
16
|
export { geminiAdapter, anthropicToGeminiContents, geminiResponseToAnthropic, geminiChunksToSSE, geminiFinishToStopReason, } from './adapters/gemini.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,8 @@ 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
|
+
/** The gate rule itself, shared by evaluateGate (tiers) and evaluateEmbedGate (embed adapters). */
|
|
14
|
+
export declare function gateRule(sovereigntyClass: SovereigntyClass, cloud: boolean, provider: Provider): GateOutcome;
|
|
30
15
|
/**
|
|
31
16
|
* §4 — the caller's `model` is honored when it belongs to the selected
|
|
32
17
|
* tier's provider family (a gemini caller may pick flash vs pro); otherwise
|
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(',')
|
|
@@ -25,9 +99,13 @@ export function gateEnforced() {
|
|
|
25
99
|
* Never triggers for aggregate_only/externalizable (spec scope: local_only).
|
|
26
100
|
*/
|
|
27
101
|
export function evaluateGate(sovereigntyClass, tier) {
|
|
28
|
-
|
|
102
|
+
return gateRule(sovereigntyClass, tier.cloud, tier.provider);
|
|
103
|
+
}
|
|
104
|
+
/** The gate rule itself, shared by evaluateGate (tiers) and evaluateEmbedGate (embed adapters). */
|
|
105
|
+
export function gateRule(sovereigntyClass, cloud, provider) {
|
|
106
|
+
if (sovereigntyClass !== 'local_only' || !cloud)
|
|
29
107
|
return 'allowed';
|
|
30
|
-
if (baaProviders().has(
|
|
108
|
+
if (baaProviders().has(provider))
|
|
31
109
|
return 'allowed'; // Stage 2 governed egress
|
|
32
110
|
return gateEnforced() ? 'blocked' : 'would_block'; // Stage 3 : Stage 1
|
|
33
111
|
}
|
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, modelMatchesProvider, 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 ?? '';
|
|
@@ -69,6 +69,25 @@ async function checkQuotaOnce(req, ctx, deps) {
|
|
|
69
69
|
throw new Error(`Token quota exceeded. ${quotaCheck.remainingTokens} tokens remaining. Quota resets at ${quotaCheck.quotaResetAt}.`);
|
|
70
70
|
}
|
|
71
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
|
+
}
|
|
72
91
|
/**
|
|
73
92
|
* Walk the chain applying availability, the privacy gate, per-backend policy,
|
|
74
93
|
* and adapter eligibility (§3.3/§7.1 #3-4, §8.2). Yields callable candidates
|
|
@@ -84,6 +103,7 @@ async function* eligibleTiers(req, ctx, deps, state) {
|
|
|
84
103
|
continue; // tier not deployed (e.g. Tier 0 pre-OpenMed) — next
|
|
85
104
|
const gate = evaluateGate(env.sovereignty_class, tier);
|
|
86
105
|
if (gate === 'blocked') {
|
|
106
|
+
state.blockedCloud.push(tierKey(tier));
|
|
87
107
|
console.warn(`[privacy-gate] BLOCKED local_only → tier${tier.tier}/${tier.provider} (op=${ctx.operation} svc=${ctx.serviceId})`);
|
|
88
108
|
continue; // fail closed on this tier
|
|
89
109
|
}
|
|
@@ -120,6 +140,7 @@ async function* eligibleTiers(req, ctx, deps, state) {
|
|
|
120
140
|
console.warn(`[model-router] tier${tier.tier}/${tier.provider} ineligible: ${reason}`);
|
|
121
141
|
continue;
|
|
122
142
|
}
|
|
143
|
+
state.walked.push(tierKey(tier));
|
|
123
144
|
yield { tier, adapter, gate };
|
|
124
145
|
}
|
|
125
146
|
}
|
|
@@ -133,7 +154,7 @@ function exhaustedError(env, state, lastError) {
|
|
|
133
154
|
return lastError ?? new Error(`No available tier for task class ${env.task_class}`);
|
|
134
155
|
}
|
|
135
156
|
/** §7.2 post-call hooks + §10 uniform metering. Observation-only; never gates. */
|
|
136
|
-
async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps, state) {
|
|
157
|
+
async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps, state, decision) {
|
|
137
158
|
const env = req.cloudpeers;
|
|
138
159
|
const usage = {
|
|
139
160
|
...candidate.adapter.reportUsage(stripped, response),
|
|
@@ -175,6 +196,7 @@ async function postCall(candidate, stripped, response, latencyMs, req, ctx, deps
|
|
|
175
196
|
tier: candidate.tier.tier,
|
|
176
197
|
taskClass: env.task_class,
|
|
177
198
|
gate,
|
|
199
|
+
decision,
|
|
178
200
|
...(outcomeMatches !== undefined ? { outcome_matches_mandate: outcomeMatches } : {}),
|
|
179
201
|
},
|
|
180
202
|
},
|
|
@@ -191,7 +213,7 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
191
213
|
const env = req.cloudpeers;
|
|
192
214
|
await enforcePreCall(env, deps);
|
|
193
215
|
await checkQuotaOnce(req, ctx, deps);
|
|
194
|
-
const state = { requestGate: 'allowed', toolStrictViolation: false };
|
|
216
|
+
const state = { requestGate: 'allowed', toolStrictViolation: false, walked: [], blockedCloud: [] };
|
|
195
217
|
let escalations = 0;
|
|
196
218
|
let lastError = null;
|
|
197
219
|
let bestEffort = null;
|
|
@@ -205,7 +227,8 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
205
227
|
operation: ctx.operation,
|
|
206
228
|
signal: ctx.signal,
|
|
207
229
|
});
|
|
208
|
-
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);
|
|
209
232
|
const result = {
|
|
210
233
|
response,
|
|
211
234
|
provider: candidate.tier.provider,
|
|
@@ -215,6 +238,7 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
215
238
|
degraded: false,
|
|
216
239
|
gate: state.requestGate === 'would_block' ? 'would_block' : candidate.gate,
|
|
217
240
|
usage,
|
|
241
|
+
decision,
|
|
218
242
|
};
|
|
219
243
|
if (ctx.validate && !ctx.validate(firstText(response.content))) {
|
|
220
244
|
// Confidence v1: shape validation failed — keep as best-effort, escalate.
|
|
@@ -232,6 +256,8 @@ export async function routeMessages(input, ctx, deps) {
|
|
|
232
256
|
}
|
|
233
257
|
if (bestEffort)
|
|
234
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))}`);
|
|
235
261
|
throw exhaustedError(env, state, lastError);
|
|
236
262
|
}
|
|
237
263
|
/**
|
|
@@ -244,7 +270,7 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
244
270
|
const env = req.cloudpeers;
|
|
245
271
|
await enforcePreCall(env, deps);
|
|
246
272
|
await checkQuotaOnce(req, ctx, deps);
|
|
247
|
-
const state = { requestGate: 'allowed', toolStrictViolation: false };
|
|
273
|
+
const state = { requestGate: 'allowed', toolStrictViolation: false, walked: [], blockedCloud: [] };
|
|
248
274
|
for await (const candidate of eligibleTiers(req, ctx, deps, state)) {
|
|
249
275
|
if (!candidate.adapter.supports_streaming)
|
|
250
276
|
continue;
|
|
@@ -285,7 +311,7 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
285
311
|
stop_reason: stopReason,
|
|
286
312
|
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
287
313
|
};
|
|
288
|
-
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));
|
|
289
315
|
};
|
|
290
316
|
return {
|
|
291
317
|
provider: candidate.tier.provider,
|
|
@@ -295,6 +321,7 @@ export async function routeMessagesStream(input, ctx, deps) {
|
|
|
295
321
|
stream: metered(),
|
|
296
322
|
};
|
|
297
323
|
}
|
|
324
|
+
console.warn(`[stage1-decision] ${JSON.stringify(stageOneDecision(env, state, null))}`);
|
|
298
325
|
throw exhaustedError(env, state, null);
|
|
299
326
|
}
|
|
300
327
|
/**
|
package/dist/schema.d.ts
CHANGED
|
@@ -35,11 +35,11 @@ export declare const cloudpeersEnvelopeSchema: z.ZodObject<{
|
|
|
35
35
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
36
36
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
37
37
|
v?: 1 | undefined;
|
|
38
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
38
39
|
mandate_id?: string | undefined;
|
|
39
40
|
policy_id?: string | undefined;
|
|
40
41
|
attribution_chain?: string[] | undefined;
|
|
41
42
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
42
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
43
43
|
tool_use_strict?: boolean | undefined;
|
|
44
44
|
byok?: {
|
|
45
45
|
provider: string;
|
|
@@ -49,11 +49,11 @@ export declare const cloudpeersEnvelopeSchema: z.ZodObject<{
|
|
|
49
49
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
50
50
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
51
51
|
v?: 1 | undefined;
|
|
52
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
52
53
|
mandate_id?: string | undefined;
|
|
53
54
|
policy_id?: string | undefined;
|
|
54
55
|
attribution_chain?: string[] | undefined;
|
|
55
56
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
56
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
57
57
|
tool_use_strict?: boolean | undefined;
|
|
58
58
|
byok?: {
|
|
59
59
|
provider: string;
|
|
@@ -125,11 +125,11 @@ export declare const routerMessagesRequestSchema: z.ZodObject<{
|
|
|
125
125
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
126
126
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
127
127
|
v?: 1 | undefined;
|
|
128
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
128
129
|
mandate_id?: string | undefined;
|
|
129
130
|
policy_id?: string | undefined;
|
|
130
131
|
attribution_chain?: string[] | undefined;
|
|
131
132
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
132
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
133
133
|
tool_use_strict?: boolean | undefined;
|
|
134
134
|
byok?: {
|
|
135
135
|
provider: string;
|
|
@@ -139,11 +139,11 @@ export declare const routerMessagesRequestSchema: z.ZodObject<{
|
|
|
139
139
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
140
140
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
141
141
|
v?: 1 | undefined;
|
|
142
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
142
143
|
mandate_id?: string | undefined;
|
|
143
144
|
policy_id?: string | undefined;
|
|
144
145
|
attribution_chain?: string[] | undefined;
|
|
145
146
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
146
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
147
147
|
tool_use_strict?: boolean | undefined;
|
|
148
148
|
byok?: {
|
|
149
149
|
provider: string;
|
|
@@ -215,11 +215,11 @@ export declare const routerMessagesRequestSchema: z.ZodObject<{
|
|
|
215
215
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
216
216
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
217
217
|
v?: 1 | undefined;
|
|
218
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
218
219
|
mandate_id?: string | undefined;
|
|
219
220
|
policy_id?: string | undefined;
|
|
220
221
|
attribution_chain?: string[] | undefined;
|
|
221
222
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
222
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
223
223
|
tool_use_strict?: boolean | undefined;
|
|
224
224
|
byok?: {
|
|
225
225
|
provider: string;
|
|
@@ -229,11 +229,11 @@ export declare const routerMessagesRequestSchema: z.ZodObject<{
|
|
|
229
229
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
230
230
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
231
231
|
v?: 1 | undefined;
|
|
232
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
232
233
|
mandate_id?: string | undefined;
|
|
233
234
|
policy_id?: string | undefined;
|
|
234
235
|
attribution_chain?: string[] | undefined;
|
|
235
236
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
236
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
237
237
|
tool_use_strict?: boolean | undefined;
|
|
238
238
|
byok?: {
|
|
239
239
|
provider: string;
|
|
@@ -305,11 +305,11 @@ export declare const routerMessagesRequestSchema: z.ZodObject<{
|
|
|
305
305
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
306
306
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
307
307
|
v?: 1 | undefined;
|
|
308
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
308
309
|
mandate_id?: string | undefined;
|
|
309
310
|
policy_id?: string | undefined;
|
|
310
311
|
attribution_chain?: string[] | undefined;
|
|
311
312
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
312
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
313
313
|
tool_use_strict?: boolean | undefined;
|
|
314
314
|
byok?: {
|
|
315
315
|
provider: string;
|
|
@@ -319,11 +319,11 @@ export declare const routerMessagesRequestSchema: z.ZodObject<{
|
|
|
319
319
|
sovereignty_class: "local_only" | "aggregate_only" | "externalizable";
|
|
320
320
|
task_class: "extraction" | "classification" | "coaching" | "reasoning" | "synthesis";
|
|
321
321
|
v?: 1 | undefined;
|
|
322
|
+
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
322
323
|
mandate_id?: string | undefined;
|
|
323
324
|
policy_id?: string | undefined;
|
|
324
325
|
attribution_chain?: string[] | undefined;
|
|
325
326
|
trust_level?: "L0" | "L1" | "L2" | "L3" | "L4" | "L5" | undefined;
|
|
326
|
-
backend_hint?: "selfhosted" | "gemini" | "anthropic" | undefined;
|
|
327
327
|
tool_use_strict?: boolean | undefined;
|
|
328
328
|
byok?: {
|
|
329
329
|
provider: string;
|
package/dist/types.d.ts
CHANGED
|
@@ -16,7 +16,15 @@
|
|
|
16
16
|
export type SovereigntyClass = 'local_only' | 'aggregate_only' | 'externalizable';
|
|
17
17
|
/** Static task classes (confidence v1 — no learned complexity classifier). */
|
|
18
18
|
export type TaskClass = 'extraction' | 'classification' | 'coaching' | 'reasoning' | 'synthesis';
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `edge` = on-device execution in the caller's browser/app (LiteRT / ONNX
|
|
21
|
+
* WASM). It appears only on the embed contract (`EmbedAdapter`); the
|
|
22
|
+
* generate path (tier table, routing-policy.json, backend_hint) never
|
|
23
|
+
* resolves to it.
|
|
24
|
+
*/
|
|
25
|
+
export type Provider = 'selfhosted' | 'gemini' | 'anthropic' | 'edge';
|
|
26
|
+
/** Providers the generate path (tier chain / BackendAdapter) can resolve to. */
|
|
27
|
+
export type GenerateProvider = Exclude<Provider, 'edge'>;
|
|
20
28
|
export type BackendSurface = 'cloud' | 'self_hosted' | 'edge';
|
|
21
29
|
export type GateOutcome = 'allowed' | 'would_block' | 'blocked';
|
|
22
30
|
export interface TextBlock {
|
|
@@ -60,7 +68,7 @@ export interface CloudpeersEnvelope {
|
|
|
60
68
|
/** Lab Zero delegation grant. */
|
|
61
69
|
trust_level?: 'L0' | 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
|
62
70
|
/** Advisory backend selector — reorders the tier chain, never bypasses the gate. */
|
|
63
|
-
backend_hint?:
|
|
71
|
+
backend_hint?: GenerateProvider;
|
|
64
72
|
/** §8.2 — reject 422 instead of degrading when tool-use support is missing. */
|
|
65
73
|
tool_use_strict?: boolean;
|
|
66
74
|
/** §10.2 — accepted by the schema, REJECTED by the router (deferred per A1.2). */
|
|
@@ -138,7 +146,7 @@ export interface InvocationContext {
|
|
|
138
146
|
signal?: AbortSignal;
|
|
139
147
|
}
|
|
140
148
|
export interface BackendAdapter {
|
|
141
|
-
id:
|
|
149
|
+
id: GenerateProvider;
|
|
142
150
|
display_name: string;
|
|
143
151
|
surface: BackendSurface;
|
|
144
152
|
supports_streaming: boolean;
|
|
@@ -189,7 +197,7 @@ export interface PolicyVerdict {
|
|
|
189
197
|
export interface RouterHooks {
|
|
190
198
|
verifyMandate?: (mandateId: string) => Promise<MandateVerdict>;
|
|
191
199
|
applyPolicy?: (policyId: string, call: {
|
|
192
|
-
backend_id:
|
|
200
|
+
backend_id: GenerateProvider;
|
|
193
201
|
model: string;
|
|
194
202
|
tier: number;
|
|
195
203
|
}) => Promise<PolicyVerdict>;
|
|
@@ -201,7 +209,7 @@ export interface RouterHooks {
|
|
|
201
209
|
writeAttribution?: (chain: string[], usage: UsageEvent) => Promise<void>;
|
|
202
210
|
}
|
|
203
211
|
export interface RouterDeps {
|
|
204
|
-
adapters: Partial<Record<
|
|
212
|
+
adapters: Partial<Record<GenerateProvider, BackendAdapter>>;
|
|
205
213
|
meter: Meter;
|
|
206
214
|
quota: QuotaFn;
|
|
207
215
|
hooks?: RouterHooks;
|
|
@@ -225,7 +233,7 @@ export interface RouterRequest {
|
|
|
225
233
|
}
|
|
226
234
|
export interface RouterResult {
|
|
227
235
|
text: string;
|
|
228
|
-
provider:
|
|
236
|
+
provider: GenerateProvider;
|
|
229
237
|
model: string;
|
|
230
238
|
tier: number;
|
|
231
239
|
escalations: number;
|
|
@@ -237,20 +245,81 @@ export interface RouterResult {
|
|
|
237
245
|
totalTokens: number;
|
|
238
246
|
}
|
|
239
247
|
/** Spec-API result: full Anthropic response + the routing telemetry. */
|
|
248
|
+
/**
|
|
249
|
+
* §N.5 Stage-1 decision record — one per routing decision, success or 412.
|
|
250
|
+
* `no_egress_proof` is present only for `local_only`: the router's claim about
|
|
251
|
+
* cloud egress for this request. The empirical proof lives outside the library
|
|
252
|
+
* (gateway harness zero-canary assertion, joined by trace); this record is the
|
|
253
|
+
* claim and the join key's payload.
|
|
254
|
+
*/
|
|
255
|
+
export interface StageOneDecision {
|
|
256
|
+
sovereignty_class: CloudpeersEnvelope['sovereignty_class'];
|
|
257
|
+
/** `tier{n}/{provider}` candidates that passed the gate walk, in chain order. */
|
|
258
|
+
eligible_set: string[];
|
|
259
|
+
/** null on a 412 (empty eligible set). */
|
|
260
|
+
chosen_backend: string | null;
|
|
261
|
+
no_egress_proof?: {
|
|
262
|
+
gate_enforced: boolean;
|
|
263
|
+
/** Cloud tiers the gate failed closed on. */
|
|
264
|
+
cloud_tiers_blocked: string[];
|
|
265
|
+
/** true only when a cloud tier served the request (BAA-governed or monitor mode). */
|
|
266
|
+
chosen_backend_cloud: boolean;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
240
269
|
export interface MessagesRouteResult {
|
|
241
270
|
response: AnthropicMessagesResponse;
|
|
242
|
-
provider:
|
|
271
|
+
provider: GenerateProvider;
|
|
243
272
|
model: string;
|
|
244
273
|
tier: number;
|
|
245
274
|
escalations: number;
|
|
246
275
|
degraded: boolean;
|
|
247
276
|
gate: GateOutcome;
|
|
248
277
|
usage: UsageEvent;
|
|
278
|
+
decision: StageOneDecision;
|
|
249
279
|
}
|
|
250
280
|
export interface TierDef {
|
|
251
281
|
tier: number;
|
|
252
|
-
provider:
|
|
282
|
+
provider: GenerateProvider;
|
|
253
283
|
model: string;
|
|
254
284
|
/** Cloud tiers are subject to the local_only privacy gate. */
|
|
255
285
|
cloud: boolean;
|
|
256
286
|
}
|
|
287
|
+
export interface EmbedOutput {
|
|
288
|
+
vectors: number[][];
|
|
289
|
+
dims: number;
|
|
290
|
+
model: string;
|
|
291
|
+
/** Provider-reported input tokens, when the backend reports them. */
|
|
292
|
+
tokens_in?: number;
|
|
293
|
+
}
|
|
294
|
+
export interface EmbedAdapter {
|
|
295
|
+
id: Provider;
|
|
296
|
+
display_name: string;
|
|
297
|
+
surface: BackendSurface;
|
|
298
|
+
/** Deploy-time availability (key/endpoint configured / executable here). */
|
|
299
|
+
available(): boolean;
|
|
300
|
+
embed(texts: string[], ctx: InvocationContext): Promise<EmbedOutput>;
|
|
301
|
+
}
|
|
302
|
+
export interface EmbedEnvelope {
|
|
303
|
+
v?: 1;
|
|
304
|
+
sovereignty_class: SovereigntyClass;
|
|
305
|
+
/** Advisory: moves this provider's adapter to the front; never bypasses the gate. */
|
|
306
|
+
backend_hint?: Provider;
|
|
307
|
+
}
|
|
308
|
+
export interface EmbedRequest {
|
|
309
|
+
texts: string[];
|
|
310
|
+
cloudpeers: EmbedEnvelope;
|
|
311
|
+
}
|
|
312
|
+
export interface EmbedDeps {
|
|
313
|
+
/** Candidate adapters in preference order. */
|
|
314
|
+
adapters: EmbedAdapter[];
|
|
315
|
+
meter: Meter;
|
|
316
|
+
}
|
|
317
|
+
export interface EmbedRouteResult extends EmbedOutput {
|
|
318
|
+
provider: Provider;
|
|
319
|
+
surface: BackendSurface;
|
|
320
|
+
gate: GateOutcome;
|
|
321
|
+
latency_ms: number;
|
|
322
|
+
/** Adapters that failed before this one served. */
|
|
323
|
+
escalations: number;
|
|
324
|
+
decision: StageOneDecision;
|
|
325
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudpeers-jkl/model-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|
|
@@ -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
|
+
}
|