@tangle-network/agent-app 0.60.191 → 0.60.193

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 (51) hide show
  1. package/dist/app-auth/index.js +3 -3
  2. package/dist/assistant/index.js +8 -8
  3. package/dist/chat-react/index.js +2 -2
  4. package/dist/chat-routes/attachment-upload.d.ts +1 -1
  5. package/dist/chat-routes/attachment-validation.d.ts +50 -1
  6. package/dist/chat-routes/index.js +16 -82
  7. package/dist/chat-routes/index.js.map +1 -1
  8. package/dist/chat-routes/model-failover-stream.d.ts +8 -0
  9. package/dist/chat-routes/resolve-attachments.d.ts +1 -25
  10. package/dist/chat-routes/sandbox-producer.d.ts +12 -0
  11. package/dist/chat-routes/turn-routes.d.ts +14 -0
  12. package/dist/chat-store/index.js +2 -2
  13. package/dist/{chunk-MSAGTFN4.js → chunk-6RFNLZCK.js} +2 -2
  14. package/dist/{chunk-TNWDYAFX.js → chunk-E6UTFXKQ.js} +4 -4
  15. package/dist/{chunk-Y2SOQOJE.js → chunk-KNTRS7OZ.js} +2 -2
  16. package/dist/{chunk-QAF7ZTO3.js → chunk-L5JLOC2F.js} +4 -4
  17. package/dist/{chunk-WTC6AOPL.js → chunk-PBQYVAB6.js} +82 -2
  18. package/dist/chunk-PBQYVAB6.js.map +1 -0
  19. package/dist/chunk-XELWWXJJ.js +101 -0
  20. package/dist/chunk-XELWWXJJ.js.map +1 -0
  21. package/dist/{chunk-J26IXWFK.js → chunk-XP77DJEF.js} +17 -2
  22. package/dist/chunk-XP77DJEF.js.map +1 -0
  23. package/dist/{chunk-63EKFZOE.js → chunk-ZLX4BA27.js} +15 -1
  24. package/dist/chunk-ZLX4BA27.js.map +1 -0
  25. package/dist/model-resolution/failover.d.ts +16 -2
  26. package/dist/model-resolution/index.d.ts +42 -0
  27. package/dist/model-resolution/index.js +30 -5
  28. package/dist/model-resolution/index.js.map +1 -1
  29. package/dist/operator/chat-adapter.d.ts +9 -0
  30. package/dist/operator/contract.d.ts +11 -1
  31. package/dist/operator/index.js +18 -4
  32. package/dist/operator/index.js.map +1 -1
  33. package/dist/operator/server.d.ts +7 -0
  34. package/dist/policy-filters/index.d.ts +166 -0
  35. package/dist/policy-filters/index.js +23 -0
  36. package/dist/policy-filters/index.js.map +1 -0
  37. package/dist/public-consultation/index.d.ts +1 -1
  38. package/dist/public-consultation/index.js +4 -3
  39. package/dist/public-consultation/index.js.map +1 -1
  40. package/dist/sandbox/index.js +2 -2
  41. package/dist/settings-react/index.js +1 -1
  42. package/dist/tools/index.js +2 -2
  43. package/dist/web-react/index.js +12 -12
  44. package/package.json +6 -1
  45. package/dist/chunk-63EKFZOE.js.map +0 -1
  46. package/dist/chunk-J26IXWFK.js.map +0 -1
  47. package/dist/chunk-WTC6AOPL.js.map +0 -1
  48. /package/dist/{chunk-MSAGTFN4.js.map → chunk-6RFNLZCK.js.map} +0 -0
  49. /package/dist/{chunk-TNWDYAFX.js.map → chunk-E6UTFXKQ.js.map} +0 -0
  50. /package/dist/{chunk-Y2SOQOJE.js.map → chunk-KNTRS7OZ.js.map} +0 -0
  51. /package/dist/{chunk-QAF7ZTO3.js.map → chunk-L5JLOC2F.js.map} +0 -0
@@ -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":"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","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":[]}
@@ -1,3 +1,4 @@
1
+ import type { ChatAttachmentInput } from '../chat-routes/wire.js';
1
2
  import type { OperatorApproval, OperatorAssetRef, OperatorFile, OperatorFileEntry, OperatorJournalEntry, OperatorRole, OperatorScorecard } from './contract.js';
2
3
  import { type OperatorAdapter, type OperatorContext } from './server.js';
3
4
  /** The message fields the adapter reads; agent-app's chat-store rows satisfy it. */
@@ -58,6 +59,12 @@ export interface ChatOperatorAdmissionStore<Identity> {
58
59
  export interface ChatOperatorAdapterOptions<Identity> {
59
60
  /** Optional durable claims; without one, transcript retry detection is not an atomic admission lock. */
60
61
  admissions?: ChatOperatorAdmissionStore<Identity>;
62
+ /**
63
+ * Set when `runTurn` forwards `attachments` in the chat request body and the
64
+ * chat route resolves them from the workspace's store, as with
65
+ * `resolveChatAttachments`. Without it, turns that carry attachments are refused.
66
+ */
67
+ acceptsAttachments?: boolean;
61
68
  /** The app's public origin, the base of every browser link. */
62
69
  origin: string;
63
70
  /** Browser path of a conversation; defaults to `/app/<workspace>/chat/<thread>`. */
@@ -98,6 +105,7 @@ export interface ChatOperatorAdapterOptions<Identity> {
98
105
  * Run one turn through the app's own chat route as this caller. The adapter
99
106
  * reads the response to its end, so the turn is driven exactly as a browser
100
107
  * holding its stream drives it, and the start request returns once it settles.
108
+ * `attachments` arrives only when `acceptsAttachments` is set.
101
109
  */
102
110
  runTurn(ctx: OperatorContext<Identity>, input: {
103
111
  workspaceId: string;
@@ -105,6 +113,7 @@ export interface ChatOperatorAdapterOptions<Identity> {
105
113
  turnId: string;
106
114
  content: string;
107
115
  model?: string;
116
+ attachments?: ChatAttachmentInput[];
108
117
  }): Promise<Response>;
109
118
  /** Optional authoritative status of this user-turn anchor. When supplied,
110
119
  * missing/running status without a live lease never promotes a partial reply to success. */
@@ -4,6 +4,7 @@
4
4
  * another app's agent) holds one scoped key per app and drives workspaces
5
5
  * through these routes; the app supplies only storage and execution.
6
6
  */
7
+ import type { ChatAttachmentInput } from '../chat-routes/wire.js';
7
8
  export declare const OPERATOR_API_VERSION = "v1";
8
9
  export declare const OPERATOR_API_BASE_PATH = "/api/operator/v1";
9
10
  /** Key permissions, with the labels an app's API access page shows. */
@@ -42,7 +43,7 @@ export interface OperatorAppInfo {
42
43
  /** Optional operations this app implements; absent ones answer 501. */
43
44
  capabilities: OperatorCapability[];
44
45
  }
45
- export type OperatorCapability = 'workspaces.create' | 'threads.read' | 'files.read' | 'journal.read' | 'assets.read' | 'scorecard.read';
46
+ export type OperatorCapability = 'turns.attachments' | 'workspaces.create' | 'threads.read' | 'files.read' | 'journal.read' | 'assets.read' | 'scorecard.read';
46
47
  export interface OperatorPrincipalInfo {
47
48
  keyId: string;
48
49
  scopes: string[];
@@ -174,6 +175,15 @@ export interface StartTurnInput {
174
175
  /** Title for a new conversation. */
175
176
  title?: string;
176
177
  model?: string;
178
+ /**
179
+ * Files uploaded to this workspace ahead of the turn, as the app's
180
+ * attachment upload route returned them in `{ files }`. They reach the
181
+ * agent exactly as browser-composer attachments do; the app re-checks each
182
+ * path against the workspace's own store. At most `ATTACHMENT_MAX_COUNT`
183
+ * (`/chat-routes`) entries. Apps without the `turns.attachments`
184
+ * capability refuse them.
185
+ */
186
+ attachments?: ChatAttachmentInput[];
177
187
  }
178
188
  /** Route table: method, path pattern under the base path, and required scopes. */
179
189
  export declare const OPERATOR_ROUTES: readonly [{
@@ -3,12 +3,17 @@ import {
3
3
  agentOperatorScopes,
4
4
  createApiKeyRequestAuth
5
5
  } from "../chunk-XB4KZVUV.js";
6
+ import {
7
+ parseChatAttachmentInputs
8
+ } from "../chunk-PBQYVAB6.js";
6
9
  import {
7
10
  createApiKeyFetch
8
11
  } from "../chunk-RN4WPTBH.js";
9
12
  import {
10
13
  messageHasTurnId
11
14
  } from "../chunk-YG66IHDU.js";
15
+ import "../chunk-YHRWZZJN.js";
16
+ import "../chunk-TXD5HXLE.js";
12
17
 
13
18
  // src/operator/contract.ts
14
19
  var OPERATOR_API_VERSION = "v1";
@@ -121,7 +126,7 @@ var TURN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a
121
126
  var THREAD_ID = /^[A-Za-z0-9_-]{1,256}$/;
122
127
  var MAX_BODY_BYTES = 128 * 1024;
123
128
  var MAX_CONTENT_BYTES = 64 * 1024;
124
- var START_FIELDS = /* @__PURE__ */ new Set(["turnId", "content", "threadId", "title", "model"]);
129
+ var START_FIELDS = /* @__PURE__ */ new Set(["turnId", "content", "threadId", "title", "model", "attachments"]);
125
130
  var NO_STORE = { "cache-control": "private, no-store" };
126
131
  function json(body, status = 200) {
127
132
  return Response.json(body, { status, headers: NO_STORE });
@@ -192,12 +197,15 @@ function parseStartTurn(body) {
192
197
  }
193
198
  const title = optionalString(body, "title", 200);
194
199
  const model = optionalString(body, "model", 200);
200
+ const attachments = parseChatAttachmentInputs(body.attachments);
201
+ if (!attachments.succeeded) throw new OperatorError("operator.invalid_input", 400, attachments.error);
195
202
  return {
196
203
  turnId: body.turnId.toLowerCase(),
197
204
  content: body.content,
198
205
  ...threadId === void 0 ? {} : { threadId },
199
206
  ...title === void 0 ? {} : { title },
200
- ...model === void 0 ? {} : { model }
207
+ ...model === void 0 ? {} : { model },
208
+ ...attachments.value.length === 0 ? {} : { attachments: attachments.value }
201
209
  };
202
210
  }
203
211
  function capabilities(adapter) {
@@ -209,7 +217,8 @@ function capabilities(adapter) {
209
217
  ["assets.read", adapter.readAsset],
210
218
  ["scorecard.read", adapter.getScorecard]
211
219
  ];
212
- return present.filter(([, implemented]) => typeof implemented === "function").map(([name]) => name);
220
+ const implemented = present.filter(([, method]) => typeof method === "function").map(([name]) => name);
221
+ return adapter.acceptsAttachments === true ? ["turns.attachments", ...implemented] : implemented;
213
222
  }
214
223
  function unsupported() {
215
224
  throw new OperatorError("operator.unsupported", 501, "This app does not implement this operation");
@@ -306,6 +315,9 @@ function createOperatorApi(options) {
306
315
  return json({ workspace });
307
316
  case "turns.start": {
308
317
  const input = parseStartTurn(await readJsonBody(request));
318
+ if (input.attachments && adapter.acceptsAttachments !== true) {
319
+ throw new OperatorError("operator.unsupported", 501, "This app does not accept attachments on operator turns");
320
+ }
309
321
  return json({ turn: await adapter.startTurn(ctx, workspace, input) }, 202);
310
322
  }
311
323
  case "threads.list": {
@@ -682,6 +694,7 @@ function createChatOperatorAdapter(options) {
682
694
  return id;
683
695
  }
684
696
  const adapter = {
697
+ ...options.acceptsAttachments === true ? { acceptsAttachments: true } : {},
685
698
  async authorizeWorkspace(ctx, workspaceId, access) {
686
699
  const workspace = await options.workspaces.authorize(ctx, workspaceId, access);
687
700
  return workspace ? workspaceView(workspace) : null;
@@ -715,7 +728,8 @@ function createChatOperatorAdapter(options) {
715
728
  threadId,
716
729
  turnId: input.turnId,
717
730
  content: input.content,
718
- ...input.model === void 0 ? {} : { model: input.model }
731
+ ...input.model === void 0 ? {} : { model: input.model },
732
+ ...input.attachments === void 0 ? {} : { attachments: input.attachments }
719
733
  });
720
734
  if (!response.ok) {
721
735
  const body = await response.json().catch(() => ({}));