@tangle-network/agent-app 0.60.192 → 0.60.194

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.
Files changed (37) hide show
  1. package/dist/app-auth/index.js +3 -3
  2. package/dist/assistant/index.js +3 -3
  3. package/dist/chat-routes/index.js +8 -5
  4. package/dist/chat-routes/index.js.map +1 -1
  5. package/dist/chat-routes/model-failover-stream.d.ts +8 -0
  6. package/dist/chat-routes/sandbox-producer.d.ts +12 -0
  7. package/dist/chat-routes/turn-routes.d.ts +14 -0
  8. package/dist/{chunk-QAF7ZTO3.js → chunk-L5JLOC2F.js} +4 -4
  9. package/dist/{chunk-QW4UOEPH.js → chunk-SUIH5B55.js} +5 -3
  10. package/dist/chunk-SUIH5B55.js.map +1 -0
  11. package/dist/chunk-XELWWXJJ.js +101 -0
  12. package/dist/chunk-XELWWXJJ.js.map +1 -0
  13. package/dist/{chunk-Q2H5FKVG.js → chunk-XP77DJEF.js} +16 -1
  14. package/dist/chunk-XP77DJEF.js.map +1 -0
  15. package/dist/{chunk-63EKFZOE.js → chunk-ZLX4BA27.js} +15 -1
  16. package/dist/chunk-ZLX4BA27.js.map +1 -0
  17. package/dist/model-resolution/failover.d.ts +16 -2
  18. package/dist/model-resolution/index.d.ts +42 -0
  19. package/dist/model-resolution/index.js +30 -5
  20. package/dist/model-resolution/index.js.map +1 -1
  21. package/dist/policy-filters/index.d.ts +166 -0
  22. package/dist/policy-filters/index.js +23 -0
  23. package/dist/policy-filters/index.js.map +1 -0
  24. package/dist/public-consultation/index.d.ts +1 -1
  25. package/dist/public-consultation/index.js +2 -1
  26. package/dist/public-consultation/index.js.map +1 -1
  27. package/dist/sandbox/index.d.ts +1 -0
  28. package/dist/sandbox/index.js +3 -3
  29. package/dist/sandbox/model.d.ts +4 -0
  30. package/dist/settings-react/index.js +1 -1
  31. package/dist/tools/index.js +2 -2
  32. package/dist/web-react/index.js +7 -7
  33. package/package.json +6 -1
  34. package/dist/chunk-63EKFZOE.js.map +0 -1
  35. package/dist/chunk-Q2H5FKVG.js.map +0 -1
  36. package/dist/chunk-QW4UOEPH.js.map +0 -1
  37. /package/dist/{chunk-QAF7ZTO3.js.map → chunk-L5JLOC2F.js.map} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/model-resolution/failover.ts"],"sourcesContent":["/**\n * Model failover — the answer to \"one upstream hit a quota wall and the customer\n * got a Bad Gateway even though the router had abundant healthy capacity\".\n *\n * This is deliberately NOT a health probe. Probing before every turn buys a\n * round-trip of latency on the happy path and still races the outage (a model\n * healthy at probe time can 502 a second later). Failover is REACTIVE: run the\n * preferred model, and on an upstream-outage signal move to the next model in\n * the chain. Zero added latency when the preferred model works.\n *\n * Two facts drive the design, both measured against a live box during the\n * 2026-07-25 Anthropic/DeepSeek outage:\n *\n * 1. An outage is NOT always a thrown error. The sandbox resolves with a\n * payload — `{ success: false, errorCode: 'provider_inference_unavailable' }`\n * — so a classifier that only inspects `catch` misses the customer-visible\n * case entirely. `isUpstreamUnavailable` inspects resolved values too.\n * 2. Catalog membership is NOT liveness. The router's `/v1/models` still listed\n * every dead model during the outage, so `validateChatModelId` admitted\n * `claude-sonnet-4-6` while every call to it returned 502. Nothing upstream\n * of the actual call can be trusted to tell you a model is serving.\n *\n * Substrate-free: the caller injects `run`, so this composes with a sandbox\n * turn, a router chat completion, or a test fake without importing any of them.\n */\n\nimport {\n checkModelPolicy,\n type ModelPolicy,\n} from '../policy-filters/index'\n\n/**\n * Error codes that mean \"this model's upstream is unavailable — a different\n * model may still work\". Deliberately excludes codes that would fail identically\n * on every model (bad request, auth, content filter): retrying those down a\n * chain burns latency and money to reach the same failure.\n */\nexport const UPSTREAM_UNAVAILABLE_CODES: readonly string[] = [\n 'provider_inference_unavailable',\n 'provider_quota_exhausted',\n 'upstream_unavailable',\n 'insufficient_quota',\n 'model_not_available',\n 'server_error',\n 'bad_gateway',\n 'service_unavailable',\n]\n\n/** HTTP statuses that indicate an upstream capacity/availability problem. */\nexport const UPSTREAM_UNAVAILABLE_STATUSES: readonly number[] = [429, 500, 502, 503, 504]\n\n/**\n * Statuses that mean the REQUEST is wrong, so every model in the chain would\n * fail on it identically. Listed explicitly rather than as \"any 4xx\" because\n * 404 is NOT one of them: a model the endpoint cannot find is model-scoped, and\n * the next model in the chain may well be there. 429 is absent for the same\n * reason in reverse — it is a capacity signal and lives in the list above.\n */\nconst REQUEST_ERROR_STATUSES: readonly number[] = [400, 401, 403, 405, 413, 422]\n\n/**\n * Message fragments emitted by real upstreams during this class of outage.\n * Matched case-insensitively as a last resort, after code and status.\n */\nconst UPSTREAM_UNAVAILABLE_MESSAGES: readonly string[] = [\n 'bad gateway',\n 'service unavailable',\n 'inference temporarily unavailable',\n 'provider inference is unavailable',\n 'insufficient balance',\n 'usage limits',\n 'quota exceeded',\n 'rate limit',\n 'overloaded',\n 'temporarily unavailable',\n // Model-SCOPED unavailability. A different model in the chain can still\n // serve, so this belongs here and not with the client errors. Measured on a\n // real box 2026-07-27: the sandbox now reports an unservable model as a\n // terminal `error` whose data is `{\"message\":\"Session error:\n // {\\\"error\\\":{\\\"name\\\":\\\"UnknownError\\\",\\\"data\\\":{\\\"message\\\":\\\"Model not\n // found: openai-compat/zai/glm-4.7.\\\"}}}\"}` — no `code`, no numeric status,\n // and none of the fragments above. The shipped live product-path proof went\n // red on exactly this: `usedFallback:false`, `failed:true`, an error row to\n // the customer where a fallback was available and would have answered.\n 'model not found',\n 'is not currently available',\n]\n\n/**\n * Textual forms an HTTP status arrives in when NO numeric field carries it.\n *\n * The case that forced this is the opaque Cloudflare 502. Captured verbatim\n * from `router.tangle.tools` on 2026-07-27 17:48:12 GMT (cf-ray\n * `a21d793899f6cef3-DEN`): `HTTP/2 502`, `content-type: text/plain;\n * charset=UTF-8`, a 16-byte body reading `error code: 502\\n`, `server:\n * cloudflare`, and **zero `x-tangle-*` headers** where a healthy 200 from the\n * same endpoint carries nine of them.\n *\n * That capture was originally read as \"zero headers means the origin never\n * executed\". It does not, and the correction matters more than the original\n * guess did. Matching client-observed cf-rays against the origin's own access\n * log (tangle-router #307) showed all 62 of those responses WERE produced by\n * the router — `content-type: application/json`, an `X-Generation-Id`, and a\n * routing trace showing the upstream had returned 200 — and Cloudflare\n * replaced the body and every response header with its own error page, because\n * an origin 502 reads to the edge as the origin having failed.\n *\n * The classifier's BEHAVIOR here was right for the wrong reason, and stays\n * exactly as it is. What changes is why: the rescue is needed not because the\n * origin is unreachable, but because a response the origin carefully\n * structured arrives at the client as sixteen unlabelled bytes. The router now\n * emits 503 instead of 502 on those paths so the body survives, but this\n * pattern must remain — it is the client's only handle on any origin 5xx the\n * edge decides to rewrite, including from a router that has not deployed the\n * fix yet.\n *\n * Every pattern below matches a string a real producer emits:\n * - `error code: 502` — Cloudflare's edge body (the capture above).\n * - `(HTTP 502)` — `src/runtime/openai-stream.ts` wrapping a non-ok response.\n * - `returned 502` — `src/runtime/model-catalog.ts`.\n * - `failed with status 502` — `src/turn-stream/adapters.ts`.\n * - `502 Bad Gateway` — an HTTP/1.1 status line. HTTP/2 carries no reason\n * phrase at all, which is precisely why matching the phrase alone (the\n * `'bad gateway'` fragment below) read the capture as a non-outage.\n */\nconst HTTP_STATUS_HINT_PATTERNS: readonly RegExp[] = [\n /\\berror\\s+code:?\\s*([1-5]\\d{2})\\b/i,\n /\\bhttp(?:\\/[\\d.]+)?[\\s:]\\s*([1-5]\\d{2})\\b/i,\n /\\bstatus(?:\\s*code)?[\\s:=]\\s*([1-5]\\d{2})\\b/i,\n /\\b(?:returned|responded(?:\\s+with)?)\\s+([1-5]\\d{2})\\b/i,\n /\\b([1-5]\\d{2})\\s+(?:bad\\s+gateway|service\\s+unavailable|gateway\\s+time-?out|internal\\s+server\\s+error|too\\s+many\\s+requests)\\b/i,\n]\n\n/**\n * The HTTP status a message states in prose, or `undefined` when it states\n * none. Deliberately NOT \"any three digits in the string\" — an unanchored digit\n * scan would read a token count or a duration as a status.\n */\nexport function readHttpStatusHint(text: string): number | undefined {\n for (const pattern of HTTP_STATUS_HINT_PATTERNS) {\n const match = pattern.exec(text)\n if (match?.[1]) return Number(match[1])\n }\n return undefined\n}\n\nfunction readString(source: Record<string, unknown>, key: string): string | undefined {\n const value = source[key]\n return typeof value === 'string' && value.trim().length > 0 ? value : undefined\n}\n\n/** Read a recognized upstream code, including the terminal message-only shape. */\nexport function readUpstreamUnavailableCode(signal: unknown): string | undefined {\n if (signal === null || typeof signal !== 'object') return undefined\n const record = signal as Record<string, unknown>\n const nested = record.error\n const nestedRecord = nested !== null && typeof nested === 'object' ? (nested as Record<string, unknown>) : undefined\n const code =\n readString(record, 'errorCode') ??\n readString(record, 'code') ??\n (nestedRecord ? (readString(nestedRecord, 'code') ?? readString(nestedRecord, 'type')) : undefined)\n if (code && UPSTREAM_UNAVAILABLE_CODES.includes(code)) return code\n\n const message =\n readString(record, 'message') ??\n readString(record, 'error') ??\n (nestedRecord ? readString(nestedRecord, 'message') : undefined)\n return message && /\\(\\s*provider_quota_exhausted\\s*\\)/i.test(message)\n ? 'provider_quota_exhausted'\n : undefined\n}\n\n/**\n * True when `signal` — a thrown error OR a resolved result payload — indicates\n * the model's upstream is unavailable and another model is worth trying.\n *\n * Checked in order of decreasing confidence: explicit code, HTTP status, then\n * message text. A resolved payload only counts as a failure when it carries an\n * explicit failure marker (`success: false`, or an `error`/`errorCode` field) —\n * a successful result is never misread as an outage.\n */\nexport function isUpstreamUnavailable(signal: unknown): boolean {\n if (signal === null || typeof signal !== 'object') return false\n const record = signal as Record<string, unknown>\n\n // A resolved payload that explicitly reports success is never an outage.\n if (record.success === true) return false\n\n const nested = record.error\n const nestedRecord = nested !== null && typeof nested === 'object' ? (nested as Record<string, unknown>) : undefined\n if (readUpstreamUnavailableCode(record)) return true\n\n for (const key of ['status', 'statusCode', 'httpStatus']) {\n const value = record[key]\n if (typeof value === 'number' && UPSTREAM_UNAVAILABLE_STATUSES.includes(value)) return true\n }\n\n const message =\n readString(record, 'message') ??\n readString(record, 'error') ??\n (nestedRecord ? readString(nestedRecord, 'message') : undefined)\n if (!message) return false\n\n // A status carried as PROSE, checked before the fragment list because it is\n // the stronger signal and it cuts BOTH ways. An edge 502 names no exception\n // type and sets no numeric field, so without this the shipped classifier read\n // `error code: 502` as a non-outage and handed the customer a Bad Gateway\n // instead of trying the next model — the exact failure the router cannot\n // rescue from inside its own origin.\n const hinted = readHttpStatusHint(message)\n if (hinted !== undefined) {\n if (UPSTREAM_UNAVAILABLE_STATUSES.includes(hinted)) return true\n // An explicit REQUEST-error status is decisive the OTHER way. A 400\n // carrying a validation message must surface rather than walk the chain,\n // even when its body happens to contain a word from the fragment list —\n // the router's own `Function tools with reasoning_effort are not supported`\n // 400 is a request-shaping bug that fails identically on every model.\n if (REQUEST_ERROR_STATUSES.includes(hinted)) return false\n }\n\n const lowered = message.toLowerCase()\n return UPSTREAM_UNAVAILABLE_MESSAGES.some((fragment) => lowered.includes(fragment))\n}\n\n/** One model tried, and how it went. */\nexport interface ModelFailoverAttempt {\n model: string\n ok: boolean\n /** Why this model was abandoned. Absent when `ok`. */\n reason?: string\n /** Recognized upstream code carried by this failed attempt. */\n errorCode?: string\n}\n\n/** The outcome of a failover run: the value plus the full attempt trail. */\nexport interface ModelFailoverResult<T> {\n value: T\n /** The model that actually produced `value`. */\n model: string\n attempts: ModelFailoverAttempt[]\n /** True when the preferred (first) model did not serve the request. */\n usedFallback: boolean\n}\n\n/** Every model in the chain failed; carries the trail for logging. */\nexport class ModelFailoverExhaustedError extends Error {\n readonly attempts: ModelFailoverAttempt[]\n /** Last recognized upstream code across the exhausted chain, when present. */\n readonly upstreamCode?: string\n constructor(attempts: ModelFailoverAttempt[]) {\n const trail = attempts.map((a) => `${a.model}: ${a.reason ?? 'failed'}`).join(' | ')\n super(`All ${attempts.length} model(s) failed. ${trail}`)\n this.name = 'ModelFailoverExhaustedError'\n this.attempts = attempts\n this.upstreamCode = [...attempts].reverse().find((attempt) => attempt.errorCode)?.errorCode\n }\n}\n\n/** Inputs to {@link runWithModelFailover}. */\nexport interface RunWithModelFailoverInput<T> {\n /** Preferred model first, then fallbacks in descending preference. */\n models: readonly string[]\n /** Executes one turn with the given model. */\n run: (model: string) => Promise<T>\n /**\n * Product admission policy applied to EVERY chain member before it runs.\n * Without this a compliant model 502ing mid-turn would silently re-run the\n * turn's data through a non-compliant fallback: availability is not\n * eligibility. A member the policy refuses is SKIPPED (recorded in the\n * attempt trail with the refusal as its reason) and the walk continues; when\n * no member is admitted the run ends in {@link ModelFailoverExhaustedError}\n * like any other exhausted chain. Fail-closed — a member carrying no policy\n * metadata is refused.\n */\n policy?: ModelPolicy\n /**\n * Classifies a resolved result as an upstream outage. Defaults to\n * {@link isUpstreamUnavailable}, which understands the sandbox's\n * `{ success: false, errorCode }` payload.\n */\n isUnavailableResult?: (result: T) => boolean\n /** Classifies a thrown error. Defaults to {@link isUpstreamUnavailable}. */\n isUnavailableError?: (error: unknown) => boolean\n /** Observability hook fired each time a model is abandoned — including a\n * policy refusal, whose `reason` is the policy's own refusal text. */\n onFallback?: (attempt: ModelFailoverAttempt, nextModel: string) => void\n}\n\nfunction describe(signal: unknown): string {\n if (signal instanceof Error) return signal.message\n if (signal !== null && typeof signal === 'object') {\n const record = signal as Record<string, unknown>\n const message = readString(record, 'error') ?? readString(record, 'message') ?? readString(record, 'errorCode')\n if (message) return message\n }\n return String(signal)\n}\n\n/**\n * Run `run` against the first model in `models` that does not report an upstream\n * outage, falling through the chain in order.\n *\n * A non-outage failure (bad request, auth, content filter) is re-thrown\n * immediately rather than retried down the chain — those fail identically on\n * every model, so walking the chain would only multiply latency and spend.\n *\n * @throws ModelFailoverExhaustedError when every model reports an outage or is\n * refused by the configured admission policy.\n */\nexport async function runWithModelFailover<T>(\n input: RunWithModelFailoverInput<T>,\n): Promise<ModelFailoverResult<T>> {\n const models = input.models.map((m) => m.trim()).filter((m) => m.length > 0)\n if (models.length === 0) throw new Error('runWithModelFailover requires at least one model')\n\n const isUnavailableResult = input.isUnavailableResult ?? ((r: T) => isUpstreamUnavailable(r))\n const isUnavailableError = input.isUnavailableError ?? isUpstreamUnavailable\n const attempts: ModelFailoverAttempt[] = []\n\n for (let index = 0; index < models.length; index += 1) {\n const model = models[index]!\n\n // Policy BEFORE run: a chain member the deployment does not admit never\n // sees the turn's data. The refusal is one failed attempt — the walk\n // continues to the next member, and an all-refused chain exhausts exactly\n // like an all-dead one.\n if (input.policy) {\n const decision = await checkModelPolicy(model, input.policy.catalog, input.policy.filter, input.policy.context)\n if (!decision.allowed) {\n const attempt: ModelFailoverAttempt = { model, ok: false, reason: decision.reason }\n attempts.push(attempt)\n const next = models[index + 1]\n if (next) input.onFallback?.(attempt, next)\n continue\n }\n }\n\n let result: T\n try {\n result = await input.run(model)\n } catch (error) {\n if (!isUnavailableError(error)) throw error\n const errorCode = readUpstreamUnavailableCode(error)\n const attempt: ModelFailoverAttempt = {\n model,\n ok: false,\n reason: describe(error),\n ...(errorCode ? { errorCode } : {}),\n }\n attempts.push(attempt)\n const next = models[index + 1]\n if (next) input.onFallback?.(attempt, next)\n continue\n }\n\n if (isUnavailableResult(result)) {\n const errorCode = readUpstreamUnavailableCode(result)\n const attempt: ModelFailoverAttempt = {\n model,\n ok: false,\n reason: describe(result),\n ...(errorCode ? { errorCode } : {}),\n }\n attempts.push(attempt)\n const next = models[index + 1]\n if (next) input.onFallback?.(attempt, next)\n continue\n }\n\n attempts.push({ model, ok: true })\n return { value: result, model, attempts, usedFallback: index > 0 }\n }\n\n throw new ModelFailoverExhaustedError(attempts)\n}\n\n/**\n * Build a failover chain: the preferred model first, then `fallbacks`, with\n * duplicates removed so a model is never retried twice in one turn.\n */\nexport function buildModelChain(preferred: string, fallbacks: readonly string[]): string[] {\n const chain: string[] = []\n for (const model of [preferred, ...fallbacks]) {\n const cleaned = typeof model === 'string' ? model.trim() : ''\n if (cleaned.length > 0 && !chain.includes(cleaned)) chain.push(cleaned)\n }\n return chain\n}\n"],"mappings":"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","names":[]}
@@ -23,6 +23,7 @@
23
23
  * Substrate-free: the caller injects `run`, so this composes with a sandbox
24
24
  * turn, a router chat completion, or a test fake without importing any of them.
25
25
  */
26
+ import { type ModelPolicy } from '../policy-filters/index';
26
27
  /**
27
28
  * Error codes that mean "this model's upstream is unavailable — a different
28
29
  * model may still work". Deliberately excludes codes that would fail identically
@@ -81,6 +82,17 @@ export interface RunWithModelFailoverInput<T> {
81
82
  models: readonly string[];
82
83
  /** Executes one turn with the given model. */
83
84
  run: (model: string) => Promise<T>;
85
+ /**
86
+ * Product admission policy applied to EVERY chain member before it runs.
87
+ * Without this a compliant model 502ing mid-turn would silently re-run the
88
+ * turn's data through a non-compliant fallback: availability is not
89
+ * eligibility. A member the policy refuses is SKIPPED (recorded in the
90
+ * attempt trail with the refusal as its reason) and the walk continues; when
91
+ * no member is admitted the run ends in {@link ModelFailoverExhaustedError}
92
+ * like any other exhausted chain. Fail-closed — a member carrying no policy
93
+ * metadata is refused.
94
+ */
95
+ policy?: ModelPolicy;
84
96
  /**
85
97
  * Classifies a resolved result as an upstream outage. Defaults to
86
98
  * {@link isUpstreamUnavailable}, which understands the sandbox's
@@ -89,7 +101,8 @@ export interface RunWithModelFailoverInput<T> {
89
101
  isUnavailableResult?: (result: T) => boolean;
90
102
  /** Classifies a thrown error. Defaults to {@link isUpstreamUnavailable}. */
91
103
  isUnavailableError?: (error: unknown) => boolean;
92
- /** Observability hook fired each time a model is abandoned. */
104
+ /** Observability hook fired each time a model is abandoned — including a
105
+ * policy refusal, whose `reason` is the policy's own refusal text. */
93
106
  onFallback?: (attempt: ModelFailoverAttempt, nextModel: string) => void;
94
107
  }
95
108
  /**
@@ -100,7 +113,8 @@ export interface RunWithModelFailoverInput<T> {
100
113
  * immediately rather than retried down the chain — those fail identically on
101
114
  * every model, so walking the chain would only multiply latency and spend.
102
115
  *
103
- * @throws ModelFailoverExhaustedError when every model reports an outage.
116
+ * @throws ModelFailoverExhaustedError when every model reports an outage or is
117
+ * refused by the configured admission policy.
104
118
  */
105
119
  export declare function runWithModelFailover<T>(input: RunWithModelFailoverInput<T>): Promise<ModelFailoverResult<T>>;
106
120
  /**
@@ -15,7 +15,13 @@
15
15
  * - validateChatModelId: fail-closed. Admit an id that is in the allowlist, or
16
16
  * equals the operator-set env model, or is served by the live router catalog
17
17
  * (exact, or a bare id resolved to its canonical id when the suffix is unique).
18
+ * An optional `policy` (see ../policy-filters) layers product admission policy
19
+ * over EVERY admission path — allowlist, env, and catalog alike — so a
20
+ * policy-filtered deployment (e.g. HIPAA: zero-data-retention, BAA-eligible
21
+ * models only) cannot be bypassed through an operator-trusted lane. With a
22
+ * policy configured, a model carrying no policy metadata is refused.
18
23
  */
24
+ import { type ModelPolicy, type ModelPolicyInfo } from '../policy-filters/index';
19
25
  /** The router /v1/models entry shape this module reads. Minimal on purpose. */
20
26
  export interface ModelInfo {
21
27
  id: string;
@@ -69,6 +75,10 @@ export interface ValidateChatModelIdInput {
69
75
  loadModels?: LoadModels;
70
76
  /** Catalog endpoint base; required to reach the catalog path. */
71
77
  routerBaseUrl?: string;
78
+ /** Product admission policy, evaluated AFTER availability on EVERY admission
79
+ * path (allowlist, env, catalog). Fail-closed: a validated model with no
80
+ * policy metadata in `catalog` is refused. */
81
+ policy?: ModelPolicy;
72
82
  }
73
83
  /**
74
84
  * Fail-closed model-id validation. Accepts an id only when it is well-formed AND
@@ -84,5 +94,37 @@ export declare function cleanModelId(value: unknown): string | undefined;
84
94
  export declare function isWellFormedModelId(modelId: string): boolean;
85
95
  /** Resolve unique catalog IDs associated with a given model including its canonical form if applicable */
86
96
  export declare function catalogIdsForModel(model: ModelInfo): string[];
97
+ /**
98
+ * A router `/v1/models` row carrying the compliance flags
99
+ * {@link modelPolicyCatalogFromRouter} reads. The router spells them
100
+ * snake_case; the policy catalog spells them camelCase (`ModelPolicyInfo`).
101
+ */
102
+ export interface RouterModelPolicyRow extends ModelInfo {
103
+ /** The provider retains no prompt/response data for this model. */
104
+ zdr?: boolean;
105
+ /** The vendor will sign a BAA covering this model. */
106
+ hipaa_eligible?: boolean;
107
+ }
108
+ /**
109
+ * Map router `/v1/models` rows to a {@link ModelPolicyInfo} catalog: snake_case
110
+ * `zdr` / `hipaa_eligible` become `zeroDataRetention` / `hipaaEligible`, and
111
+ * `provider` comes from the row's `_provider`/`provider`.
112
+ *
113
+ * GRANULARITY: the flags are trusted VERBATIM, per row, at whatever
114
+ * granularity the router publishes them. A ZDR/BAA attestation is commonly a
115
+ * provider-wide vendor fact, but this mapper never infers one model's
116
+ * eligibility from a sibling's — a row that omits a flag yields `undefined`
117
+ * for it, and a filter reading `=== true` refuses that row (fail closed). If
118
+ * your attestation is provider-wide, publish it on every row upstream rather
119
+ * than asking the mapper to guess.
120
+ *
121
+ * ID KEYING: one entry is emitted per spelling {@link catalogIdsForModel}
122
+ * yields (the bare id AND its provider-prefixed canonical form), so a turn
123
+ * carrying either spelling classifies — lookups in `/policy-filters` are
124
+ * exact-string and never normalize. Rows with a blank/non-string id are
125
+ * skipped. Duplicate ids across rows are a product bug (see
126
+ * `indexPolicyCatalog`).
127
+ */
128
+ export declare function modelPolicyCatalogFromRouter(rows: Iterable<RouterModelPolicyRow>): ModelPolicyInfo[];
87
129
  export { parseModelNotAllowed, resolveModelWithinAllowList, formatModelNotAllowedRefusal, type ResolveModelWithinAllowListInput, type ModelWithinAllowListResolution, } from './allow-list';
88
130
  export { isUpstreamUnavailable, readHttpStatusHint, runWithModelFailover, buildModelChain, ModelFailoverExhaustedError, UPSTREAM_UNAVAILABLE_CODES, UPSTREAM_UNAVAILABLE_STATUSES, type ModelFailoverAttempt, type ModelFailoverResult, type RunWithModelFailoverInput, } from './failover';
@@ -6,7 +6,10 @@ import {
6
6
  isUpstreamUnavailable,
7
7
  readHttpStatusHint,
8
8
  runWithModelFailover
9
- } from "../chunk-63EKFZOE.js";
9
+ } from "../chunk-ZLX4BA27.js";
10
+ import {
11
+ checkModelPolicy
12
+ } from "../chunk-XELWWXJJ.js";
10
13
 
11
14
  // src/model-resolution/allow-list.ts
12
15
  function parseModelNotAllowed(message) {
@@ -49,12 +52,18 @@ function resolveChatModel(input) {
49
52
  return { model: input.defaultModel, source: "default" };
50
53
  }
51
54
  async function validateChatModelId(modelId, input) {
55
+ const admit = async (value) => {
56
+ if (!input.policy) return { succeeded: true, value };
57
+ const decision = await checkModelPolicy(value, input.policy.catalog, input.policy.filter, input.policy.context);
58
+ if (!decision.allowed) return { succeeded: false, error: decision.reason };
59
+ return { succeeded: true, value };
60
+ };
52
61
  const cleaned = cleanModelId(modelId);
53
62
  if (!cleaned) return { succeeded: false, error: "Model id must be a non-empty string." };
54
63
  if (!isWellFormedModelId(cleaned)) return { succeeded: false, error: `Model id is malformed: ${cleaned}` };
55
64
  const allowed = new Set(input.allowlist ?? []);
56
- if (allowed.has(cleaned)) return { succeeded: true, value: cleaned };
57
- if (cleanModelId(input.envModel) === cleaned) return { succeeded: true, value: cleaned };
65
+ if (allowed.has(cleaned)) return admit(cleaned);
66
+ if (cleanModelId(input.envModel) === cleaned) return admit(cleaned);
58
67
  if (!input.loadModels || typeof input.routerBaseUrl !== "string" || input.routerBaseUrl.length === 0) {
59
68
  return { succeeded: false, error: `Model is not available: ${cleaned}` };
60
69
  }
@@ -66,7 +75,7 @@ async function validateChatModelId(modelId, input) {
66
75
  return { succeeded: false, error: `Could not validate model catalog: ${message}` };
67
76
  }
68
77
  const ids = new Set(catalog.flatMap(catalogIdsForModel));
69
- if (ids.has(cleaned)) return { succeeded: true, value: cleaned };
78
+ if (ids.has(cleaned)) return admit(cleaned);
70
79
  if (!cleaned.includes("/")) {
71
80
  const canonicalBySuffix = /* @__PURE__ */ new Map();
72
81
  for (const model of catalog) {
@@ -79,7 +88,7 @@ async function validateChatModelId(modelId, input) {
79
88
  else canonicalBySuffix.set(suffix, [canonical]);
80
89
  }
81
90
  const matches = canonicalBySuffix.get(cleaned);
82
- if (matches && matches.length === 1) return { succeeded: true, value: matches[0] };
91
+ if (matches && matches.length === 1) return admit(matches[0]);
83
92
  }
84
93
  return { succeeded: false, error: `Model is not available: ${cleaned}` };
85
94
  }
@@ -101,6 +110,21 @@ function catalogIdsForModel(model) {
101
110
  }
102
111
  return [...ids];
103
112
  }
113
+ function modelPolicyCatalogFromRouter(rows) {
114
+ const catalog = [];
115
+ for (const row of rows) {
116
+ if (typeof row.id !== "string" || !row.id.trim()) continue;
117
+ const provider = row._provider ?? row.provider;
118
+ const flags = {
119
+ ...typeof row.zdr === "boolean" ? { zeroDataRetention: row.zdr } : {},
120
+ ...typeof row.hipaa_eligible === "boolean" ? { hipaaEligible: row.hipaa_eligible } : {}
121
+ };
122
+ for (const id of catalogIdsForModel(row)) {
123
+ catalog.push({ id, ...provider ? { provider } : {}, ...flags });
124
+ }
125
+ }
126
+ return catalog;
127
+ }
104
128
  export {
105
129
  ModelFailoverExhaustedError,
106
130
  UPSTREAM_UNAVAILABLE_CODES,
@@ -111,6 +135,7 @@ export {
111
135
  formatModelNotAllowedRefusal,
112
136
  isUpstreamUnavailable,
113
137
  isWellFormedModelId,
138
+ modelPolicyCatalogFromRouter,
114
139
  parseModelNotAllowed,
115
140
  readHttpStatusHint,
116
141
  resolveChatModel,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/model-resolution/allow-list.ts","../../src/model-resolution/index.ts"],"sourcesContent":["/**\n * Live model routing by allow-list — the positive form of `model_not_allowed`.\n *\n * A scoped key (QA keys, delegated child keys, provisioned product keys)\n * carries an allowed-model set. The router enforces it as a wall:\n *\n * 400 model_not_allowed — `Model \"gpt-6-luna\" is not in this key's\n * allow-list. Allowed: glm-5.3, gpt-5.5.`\n *\n * The refusal already NAMES the allowed set — so a product can treat the\n * allow-list as routing information instead of a dead end: parse the set,\n * pick the first preferred model it permits, and retry. Combined with the\n * router's key-info endpoint (which exposes `allowed_models` before the\n * first refusal), a product can route right the first time.\n *\n * Fail-closed by design: when no preferred model is allowed, the resolver\n * returns `status: 'refused'` with the allowed set — the caller surfaces a\n * named refusal (via {@link formatModelNotAllowedRefusal}) rather than\n * silently downgrading to an arbitrary model. A different model is a\n * different decision-maker; the product's fallback ORDER is a product\n * decision, and this module only executes it.\n */\n\n/** Parse the allowed-model set out of a router `model_not_allowed` refusal.\n * Both refusal shapes name the set after `Allowed:` —\n * `Model \"x\" is not in this key's allow-list. Allowed: a, b, c.` and\n * `Fallback model(s) \"x\" are not in this key's allow-list. Allowed: a, b.`\n * Returns null when the message carries no set (an unexpected shape — do\n * not guess). */\nexport function parseModelNotAllowed(message: string): string[] | null {\n const match = /Allowed:\\s*([^\\n]+?)(?:\\.\\s*)?$/.exec(message)\n if (!match?.[1]) return null\n const allowed = match[1].split(',').map((id) => id.trim().replace(/^[\"']|[\"']$/g, '')).filter(Boolean)\n return allowed.length > 0 ? allowed : null\n}\n\nexport interface ResolveModelWithinAllowListInput {\n /** The model the product wants to run. */\n readonly requested: string\n /** The key's allowed-model set (empty/absent = the key is unscoped; the\n * request stands). */\n readonly allowedModels?: readonly string[] | null\n /** The product's reroute order when `requested` is blocked — tried in\n * order, first allowed wins. */\n readonly fallbacks?: readonly string[]\n}\n\nexport type ModelWithinAllowListResolution =\n | { readonly status: 'allowed'; readonly model: string; readonly rerouted: false }\n | { readonly status: 'rerouted'; readonly model: string; readonly rerouted: true; readonly blockedModel: string }\n | { readonly status: 'refused'; readonly model: null; readonly allowedModels: readonly string[] }\n\n/** Resolve a model request against the key's allow-list:\n * unscoped → allowed as-is; requested permitted → allowed; a fallback\n * permitted → rerouted (named); nothing permitted → refused with the set. */\nexport function resolveModelWithinAllowList(input: ResolveModelWithinAllowListInput): ModelWithinAllowListResolution {\n const allowed = input.allowedModels\n if (!allowed || allowed.length === 0) {\n return { status: 'allowed', model: input.requested, rerouted: false }\n }\n if (allowed.includes(input.requested)) {\n return { status: 'allowed', model: input.requested, rerouted: false }\n }\n const fallback = (input.fallbacks ?? []).find((id) => allowed.includes(id))\n if (fallback !== undefined) {\n return { status: 'rerouted', model: fallback, rerouted: true, blockedModel: input.requested }\n }\n return { status: 'refused', model: null, allowedModels: allowed }\n}\n\n/** Format the loud, correctable refusal: the blocked model, the allowed set,\n * and the fix. Mirrors the router's own refusal text so both layers name\n * the same fact. */\nexport function formatModelNotAllowedRefusal(input: {\n requested: string\n allowedModels: readonly string[]\n}): string {\n return (\n `Model \"${input.requested}\" is not in this key's allow-list. ` +\n `Allowed: ${input.allowedModels.join(', ')}. ` +\n 'Route to an allowed model or widen the key\\u2019s allow-list.'\n )\n}\n","/**\n * Canonical chat-model resolution — identical across every agent app.\n *\n * The ONLY per-app inputs are DATA, never logic: the default model, the\n * allowlist, the env value the deployment set, and the catalog-fetch loader.\n * The logic is one precedence ladder + one fail-closed validator that every\n * product uses the same way — there is no per-product variant, no env-var name\n * baked in, and no backend dimension (router-vs-sandbox is the harness/dispatch\n * concern, not model resolution; a sandbox's provider default lives in the\n * sandbox subpath).\n *\n * - resolveChatModel: request > workspace > env > default. The product reads its\n * own deploy env var and passes the VALUE as `envModel`; the shell knows no\n * env-var names. Source is canonical: 'request' | 'workspace' | 'env' | 'default'.\n * - validateChatModelId: fail-closed. Admit an id that is in the allowlist, or\n * equals the operator-set env model, or is served by the live router catalog\n * (exact, or a bare id resolved to its canonical id when the suffix is unique).\n */\n\n/** The router /v1/models entry shape this module reads. Minimal on purpose. */\nexport interface ModelInfo {\n id: string\n name?: string\n _provider?: string\n provider?: string\n}\n\n/** Canonical (provider-prefixed) id for a catalog entry: pass through an id that\n * already carries a provider, else prefix the entry's provider when present. */\nfunction canonicalModelId(model: ModelInfo): string {\n if (model.id.includes('/')) return model.id\n const provider = model._provider ?? model.provider\n return provider ? `${provider}/${model.id}` : model.id\n}\n\n/** Define possible origins for the chat model configuration values */\nexport type ChatModelSource = 'request' | 'workspace' | 'env' | 'default'\n\n/** Resolve a chat model with its identifier and source information */\nexport interface ResolvedChatModel {\n model: string\n source: ChatModelSource\n}\n\n/** Represent successful chat model validation with a true status and a validated string value */\nexport interface ChatModelValidationSuccess {\n succeeded: true\n value: string\n}\n\n/** Describe a failed chat model validation result with an error message */\nexport interface ChatModelValidationFailure {\n succeeded: false\n error: string\n}\n\n/** Resolve the outcome of validating a chat model as either success or failure */\nexport type ChatModelValidationResult = ChatModelValidationSuccess | ChatModelValidationFailure\n\n/** The catalog-fetch boundary: maps a router base URL to the raw model list. */\nexport type LoadModels = (routerBaseUrl: string) => Promise<ModelInfo[]>\n\n/** Resolve the effective chat model input by prioritizing request, workspace, environment, and default models */\nexport interface ResolveChatModelInput {\n /** Per-request override (highest precedence). */\n requestModel?: string\n /** Persisted workspace-pinned model. */\n workspaceModel?: string\n /** The value the deployment's model env var holds (the product reads its own\n * var name and passes the value — the shell stays env-var-name agnostic). */\n envModel?: string\n /** Final fallback (the product's default, typically profile.model.default). */\n defaultModel: string\n}\n\n/** Resolve the chat-turn model by the one canonical precedence. Blank values are\n * treated as absent. */\nexport function resolveChatModel(input: ResolveChatModelInput): ResolvedChatModel {\n const request = cleanModelId(input.requestModel)\n if (request) return { model: request, source: 'request' }\n const workspace = cleanModelId(input.workspaceModel)\n if (workspace) return { model: workspace, source: 'workspace' }\n const env = cleanModelId(input.envModel)\n if (env) return { model: env, source: 'env' }\n return { model: input.defaultModel, source: 'default' }\n}\n\n/** Define input parameters for validating chat model IDs with optional allowlist and catalog access details */\nexport interface ValidateChatModelIdInput {\n /** Ids accepted without a catalog round-trip (defaults + operator-trusted). */\n allowlist?: Iterable<string>\n /** The operator-set env model value — always admitted (operator-trusted). */\n envModel?: string\n /** Catalog loader; required to reach the catalog path. */\n loadModels?: LoadModels\n /** Catalog endpoint base; required to reach the catalog path. */\n routerBaseUrl?: string\n}\n\n/**\n * Fail-closed model-id validation. Accepts an id only when it is well-formed AND\n * (in the allowlist, or equals the operator-set env model, or served by the live\n * catalog). A bare id (no provider prefix) resolves to its canonical id only when\n * the suffix is unique across the catalog — an ambiguous suffix is rejected\n * rather than silently assigned a provider.\n */\nexport async function validateChatModelId(\n modelId: unknown,\n input: ValidateChatModelIdInput,\n): Promise<ChatModelValidationResult> {\n const cleaned = cleanModelId(modelId)\n if (!cleaned) return { succeeded: false, error: 'Model id must be a non-empty string.' }\n if (!isWellFormedModelId(cleaned)) return { succeeded: false, error: `Model id is malformed: ${cleaned}` }\n\n const allowed = new Set(input.allowlist ?? [])\n if (allowed.has(cleaned)) return { succeeded: true, value: cleaned }\n\n // The operator-set env model is trusted without a catalog round-trip.\n if (cleanModelId(input.envModel) === cleaned) return { succeeded: true, value: cleaned }\n\n if (!input.loadModels || typeof input.routerBaseUrl !== 'string' || input.routerBaseUrl.length === 0) {\n return { succeeded: false, error: `Model is not available: ${cleaned}` }\n }\n\n let catalog: ModelInfo[]\n try {\n catalog = await input.loadModels(input.routerBaseUrl)\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n return { succeeded: false, error: `Could not validate model catalog: ${message}` }\n }\n\n const ids = new Set(catalog.flatMap(catalogIdsForModel))\n if (ids.has(cleaned)) return { succeeded: true, value: cleaned }\n\n if (!cleaned.includes('/')) {\n const canonicalBySuffix = new Map<string, string[]>()\n for (const model of catalog) {\n if (typeof model.id !== 'string' || !model.id.trim()) continue\n const canonical = canonicalModelId(model)\n if (!canonical.includes('/')) continue\n const suffix = canonical.split('/').slice(1).join('/')\n const entries = canonicalBySuffix.get(suffix)\n if (entries) entries.push(canonical)\n else canonicalBySuffix.set(suffix, [canonical])\n }\n const matches = canonicalBySuffix.get(cleaned)\n if (matches && matches.length === 1) return { succeeded: true, value: matches[0]! }\n }\n\n return { succeeded: false, error: `Model is not available: ${cleaned}` }\n}\n\n/** Resolve and return a trimmed string model ID or undefined for invalid or empty input */\nexport function cleanModelId(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\n/** Validate if a model ID string conforms to length and character format requirements */\nexport function isWellFormedModelId(modelId: string): boolean {\n if (modelId.length > 200) return false\n return /^[A-Za-z0-9._/@:-]+$/.test(modelId)\n}\n\n/** Resolve unique catalog IDs associated with a given model including its canonical form if applicable */\nexport function catalogIdsForModel(model: ModelInfo): string[] {\n const ids = new Set<string>()\n if (typeof model.id === 'string' && model.id.trim()) ids.add(model.id.trim())\n if (typeof model.id === 'string' && model.id.trim() && !model.id.includes('/')) {\n const canonical = canonicalModelId(model)\n if (canonical.includes('/')) ids.add(canonical)\n }\n return [...ids]\n}\n\n// Live model routing by allow-list: the positive form of model_not_allowed.\n// Parse the set the router's refusal already names, reroute within the\n// product's fallback order, or refuse loudly naming the set.\nexport {\n parseModelNotAllowed,\n resolveModelWithinAllowList,\n formatModelNotAllowedRefusal,\n type ResolveModelWithinAllowListInput,\n type ModelWithinAllowListResolution,\n} from './allow-list'\n\n// Reactive failover for the case validation cannot catch: a model that IS in the\n// catalog but whose upstream is down. Catalog membership is not liveness.\nexport {\n isUpstreamUnavailable,\n readHttpStatusHint,\n runWithModelFailover,\n buildModelChain,\n ModelFailoverExhaustedError,\n UPSTREAM_UNAVAILABLE_CODES,\n UPSTREAM_UNAVAILABLE_STATUSES,\n type ModelFailoverAttempt,\n type ModelFailoverResult,\n type RunWithModelFailoverInput,\n} from './failover'\n"],"mappings":";;;;;;;;;;;AA6BO,SAAS,qBAAqB,SAAkC;AACrE,QAAM,QAAQ,kCAAkC,KAAK,OAAO;AAC5D,MAAI,CAAC,QAAQ,CAAC,EAAG,QAAO;AACxB,QAAM,UAAU,MAAM,CAAC,EAAE,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,EAAE,QAAQ,gBAAgB,EAAE,CAAC,EAAE,OAAO,OAAO;AACrG,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAqBO,SAAS,4BAA4B,OAAyE;AACnH,QAAM,UAAU,MAAM;AACtB,MAAI,CAAC,WAAW,QAAQ,WAAW,GAAG;AACpC,WAAO,EAAE,QAAQ,WAAW,OAAO,MAAM,WAAW,UAAU,MAAM;AAAA,EACtE;AACA,MAAI,QAAQ,SAAS,MAAM,SAAS,GAAG;AACrC,WAAO,EAAE,QAAQ,WAAW,OAAO,MAAM,WAAW,UAAU,MAAM;AAAA,EACtE;AACA,QAAM,YAAY,MAAM,aAAa,CAAC,GAAG,KAAK,CAAC,OAAO,QAAQ,SAAS,EAAE,CAAC;AAC1E,MAAI,aAAa,QAAW;AAC1B,WAAO,EAAE,QAAQ,YAAY,OAAO,UAAU,UAAU,MAAM,cAAc,MAAM,UAAU;AAAA,EAC9F;AACA,SAAO,EAAE,QAAQ,WAAW,OAAO,MAAM,eAAe,QAAQ;AAClE;AAKO,SAAS,6BAA6B,OAGlC;AACT,SACE,UAAU,MAAM,SAAS,+CACb,MAAM,cAAc,KAAK,IAAI,CAAC;AAG9C;;;ACrDA,SAAS,iBAAiB,OAA0B;AAClD,MAAI,MAAM,GAAG,SAAS,GAAG,EAAG,QAAO,MAAM;AACzC,QAAM,WAAW,MAAM,aAAa,MAAM;AAC1C,SAAO,WAAW,GAAG,QAAQ,IAAI,MAAM,EAAE,KAAK,MAAM;AACtD;AA4CO,SAAS,iBAAiB,OAAiD;AAChF,QAAM,UAAU,aAAa,MAAM,YAAY;AAC/C,MAAI,QAAS,QAAO,EAAE,OAAO,SAAS,QAAQ,UAAU;AACxD,QAAM,YAAY,aAAa,MAAM,cAAc;AACnD,MAAI,UAAW,QAAO,EAAE,OAAO,WAAW,QAAQ,YAAY;AAC9D,QAAM,MAAM,aAAa,MAAM,QAAQ;AACvC,MAAI,IAAK,QAAO,EAAE,OAAO,KAAK,QAAQ,MAAM;AAC5C,SAAO,EAAE,OAAO,MAAM,cAAc,QAAQ,UAAU;AACxD;AAqBA,eAAsB,oBACpB,SACA,OACoC;AACpC,QAAM,UAAU,aAAa,OAAO;AACpC,MAAI,CAAC,QAAS,QAAO,EAAE,WAAW,OAAO,OAAO,uCAAuC;AACvF,MAAI,CAAC,oBAAoB,OAAO,EAAG,QAAO,EAAE,WAAW,OAAO,OAAO,0BAA0B,OAAO,GAAG;AAEzG,QAAM,UAAU,IAAI,IAAI,MAAM,aAAa,CAAC,CAAC;AAC7C,MAAI,QAAQ,IAAI,OAAO,EAAG,QAAO,EAAE,WAAW,MAAM,OAAO,QAAQ;AAGnE,MAAI,aAAa,MAAM,QAAQ,MAAM,QAAS,QAAO,EAAE,WAAW,MAAM,OAAO,QAAQ;AAEvF,MAAI,CAAC,MAAM,cAAc,OAAO,MAAM,kBAAkB,YAAY,MAAM,cAAc,WAAW,GAAG;AACpG,WAAO,EAAE,WAAW,OAAO,OAAO,2BAA2B,OAAO,GAAG;AAAA,EACzE;AAEA,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,MAAM,WAAW,MAAM,aAAa;AAAA,EACtD,SAAS,KAAK;AACZ,UAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,WAAO,EAAE,WAAW,OAAO,OAAO,qCAAqC,OAAO,GAAG;AAAA,EACnF;AAEA,QAAM,MAAM,IAAI,IAAI,QAAQ,QAAQ,kBAAkB,CAAC;AACvD,MAAI,IAAI,IAAI,OAAO,EAAG,QAAO,EAAE,WAAW,MAAM,OAAO,QAAQ;AAE/D,MAAI,CAAC,QAAQ,SAAS,GAAG,GAAG;AAC1B,UAAM,oBAAoB,oBAAI,IAAsB;AACpD,eAAW,SAAS,SAAS;AAC3B,UAAI,OAAO,MAAM,OAAO,YAAY,CAAC,MAAM,GAAG,KAAK,EAAG;AACtD,YAAM,YAAY,iBAAiB,KAAK;AACxC,UAAI,CAAC,UAAU,SAAS,GAAG,EAAG;AAC9B,YAAM,SAAS,UAAU,MAAM,GAAG,EAAE,MAAM,CAAC,EAAE,KAAK,GAAG;AACrD,YAAM,UAAU,kBAAkB,IAAI,MAAM;AAC5C,UAAI,QAAS,SAAQ,KAAK,SAAS;AAAA,UAC9B,mBAAkB,IAAI,QAAQ,CAAC,SAAS,CAAC;AAAA,IAChD;AACA,UAAM,UAAU,kBAAkB,IAAI,OAAO;AAC7C,QAAI,WAAW,QAAQ,WAAW,EAAG,QAAO,EAAE,WAAW,MAAM,OAAO,QAAQ,CAAC,EAAG;AAAA,EACpF;AAEA,SAAO,EAAE,WAAW,OAAO,OAAO,2BAA2B,OAAO,GAAG;AACzE;AAGO,SAAS,aAAa,OAAoC;AAC/D,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAGO,SAAS,oBAAoB,SAA0B;AAC5D,MAAI,QAAQ,SAAS,IAAK,QAAO;AACjC,SAAO,uBAAuB,KAAK,OAAO;AAC5C;AAGO,SAAS,mBAAmB,OAA4B;AAC7D,QAAM,MAAM,oBAAI,IAAY;AAC5B,MAAI,OAAO,MAAM,OAAO,YAAY,MAAM,GAAG,KAAK,EAAG,KAAI,IAAI,MAAM,GAAG,KAAK,CAAC;AAC5E,MAAI,OAAO,MAAM,OAAO,YAAY,MAAM,GAAG,KAAK,KAAK,CAAC,MAAM,GAAG,SAAS,GAAG,GAAG;AAC9E,UAAM,YAAY,iBAAiB,KAAK;AACxC,QAAI,UAAU,SAAS,GAAG,EAAG,KAAI,IAAI,SAAS;AAAA,EAChD;AACA,SAAO,CAAC,GAAG,GAAG;AAChB;","names":[]}
1
+ {"version":3,"sources":["../../src/model-resolution/allow-list.ts","../../src/model-resolution/index.ts"],"sourcesContent":["/**\n * Live model routing by allow-list — the positive form of `model_not_allowed`.\n *\n * A scoped key (QA keys, delegated child keys, provisioned product keys)\n * carries an allowed-model set. The router enforces it as a wall:\n *\n * 400 model_not_allowed — `Model \"gpt-6-luna\" is not in this key's\n * allow-list. Allowed: glm-5.3, gpt-5.5.`\n *\n * The refusal already NAMES the allowed set — so a product can treat the\n * allow-list as routing information instead of a dead end: parse the set,\n * pick the first preferred model it permits, and retry. Combined with the\n * router's key-info endpoint (which exposes `allowed_models` before the\n * first refusal), a product can route right the first time.\n *\n * Fail-closed by design: when no preferred model is allowed, the resolver\n * returns `status: 'refused'` with the allowed set — the caller surfaces a\n * named refusal (via {@link formatModelNotAllowedRefusal}) rather than\n * silently downgrading to an arbitrary model. A different model is a\n * different decision-maker; the product's fallback ORDER is a product\n * decision, and this module only executes it.\n */\n\n/** Parse the allowed-model set out of a router `model_not_allowed` refusal.\n * Both refusal shapes name the set after `Allowed:` —\n * `Model \"x\" is not in this key's allow-list. Allowed: a, b, c.` and\n * `Fallback model(s) \"x\" are not in this key's allow-list. Allowed: a, b.`\n * Returns null when the message carries no set (an unexpected shape — do\n * not guess). */\nexport function parseModelNotAllowed(message: string): string[] | null {\n const match = /Allowed:\\s*([^\\n]+?)(?:\\.\\s*)?$/.exec(message)\n if (!match?.[1]) return null\n const allowed = match[1].split(',').map((id) => id.trim().replace(/^[\"']|[\"']$/g, '')).filter(Boolean)\n return allowed.length > 0 ? allowed : null\n}\n\nexport interface ResolveModelWithinAllowListInput {\n /** The model the product wants to run. */\n readonly requested: string\n /** The key's allowed-model set (empty/absent = the key is unscoped; the\n * request stands). */\n readonly allowedModels?: readonly string[] | null\n /** The product's reroute order when `requested` is blocked — tried in\n * order, first allowed wins. */\n readonly fallbacks?: readonly string[]\n}\n\nexport type ModelWithinAllowListResolution =\n | { readonly status: 'allowed'; readonly model: string; readonly rerouted: false }\n | { readonly status: 'rerouted'; readonly model: string; readonly rerouted: true; readonly blockedModel: string }\n | { readonly status: 'refused'; readonly model: null; readonly allowedModels: readonly string[] }\n\n/** Resolve a model request against the key's allow-list:\n * unscoped → allowed as-is; requested permitted → allowed; a fallback\n * permitted → rerouted (named); nothing permitted → refused with the set. */\nexport function resolveModelWithinAllowList(input: ResolveModelWithinAllowListInput): ModelWithinAllowListResolution {\n const allowed = input.allowedModels\n if (!allowed || allowed.length === 0) {\n return { status: 'allowed', model: input.requested, rerouted: false }\n }\n if (allowed.includes(input.requested)) {\n return { status: 'allowed', model: input.requested, rerouted: false }\n }\n const fallback = (input.fallbacks ?? []).find((id) => allowed.includes(id))\n if (fallback !== undefined) {\n return { status: 'rerouted', model: fallback, rerouted: true, blockedModel: input.requested }\n }\n return { status: 'refused', model: null, allowedModels: allowed }\n}\n\n/** Format the loud, correctable refusal: the blocked model, the allowed set,\n * and the fix. Mirrors the router's own refusal text so both layers name\n * the same fact. */\nexport function formatModelNotAllowedRefusal(input: {\n requested: string\n allowedModels: readonly string[]\n}): string {\n return (\n `Model \"${input.requested}\" is not in this key's allow-list. ` +\n `Allowed: ${input.allowedModels.join(', ')}. ` +\n 'Route to an allowed model or widen the key\\u2019s allow-list.'\n )\n}\n","/**\n * Canonical chat-model resolution — identical across every agent app.\n *\n * The ONLY per-app inputs are DATA, never logic: the default model, the\n * allowlist, the env value the deployment set, and the catalog-fetch loader.\n * The logic is one precedence ladder + one fail-closed validator that every\n * product uses the same way — there is no per-product variant, no env-var name\n * baked in, and no backend dimension (router-vs-sandbox is the harness/dispatch\n * concern, not model resolution; a sandbox's provider default lives in the\n * sandbox subpath).\n *\n * - resolveChatModel: request > workspace > env > default. The product reads its\n * own deploy env var and passes the VALUE as `envModel`; the shell knows no\n * env-var names. Source is canonical: 'request' | 'workspace' | 'env' | 'default'.\n * - validateChatModelId: fail-closed. Admit an id that is in the allowlist, or\n * equals the operator-set env model, or is served by the live router catalog\n * (exact, or a bare id resolved to its canonical id when the suffix is unique).\n * An optional `policy` (see ../policy-filters) layers product admission policy\n * over EVERY admission path — allowlist, env, and catalog alike — so a\n * policy-filtered deployment (e.g. HIPAA: zero-data-retention, BAA-eligible\n * models only) cannot be bypassed through an operator-trusted lane. With a\n * policy configured, a model carrying no policy metadata is refused.\n */\n\nimport {\n checkModelPolicy,\n type ModelPolicy,\n type ModelPolicyInfo,\n} from '../policy-filters/index'\n\n/** The router /v1/models entry shape this module reads. Minimal on purpose. */\nexport interface ModelInfo {\n id: string\n name?: string\n _provider?: string\n provider?: string\n}\n\n/** Canonical (provider-prefixed) id for a catalog entry: pass through an id that\n * already carries a provider, else prefix the entry's provider when present. */\nfunction canonicalModelId(model: ModelInfo): string {\n if (model.id.includes('/')) return model.id\n const provider = model._provider ?? model.provider\n return provider ? `${provider}/${model.id}` : model.id\n}\n\n/** Define possible origins for the chat model configuration values */\nexport type ChatModelSource = 'request' | 'workspace' | 'env' | 'default'\n\n/** Resolve a chat model with its identifier and source information */\nexport interface ResolvedChatModel {\n model: string\n source: ChatModelSource\n}\n\n/** Represent successful chat model validation with a true status and a validated string value */\nexport interface ChatModelValidationSuccess {\n succeeded: true\n value: string\n}\n\n/** Describe a failed chat model validation result with an error message */\nexport interface ChatModelValidationFailure {\n succeeded: false\n error: string\n}\n\n/** Resolve the outcome of validating a chat model as either success or failure */\nexport type ChatModelValidationResult = ChatModelValidationSuccess | ChatModelValidationFailure\n\n/** The catalog-fetch boundary: maps a router base URL to the raw model list. */\nexport type LoadModels = (routerBaseUrl: string) => Promise<ModelInfo[]>\n\n/** Resolve the effective chat model input by prioritizing request, workspace, environment, and default models */\nexport interface ResolveChatModelInput {\n /** Per-request override (highest precedence). */\n requestModel?: string\n /** Persisted workspace-pinned model. */\n workspaceModel?: string\n /** The value the deployment's model env var holds (the product reads its own\n * var name and passes the value — the shell stays env-var-name agnostic). */\n envModel?: string\n /** Final fallback (the product's default, typically profile.model.default). */\n defaultModel: string\n}\n\n/** Resolve the chat-turn model by the one canonical precedence. Blank values are\n * treated as absent. */\nexport function resolveChatModel(input: ResolveChatModelInput): ResolvedChatModel {\n const request = cleanModelId(input.requestModel)\n if (request) return { model: request, source: 'request' }\n const workspace = cleanModelId(input.workspaceModel)\n if (workspace) return { model: workspace, source: 'workspace' }\n const env = cleanModelId(input.envModel)\n if (env) return { model: env, source: 'env' }\n return { model: input.defaultModel, source: 'default' }\n}\n\n/** Define input parameters for validating chat model IDs with optional allowlist and catalog access details */\nexport interface ValidateChatModelIdInput {\n /** Ids accepted without a catalog round-trip (defaults + operator-trusted). */\n allowlist?: Iterable<string>\n /** The operator-set env model value — always admitted (operator-trusted). */\n envModel?: string\n /** Catalog loader; required to reach the catalog path. */\n loadModels?: LoadModels\n /** Catalog endpoint base; required to reach the catalog path. */\n routerBaseUrl?: string\n /** Product admission policy, evaluated AFTER availability on EVERY admission\n * path (allowlist, env, catalog). Fail-closed: a validated model with no\n * policy metadata in `catalog` is refused. */\n policy?: ModelPolicy\n}\n\n/**\n * Fail-closed model-id validation. Accepts an id only when it is well-formed AND\n * (in the allowlist, or equals the operator-set env model, or served by the live\n * catalog). A bare id (no provider prefix) resolves to its canonical id only when\n * the suffix is unique across the catalog — an ambiguous suffix is rejected\n * rather than silently assigned a provider.\n */\nexport async function validateChatModelId(\n modelId: unknown,\n input: ValidateChatModelIdInput,\n): Promise<ChatModelValidationResult> {\n // Policy composes with availability: an id is admitted only when it is\n // available AND (no policy configured OR the policy admits it). The policy\n // runs on every lane, so the operator-trusted allowlist/env paths cannot\n // bypass a deployment's admission policy.\n const admit = async (value: string): Promise<ChatModelValidationResult> => {\n if (!input.policy) return { succeeded: true, value }\n const decision = await checkModelPolicy(value, input.policy.catalog, input.policy.filter, input.policy.context)\n if (!decision.allowed) return { succeeded: false, error: decision.reason }\n return { succeeded: true, value }\n }\n\n const cleaned = cleanModelId(modelId)\n if (!cleaned) return { succeeded: false, error: 'Model id must be a non-empty string.' }\n if (!isWellFormedModelId(cleaned)) return { succeeded: false, error: `Model id is malformed: ${cleaned}` }\n\n const allowed = new Set(input.allowlist ?? [])\n if (allowed.has(cleaned)) return admit(cleaned)\n\n // The operator-set env model is trusted without a catalog round-trip.\n if (cleanModelId(input.envModel) === cleaned) return admit(cleaned)\n\n if (!input.loadModels || typeof input.routerBaseUrl !== 'string' || input.routerBaseUrl.length === 0) {\n return { succeeded: false, error: `Model is not available: ${cleaned}` }\n }\n\n let catalog: ModelInfo[]\n try {\n catalog = await input.loadModels(input.routerBaseUrl)\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n return { succeeded: false, error: `Could not validate model catalog: ${message}` }\n }\n\n const ids = new Set(catalog.flatMap(catalogIdsForModel))\n if (ids.has(cleaned)) return admit(cleaned)\n\n if (!cleaned.includes('/')) {\n const canonicalBySuffix = new Map<string, string[]>()\n for (const model of catalog) {\n if (typeof model.id !== 'string' || !model.id.trim()) continue\n const canonical = canonicalModelId(model)\n if (!canonical.includes('/')) continue\n const suffix = canonical.split('/').slice(1).join('/')\n const entries = canonicalBySuffix.get(suffix)\n if (entries) entries.push(canonical)\n else canonicalBySuffix.set(suffix, [canonical])\n }\n const matches = canonicalBySuffix.get(cleaned)\n if (matches && matches.length === 1) return admit(matches[0]!)\n }\n\n return { succeeded: false, error: `Model is not available: ${cleaned}` }\n}\n\n/** Resolve and return a trimmed string model ID or undefined for invalid or empty input */\nexport function cleanModelId(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\n/** Validate if a model ID string conforms to length and character format requirements */\nexport function isWellFormedModelId(modelId: string): boolean {\n if (modelId.length > 200) return false\n return /^[A-Za-z0-9._/@:-]+$/.test(modelId)\n}\n\n/** Resolve unique catalog IDs associated with a given model including its canonical form if applicable */\nexport function catalogIdsForModel(model: ModelInfo): string[] {\n const ids = new Set<string>()\n if (typeof model.id === 'string' && model.id.trim()) ids.add(model.id.trim())\n if (typeof model.id === 'string' && model.id.trim() && !model.id.includes('/')) {\n const canonical = canonicalModelId(model)\n if (canonical.includes('/')) ids.add(canonical)\n }\n return [...ids]\n}\n\n/**\n * A router `/v1/models` row carrying the compliance flags\n * {@link modelPolicyCatalogFromRouter} reads. The router spells them\n * snake_case; the policy catalog spells them camelCase (`ModelPolicyInfo`).\n */\nexport interface RouterModelPolicyRow extends ModelInfo {\n /** The provider retains no prompt/response data for this model. */\n zdr?: boolean\n /** The vendor will sign a BAA covering this model. */\n hipaa_eligible?: boolean\n}\n\n/**\n * Map router `/v1/models` rows to a {@link ModelPolicyInfo} catalog: snake_case\n * `zdr` / `hipaa_eligible` become `zeroDataRetention` / `hipaaEligible`, and\n * `provider` comes from the row's `_provider`/`provider`.\n *\n * GRANULARITY: the flags are trusted VERBATIM, per row, at whatever\n * granularity the router publishes them. A ZDR/BAA attestation is commonly a\n * provider-wide vendor fact, but this mapper never infers one model's\n * eligibility from a sibling's — a row that omits a flag yields `undefined`\n * for it, and a filter reading `=== true` refuses that row (fail closed). If\n * your attestation is provider-wide, publish it on every row upstream rather\n * than asking the mapper to guess.\n *\n * ID KEYING: one entry is emitted per spelling {@link catalogIdsForModel}\n * yields (the bare id AND its provider-prefixed canonical form), so a turn\n * carrying either spelling classifies — lookups in `/policy-filters` are\n * exact-string and never normalize. Rows with a blank/non-string id are\n * skipped. Duplicate ids across rows are a product bug (see\n * `indexPolicyCatalog`).\n */\nexport function modelPolicyCatalogFromRouter(rows: Iterable<RouterModelPolicyRow>): ModelPolicyInfo[] {\n const catalog: ModelPolicyInfo[] = []\n for (const row of rows) {\n if (typeof row.id !== 'string' || !row.id.trim()) continue\n const provider = row._provider ?? row.provider\n const flags = {\n ...(typeof row.zdr === 'boolean' ? { zeroDataRetention: row.zdr } : {}),\n ...(typeof row.hipaa_eligible === 'boolean' ? { hipaaEligible: row.hipaa_eligible } : {}),\n }\n for (const id of catalogIdsForModel(row)) {\n catalog.push({ id, ...(provider ? { provider } : {}), ...flags })\n }\n }\n return catalog\n}\n\n// Live model routing by allow-list: the positive form of model_not_allowed.\n// Parse the set the router's refusal already names, reroute within the\n// product's fallback order, or refuse loudly naming the set.\nexport {\n parseModelNotAllowed,\n resolveModelWithinAllowList,\n formatModelNotAllowedRefusal,\n type ResolveModelWithinAllowListInput,\n type ModelWithinAllowListResolution,\n} from './allow-list'\n\n// Reactive failover for the case validation cannot catch: a model that IS in the\n// catalog but whose upstream is down. Catalog membership is not liveness.\nexport {\n isUpstreamUnavailable,\n readHttpStatusHint,\n runWithModelFailover,\n buildModelChain,\n ModelFailoverExhaustedError,\n UPSTREAM_UNAVAILABLE_CODES,\n UPSTREAM_UNAVAILABLE_STATUSES,\n type ModelFailoverAttempt,\n type ModelFailoverResult,\n type RunWithModelFailoverInput,\n} from './failover'\n"],"mappings":"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","names":[]}
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Policy filters — product-supplied admission policy over the models a
3
+ * deployment will run.
4
+ *
5
+ * The engine owns the MECHANISM (a typed predicate, combinators, a fail-closed
6
+ * enforcement seam, a catalog filter for pickers); the product owns the POLICY
7
+ * (the metadata and the filter), supplied as data. Nothing here names a vendor,
8
+ * a regulation, or a product — a HIPAA deployment's "zero-data-retention,
9
+ * BAA-eligible models only" rule is one `PolicyFilter` over product-supplied
10
+ * `ModelPolicyInfo`, and any other restriction composes the same way.
11
+ *
12
+ * Fail-closed contract, everywhere this module decides:
13
+ *
14
+ * - An item with NO policy metadata is refused (enforcement) or hidden
15
+ * (catalog filter). A policy that cannot classify an item does not admit it.
16
+ * - A filter that THROWS is a refusal, never an admission — a broken policy
17
+ * check fails the turn instead of waving it through.
18
+ * - Enforcement composes with availability, never replaces it: the
19
+ * `model-resolution` seam applies the policy AFTER its existing
20
+ * allowlist/env/catalog admission, on every path — a policy-filtered
21
+ * deployment cannot be bypassed through an operator-trusted lane.
22
+ *
23
+ * Browser-safe and dependency-free by construction: no imports at all, so UI
24
+ * pickers (docs/ui-picker-canon.md) can filter the catalog they pass to
25
+ * `ModelPicker` with the same filter the server enforces.
26
+ */
27
+ /** Arbitrary context a product threads to its filters (workspace, user, turn). */
28
+ export type PolicyFilterContext = Record<string, unknown>;
29
+ /**
30
+ * A typed admission predicate over one catalog item. May be async (a product
31
+ * can read its metadata from storage). Returning `false` denies; THROWING
32
+ * denies too — enforcement treats a filter error as a refusal.
33
+ */
34
+ export type PolicyFilter<T, C extends object = PolicyFilterContext> = (item: T, context: C) => boolean | Promise<boolean>;
35
+ /**
36
+ * Admit only items every filter admits.
37
+ *
38
+ * Throws at COMPOSITION time on an empty `allOf()`: an empty conjunction
39
+ * admits everything, which reads as a policy that was never wired — the same
40
+ * silent-admission class as a missing filter. Say "no restriction" out loud
41
+ * with {@link allowAll} instead.
42
+ */
43
+ export declare function allOf<T, C extends object = PolicyFilterContext>(...filters: Array<PolicyFilter<T, C>>): PolicyFilter<T, C>;
44
+ /** Admit items at least one filter admits. An empty `anyOf` admits nothing —
45
+ * already fail-closed, so it is allowed. */
46
+ export declare function anyOf<T, C extends object = PolicyFilterContext>(...filters: Array<PolicyFilter<T, C>>): PolicyFilter<T, C>;
47
+ /**
48
+ * The explicit "no restriction" filter — what an empty `allOf()` used to mean
49
+ * silently. Admits every item; the catalog still must classify the id (a
50
+ * policy of `allowAll` over an empty catalog admits nothing).
51
+ */
52
+ export declare function allowAll<T, C extends object = PolicyFilterContext>(): PolicyFilter<T, C>;
53
+ /**
54
+ * Policy metadata for one model — DATA the product supplies (from its router
55
+ * catalog, a compliance review, a vendor attestation), never engine policy.
56
+ * The named fields are the common compliance dimensions; the index signature
57
+ * carries any product-specific attribute a filter reads.
58
+ */
59
+ export interface ModelPolicyInfo {
60
+ id: string;
61
+ provider?: string;
62
+ /** The provider retains no prompt/response data for this model. */
63
+ zeroDataRetention?: boolean;
64
+ /** The vendor will sign a BAA covering this model. */
65
+ hipaaEligible?: boolean;
66
+ [attribute: string]: unknown;
67
+ }
68
+ /**
69
+ * The policy a model-enforcement seam (`validateChatModelId`,
70
+ * `runWithModelFailover`, `createChatTurnRoutes`, `createSandboxChatProducer`)
71
+ * takes: which metadata to classify by, and which filter to apply. One shape,
72
+ * threaded unchanged through every seam so a product states its admission
73
+ * policy exactly once.
74
+ */
75
+ export interface ModelPolicy<C extends object = PolicyFilterContext> {
76
+ filter: PolicyFilter<ModelPolicyInfo, C>;
77
+ catalog: PolicyCatalog<ModelPolicyInfo>;
78
+ /** Threaded to the filter unchanged. */
79
+ context?: C;
80
+ }
81
+ /**
82
+ * Where enforcement reads an item's metadata: an in-memory iterable, a
83
+ * `ReadonlyMap` keyed by id, or a lookup (sync or async) keyed by the item's
84
+ * id. Returning `undefined` means UNCLASSIFIED — enforcement refuses the item.
85
+ *
86
+ * Key the catalog by the EXACT id string the turn carries (see
87
+ * docs/policy-filters.md § "Ids are exact strings"). Lookups never normalize.
88
+ */
89
+ export type PolicyCatalog<T extends {
90
+ id: string;
91
+ }> = Iterable<T> | ReadonlyMap<string, T> | ((id: string) => T | undefined | Promise<T | undefined>);
92
+ /** Why a policy check refused, machine-readable on the result and on {@link PolicyDeniedError}. */
93
+ export type PolicyDeniedCode = 'policy-metadata-missing' | 'policy-denied' | 'policy-filter-error';
94
+ /** The outcome of one policy check. Admission carries the item's metadata; a refusal carries a code and a reason. */
95
+ export type PolicyCheckResult<T = unknown> = {
96
+ allowed: true;
97
+ info: T;
98
+ } | {
99
+ allowed: false;
100
+ code: PolicyDeniedCode;
101
+ reason: string;
102
+ };
103
+ /** The error {@link assertPolicy} throws on any refusal. */
104
+ export declare class PolicyDeniedError extends Error {
105
+ readonly code: PolicyDeniedCode;
106
+ readonly kind: string;
107
+ readonly id: string;
108
+ constructor(code: PolicyDeniedCode, kind: string, id: string, reason: string);
109
+ }
110
+ /** Input to one policy check: what is being admitted, from which catalog, under which filter. */
111
+ export interface PolicyCheckInput<T extends {
112
+ id: string;
113
+ }, C extends object = PolicyFilterContext> {
114
+ /** What is being admitted ('model' | 'harness' | 'profile' | …) — names the refusal. */
115
+ kind: string;
116
+ /** The id being admitted. */
117
+ id: string;
118
+ /** The product's policy metadata. */
119
+ catalog: PolicyCatalog<T>;
120
+ /** The product's admission filter. */
121
+ filter: PolicyFilter<T, C>;
122
+ /** Threaded to the filter unchanged. */
123
+ context?: C;
124
+ }
125
+ /**
126
+ * Fail-closed policy check. Admits only when the catalog classifies the id AND
127
+ * the filter accepts it. Unclassified ids and filter errors refuse.
128
+ */
129
+ export declare function checkPolicy<T extends {
130
+ id: string;
131
+ }, C extends object = PolicyFilterContext>(input: PolicyCheckInput<T, C>): Promise<PolicyCheckResult<T>>;
132
+ /**
133
+ * The throwing form of {@link checkPolicy} for enforcement seams that fail by
134
+ * exception. Resolves with the item's metadata on admission; throws
135
+ * {@link PolicyDeniedError} on any refusal.
136
+ */
137
+ export declare function assertPolicy<T extends {
138
+ id: string;
139
+ }, C extends object = PolicyFilterContext>(input: PolicyCheckInput<T, C>): Promise<T>;
140
+ /** Policy check over a model id. See {@link checkPolicy}. */
141
+ export declare function checkModelPolicy<C extends object = PolicyFilterContext>(id: string, catalog: PolicyCatalog<ModelPolicyInfo>, filter: PolicyFilter<ModelPolicyInfo, C>, context?: C): Promise<PolicyCheckResult<ModelPolicyInfo>>;
142
+ /**
143
+ * Browser-safe catalog filter for pickers: keep only items the policy admits.
144
+ * Items the catalog does not classify are dropped — a picker offers exactly
145
+ * what a turn would run, and no more. Per docs/ui-picker-canon.md the picker
146
+ * itself still reconciles an absent SAVED value under its own name; this
147
+ * filter shapes the offered list, never the saved value.
148
+ *
149
+ * An iterable catalog is indexed ONCE into a Map up front: per-item rescans
150
+ * are O(items × catalog) and silently return nothing on a one-shot iterable.
151
+ */
152
+ export declare function filterItemsByPolicy<Item extends {
153
+ id: string;
154
+ }, Info extends {
155
+ id: string;
156
+ }, C extends object = PolicyFilterContext>(items: readonly Item[], catalog: PolicyCatalog<Info>, filter: PolicyFilter<Info, C>, context?: C): Promise<Item[]>;
157
+ /**
158
+ * Index an iterable catalog into a `Map` keyed by exact id — the form to hold
159
+ * when one catalog answers many lookups (a picker filter, a failover chain, a
160
+ * busy route). Ids must be unique: a Map keeps the LAST entry for a repeated
161
+ * id while a linear scan of the iterable finds the FIRST, and a catalog with
162
+ * duplicate ids is a product bug either way.
163
+ */
164
+ export declare function indexPolicyCatalog<T extends {
165
+ id: string;
166
+ }>(catalog: Iterable<T>): Map<string, T>;
@@ -0,0 +1,23 @@
1
+ import {
2
+ PolicyDeniedError,
3
+ allOf,
4
+ allowAll,
5
+ anyOf,
6
+ assertPolicy,
7
+ checkModelPolicy,
8
+ checkPolicy,
9
+ filterItemsByPolicy,
10
+ indexPolicyCatalog
11
+ } from "../chunk-XELWWXJJ.js";
12
+ export {
13
+ PolicyDeniedError,
14
+ allOf,
15
+ allowAll,
16
+ anyOf,
17
+ assertPolicy,
18
+ checkModelPolicy,
19
+ checkPolicy,
20
+ filterItemsByPolicy,
21
+ indexPolicyCatalog
22
+ };
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -121,9 +121,9 @@ export declare function createPublicConsultation(options: PublicConsultationOpti
121
121
  executionBudget?: ChatTurnExecutionLimits;
122
122
  maxOutputTokens?: number;
123
123
  }) => Promise<{
124
- start?: undefined;
125
124
  status: 'unsupported';
126
125
  reason: string;
126
+ start?: undefined;
127
127
  } | {
128
128
  reason?: undefined;
129
129
  status: 'prepared';
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createChatTurnRoutes,
3
3
  streamChatRouteAsSandboxEvents
4
- } from "../chunk-Q2H5FKVG.js";
4
+ } from "../chunk-XP77DJEF.js";
5
5
  import "../chunk-Q7WEEN7P.js";
6
6
  import "../chunk-UTAIRVOK.js";
7
7
  import "../chunk-RN4WPTBH.js";
@@ -16,6 +16,7 @@ import "../chunk-TXD5HXLE.js";
16
16
  import "../chunk-ZGUXI5SI.js";
17
17
  import "../chunk-6VQCFJWP.js";
18
18
  import "../chunk-LQYIBL3G.js";
19
+ import "../chunk-XELWWXJJ.js";
19
20
 
20
21
  // src/public-consultation/index.ts
21
22
  import { searchKnowledge } from "@tangle-network/agent-knowledge";