@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.
- package/dist/app-auth/index.js +3 -3
- package/dist/assistant/index.js +8 -8
- package/dist/chat-react/index.js +2 -2
- package/dist/chat-routes/attachment-upload.d.ts +1 -1
- package/dist/chat-routes/attachment-validation.d.ts +50 -1
- package/dist/chat-routes/index.js +16 -82
- package/dist/chat-routes/index.js.map +1 -1
- package/dist/chat-routes/model-failover-stream.d.ts +8 -0
- package/dist/chat-routes/resolve-attachments.d.ts +1 -25
- package/dist/chat-routes/sandbox-producer.d.ts +12 -0
- package/dist/chat-routes/turn-routes.d.ts +14 -0
- package/dist/chat-store/index.js +2 -2
- package/dist/{chunk-MSAGTFN4.js → chunk-6RFNLZCK.js} +2 -2
- package/dist/{chunk-TNWDYAFX.js → chunk-E6UTFXKQ.js} +4 -4
- package/dist/{chunk-Y2SOQOJE.js → chunk-KNTRS7OZ.js} +2 -2
- package/dist/{chunk-QAF7ZTO3.js → chunk-L5JLOC2F.js} +4 -4
- package/dist/{chunk-WTC6AOPL.js → chunk-PBQYVAB6.js} +82 -2
- package/dist/chunk-PBQYVAB6.js.map +1 -0
- package/dist/chunk-XELWWXJJ.js +101 -0
- package/dist/chunk-XELWWXJJ.js.map +1 -0
- package/dist/{chunk-J26IXWFK.js → chunk-XP77DJEF.js} +17 -2
- package/dist/chunk-XP77DJEF.js.map +1 -0
- package/dist/{chunk-63EKFZOE.js → chunk-ZLX4BA27.js} +15 -1
- package/dist/chunk-ZLX4BA27.js.map +1 -0
- package/dist/model-resolution/failover.d.ts +16 -2
- package/dist/model-resolution/index.d.ts +42 -0
- package/dist/model-resolution/index.js +30 -5
- package/dist/model-resolution/index.js.map +1 -1
- package/dist/operator/chat-adapter.d.ts +9 -0
- package/dist/operator/contract.d.ts +11 -1
- package/dist/operator/index.js +18 -4
- package/dist/operator/index.js.map +1 -1
- package/dist/operator/server.d.ts +7 -0
- package/dist/policy-filters/index.d.ts +166 -0
- package/dist/policy-filters/index.js +23 -0
- package/dist/policy-filters/index.js.map +1 -0
- package/dist/public-consultation/index.d.ts +1 -1
- package/dist/public-consultation/index.js +4 -3
- package/dist/public-consultation/index.js.map +1 -1
- package/dist/sandbox/index.js +2 -2
- package/dist/settings-react/index.js +1 -1
- package/dist/tools/index.js +2 -2
- package/dist/web-react/index.js +12 -12
- package/package.json +6 -1
- package/dist/chunk-63EKFZOE.js.map +0 -1
- package/dist/chunk-J26IXWFK.js.map +0 -1
- package/dist/chunk-WTC6AOPL.js.map +0 -1
- /package/dist/{chunk-MSAGTFN4.js.map → chunk-6RFNLZCK.js.map} +0 -0
- /package/dist/{chunk-TNWDYAFX.js.map → chunk-E6UTFXKQ.js.map} +0 -0
- /package/dist/{chunk-Y2SOQOJE.js.map → chunk-KNTRS7OZ.js.map} +0 -0
- /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-
|
|
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
|
|
57
|
-
if (cleanModelId(input.envModel) === cleaned) return
|
|
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
|
|
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
|
|
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 [{
|
package/dist/operator/index.js
CHANGED
|
@@ -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
|
-
|
|
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(() => ({}));
|