@oxy.so/contracts 4.6.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { catalogInvocationPathParameters, findOverlappingCatalogInvocations, } from './capabilityInvocation.js';
2
3
  const nonEmptyStringSchema = z.string().trim().min(1);
3
4
  const identifierSchema = z.string().trim().min(1).max(255);
4
5
  const limitKeySchema = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/, 'limit keys must be dot-separated input property names').max(255);
@@ -346,6 +347,42 @@ export const catalogToolSchema = z.object({
346
347
  path: ['invocation', 'method'],
347
348
  });
348
349
  }
350
+ // The invocation template is how a coordinator turns arguments into a
351
+ // request (`resolveCatalogInvocation`). A `{param}` the input schema does
352
+ // not declare as a REQUIRED property can never be filled, and a GET input
353
+ // that is not a scalar has no query-string form — both are refused here so
354
+ // the builder never has to guess.
355
+ const declared = typeof tool.inputSchema.properties === 'object'
356
+ && tool.inputSchema.properties !== null
357
+ && !Array.isArray(tool.inputSchema.properties)
358
+ ? tool.inputSchema.properties
359
+ : {};
360
+ const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : [];
361
+ const parameters = catalogInvocationPathParameters(tool.invocation.path);
362
+ requireUnique(parameters, 'invocation.path parameters');
363
+ for (const parameter of parameters) {
364
+ if (!Object.prototype.hasOwnProperty.call(declared, parameter) || !required.includes(parameter)) {
365
+ context.addIssue({
366
+ code: z.ZodIssueCode.custom,
367
+ message: `Path parameter {${parameter}} must be a required input property`,
368
+ path: ['invocation', 'path'],
369
+ });
370
+ }
371
+ }
372
+ if (tool.invocation.method === 'GET') {
373
+ for (const [key, property] of Object.entries(declared)) {
374
+ const type = typeof property === 'object' && property !== null
375
+ ? property.type
376
+ : undefined;
377
+ if (type !== 'string' && type !== 'number' && type !== 'integer' && type !== 'boolean') {
378
+ context.addIssue({
379
+ code: z.ZodIssueCode.custom,
380
+ message: `GET input ${key} must be a string, number, integer or boolean`,
381
+ path: ['inputSchema', 'properties', key],
382
+ });
383
+ }
384
+ }
385
+ }
349
386
  });
350
387
  export const catalogEventSchema = z.object({
351
388
  type: identifierSchema,
@@ -413,6 +450,18 @@ export const appCapabilityCatalogSchema = z.object({
413
450
  }
414
451
  names.add(tool.name);
415
452
  }
453
+ // Matching an incoming request back to its tool must have ONE answer that
454
+ // does not depend on the order tools are listed in — see
455
+ // `matchCatalogInvocation`. Two templates that one request could satisfy
456
+ // (`/messages/{id}` beside `/messages/unread`) make that impossible.
457
+ for (const [left, right] of findOverlappingCatalogInvocations(catalog.tools)) {
458
+ context.addIssue({
459
+ code: z.ZodIssueCode.custom,
460
+ message: `Tools ${left.name} and ${right.name} have overlapping invocations `
461
+ + `(${left.invocation.method} ${left.invocation.path} / ${right.invocation.method} ${right.invocation.path})`,
462
+ path: ['tools'],
463
+ });
464
+ }
416
465
  const eventTypes = new Set();
417
466
  for (const [index, event] of catalog.events.entries()) {
418
467
  if (eventTypes.has(event.type)) {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * How a catalog tool call travels over HTTP, in both directions.
3
+ *
4
+ * A capability catalog names each tool's `invocation` as a method plus a path
5
+ * template (`/email/messages/{emailId}`). Two parties have to agree on what
6
+ * that means: the coordinator that BUILDS the request from the model's
7
+ * arguments (Alia), and the app that MATCHES an incoming request back to the
8
+ * one tool its ticket was signed for (the Inbox capability middleware). Each
9
+ * used to carry its own copy of the rules, and the matcher walked the tool list
10
+ * in array order, so a literal segment (`/messages/unread`) and a parameter
11
+ * (`/messages/{id}`) in the same position resolved to whichever tool happened to
12
+ * be declared first. Both halves live here now, next to the catalog schema that
13
+ * refuses a catalog whose templates could ever match the same request — so a
14
+ * match is unique by construction, not by ordering.
15
+ */
16
+ const PARAMETER_SEGMENT = /^\{([A-Za-z][A-Za-z0-9_]*)\}$/;
17
+ function pathSegments(path) {
18
+ return path.split('/').filter(Boolean);
19
+ }
20
+ /** The `{name}` placeholders of a path template, in order. */
21
+ export function catalogInvocationPathParameters(path) {
22
+ return pathSegments(path).flatMap((segment) => {
23
+ const parameter = PARAMETER_SEGMENT.exec(segment)?.[1];
24
+ return parameter ? [parameter] : [];
25
+ });
26
+ }
27
+ /**
28
+ * Whether one concrete request could match BOTH templates.
29
+ *
30
+ * Same method, same number of segments, and at every position either the same
31
+ * literal or a parameter on at least one side. `/messages/{id}` and
32
+ * `/messages/unread` overlap (the request `/messages/unread` fits both);
33
+ * `/messages/{id}` and `/messages/{id}/thread` do not.
34
+ */
35
+ export function catalogInvocationsOverlap(left, right) {
36
+ if (left.method.toUpperCase() !== right.method.toUpperCase())
37
+ return false;
38
+ const leftSegments = pathSegments(left.path);
39
+ const rightSegments = pathSegments(right.path);
40
+ if (leftSegments.length !== rightSegments.length)
41
+ return false;
42
+ return leftSegments.every((segment, index) => {
43
+ const other = rightSegments[index] ?? '';
44
+ return segment === other || PARAMETER_SEGMENT.test(segment) || PARAMETER_SEGMENT.test(other);
45
+ });
46
+ }
47
+ /**
48
+ * Whether two templates name the same ADDRESS: same method, and at every
49
+ * position the same literal or a parameter on both sides — parameter names do
50
+ * not matter (`/messages/{id}` and `/messages/{emailId}` are one address;
51
+ * `/messages/{id}` and `/messages/bundled` are not, though they overlap).
52
+ * Used to say which documented REST operation shares its address with a tool.
53
+ */
54
+ export function catalogInvocationTemplatesEquivalent(left, right) {
55
+ if (left.method.toUpperCase() !== right.method.toUpperCase())
56
+ return false;
57
+ const leftSegments = pathSegments(left.path);
58
+ const rightSegments = pathSegments(right.path);
59
+ if (leftSegments.length !== rightSegments.length)
60
+ return false;
61
+ return leftSegments.every((segment, index) => {
62
+ const other = rightSegments[index] ?? '';
63
+ const leftIsParameter = PARAMETER_SEGMENT.test(segment);
64
+ return leftIsParameter ? PARAMETER_SEGMENT.test(other) : segment === other;
65
+ });
66
+ }
67
+ /** Every pair of tools whose invocations overlap — empty for a valid catalog. */
68
+ export function findOverlappingCatalogInvocations(tools) {
69
+ const overlaps = [];
70
+ for (let left = 0; left < tools.length; left += 1) {
71
+ for (let right = left + 1; right < tools.length; right += 1) {
72
+ const a = tools[left];
73
+ const b = tools[right];
74
+ if (a && b && catalogInvocationsOverlap(a.invocation, b.invocation))
75
+ overlaps.push([a, b]);
76
+ }
77
+ }
78
+ return overlaps;
79
+ }
80
+ function matchTemplate(template, method, requestSegments) {
81
+ if (template.method.toUpperCase() !== method.toUpperCase())
82
+ return null;
83
+ const templateSegments = pathSegments(template.path);
84
+ if (templateSegments.length !== requestSegments.length)
85
+ return null;
86
+ const params = {};
87
+ for (const [index, templateSegment] of templateSegments.entries()) {
88
+ const requestSegment = requestSegments[index];
89
+ if (!requestSegment)
90
+ return null;
91
+ const parameter = PARAMETER_SEGMENT.exec(templateSegment)?.[1];
92
+ if (!parameter) {
93
+ if (templateSegment !== requestSegment)
94
+ return null;
95
+ continue;
96
+ }
97
+ try {
98
+ params[parameter] = decodeURIComponent(requestSegment);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ return params;
105
+ }
106
+ /**
107
+ * The ONE tool whose invocation matches `method` + `path` (the full app-local
108
+ * path, e.g. `/email/messages/abc`), or null.
109
+ *
110
+ * Every tool is tried, not the first hit, so the answer cannot depend on the
111
+ * order the catalog lists its tools in. A second hit means the catalog should
112
+ * never have validated — `appCapabilityCatalogSchema` refuses overlapping
113
+ * templates — so it throws rather than guessing which tool the ticket meant.
114
+ */
115
+ export function matchCatalogInvocation(tools, method, path) {
116
+ const requestSegments = pathSegments(path);
117
+ const matches = [];
118
+ for (const tool of tools) {
119
+ const params = matchTemplate(tool.invocation, method, requestSegments);
120
+ if (params)
121
+ matches.push({ tool, params });
122
+ }
123
+ if (matches.length > 1) {
124
+ throw new Error(`Ambiguous catalog invocation ${method} ${path}: ${matches.map(({ tool }) => tool.name).join(', ')}`);
125
+ }
126
+ return matches[0] ?? null;
127
+ }
128
+ /**
129
+ * Build the HTTP request for a tool call from the model's arguments.
130
+ *
131
+ * Path parameters are substituted (and removed from the remaining arguments);
132
+ * GET sends the rest as query parameters, everything else as a JSON body. A GET
133
+ * argument that is not a scalar is refused instead of being stringified into
134
+ * `[object Object]` — the catalog schema only lets GET tools declare scalar
135
+ * inputs, so reaching that branch means the arguments did not come from the
136
+ * tool's own schema. The resolved URL must stay on `internalBaseUrl`'s origin.
137
+ */
138
+ export function resolveCatalogInvocation(catalog, tool, args) {
139
+ const remaining = { ...args };
140
+ const path = tool.invocation.path.replace(/\{(\w+)\}/g, (_match, parameter) => {
141
+ const value = Object.prototype.hasOwnProperty.call(remaining, parameter) ? remaining[parameter] : undefined;
142
+ delete remaining[parameter];
143
+ if (value === undefined || value === null || value === '') {
144
+ throw new Error(`Missing required path parameter: ${parameter}`);
145
+ }
146
+ return encodeURIComponent(String(value));
147
+ });
148
+ const baseUrl = new URL(catalog.internalBaseUrl);
149
+ const url = new URL(path, `${baseUrl.origin}/`);
150
+ if (url.origin !== baseUrl.origin) {
151
+ throw new Error(`Catalog invocation for ${tool.name} escapes its registered app origin`);
152
+ }
153
+ const method = tool.invocation.method.toUpperCase();
154
+ if (method !== 'GET')
155
+ return { method, url, body: remaining };
156
+ for (const [key, value] of Object.entries(remaining)) {
157
+ if (value === undefined || value === null)
158
+ continue;
159
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
160
+ throw new Error(`GET argument ${key} of ${tool.name} must be a string, number or boolean`);
161
+ }
162
+ url.searchParams.set(key, String(value));
163
+ }
164
+ return { method, url };
165
+ }
package/dist/esm/index.js CHANGED
@@ -174,6 +174,7 @@ export {
174
174
  // decimal, so a plan allowance cannot be added to a balance.
175
175
  PRODUCT_PLAN_STATUSES, productPlanStatusSchema, LIVE_PRODUCT_PLAN_STATUSES, planAllowanceSchema, productPlanSchema, payAsYouGoEntitlementSchema, COST_CENTER_STATUSES, costCenterStatusSchema, costCenterSchema, costCenterSpendSchema, productEntitlementSchema, } from './inference/entitlement.js';
176
176
  export { AUTONOMY_LEVELS, CAPABILITY_PACKAGES, autonomyLevelSchema, capabilityPackageSchema, actorRefSchema, resourceRefSchema, toolGrantOverrideSchema, grantLimitSchema, capabilityCatalogBindingSchema, executionAuthorizationRefSchema, capabilityCoordinatorSchema, delegationGrantSchema, automationTriggerSchema, automationActorSelectionSchema, automationDataFlowSchema, automationDefinitionSchema, capabilityTicketClaimsSchema, policyDecisionSchema, auditResultSchema, auditEventSchema, catalogToolSchema, catalogEventSchema, appCapabilityCatalogSchema, catalogRegistrationSchema, normalizedAppEventSchema, } from './agency.js';
177
+ export { catalogInvocationPathParameters, catalogInvocationsOverlap, catalogInvocationTemplatesEquivalent, findOverlappingCatalogInvocations, matchCatalogInvocation, resolveCatalogInvocation, } from './capabilityInvocation.js';
177
178
  export { emailContextAddressSchema, emailContextMailboxSchema, emailContextMessageSchema, emailAgentContextSchema, } from './emailAgentContext.js';
178
179
  export { inboxComposeRequestSchema, inboxDailyBriefRequestSchema, inboxNaturalSearchRequestSchema, inboxMessageInferenceParamsSchema, inboxInferenceTextResponseSchema, inboxNaturalSearchResponseSchema, inboxSmartRepliesResponseSchema, inboxThreadSummaryResponseSchema, inboxInferenceStreamEventSchema, } from './inference/inbox.js';
179
180
  export * from './externalIdentity.js';
@@ -182,3 +183,4 @@ export * from './federationInstanceFetch.js';
182
183
  export * from './notifications.js';
183
184
  export * from './email/messages.js';
184
185
  export * from './stickers.js';
186
+ export { decisionEffortSchema, decisionQuestionSchema, decisionInputSchema, decisionRequestSchema, decisionAnswerSchema, decisionResultSchema, decisionSuccessSchema, decisionFailureSchema, decisionInputBudget, decisionFitsGateway, decisionAnswersMatch, } from './inference/decisions.js';
@@ -55,7 +55,8 @@ export const reasoningEffortSchema = z.enum(['low', 'medium', 'high']);
55
55
  * 3.4.0). Ordered cheapest first; `auto` asks Oxy to pick the cheapest level
56
56
  * that suffices for the request.
57
57
  *
58
- * - `instant` — very cheap, fast small models; no reasoning effort requested
58
+ * - `instant` — very cheap, fast small models; the least reasoning each model
59
+ * accepts (`low` on a reasoning model, nothing on one without effort control)
59
60
  * - `medium` — mid-size models, low reasoning effort
60
61
  * - `high` — strong models, medium reasoning effort
61
62
  * - `xhigh` — strong models, high reasoning effort
@@ -93,6 +94,7 @@ export const inferenceApiFormatSchema = z.enum([
93
94
  'responses',
94
95
  'chat_completions',
95
96
  'embeddings',
97
+ 'decisions',
96
98
  'images_generations',
97
99
  'audio_transcriptions',
98
100
  'audio_speech',
@@ -580,7 +582,9 @@ export const routingProfileSchema = z.object({
580
582
  powerLevel: powerLevelSchema.optional(),
581
583
  /**
582
584
  * The reasoning effort a power level asks for when the request names none,
583
- * applied only on a model that advertises it. Absent: none is requested.
585
+ * clamped to the nearest effort the chosen model accepts (the lowest at or
586
+ * above it, else the highest). Absent: the level asks for no reasoning, sent
587
+ * as the least effort a reasoning model accepts.
584
588
  */
585
589
  reasoningEffort: reasoningEffortSchema.optional(),
586
590
  });
@@ -0,0 +1,239 @@
1
+ import { z } from "zod";
2
+ import { inferenceErrorSchema } from "./errors.js";
3
+ import { modelReferenceSchema, requestIdSchema } from "./identifiers.js";
4
+ import { usageQuantitySchema } from "./money.js";
5
+ import { routingPolicyReferenceSchema } from "./routingPolicy.js";
6
+ import { normalizedUsageReportSchema } from "./usage.js";
7
+ const probability = z.number().finite().min(0).max(1);
8
+ const text = z.string().min(1).max(65536);
9
+ const id = z.string().min(1).max(128);
10
+ const tolerance = 1e-6;
11
+ export const decisionEffortSchema = z.enum([
12
+ "instant",
13
+ "low",
14
+ "medium",
15
+ "high",
16
+ "xhigh",
17
+ ]);
18
+ const common = { id, question: text, criteria: text.optional() };
19
+ export const decisionQuestionSchema = z.discriminatedUnion("kind", [
20
+ z
21
+ .object({
22
+ ...common,
23
+ kind: z.literal("choice"),
24
+ options: z.array(text).min(2).max(255),
25
+ })
26
+ .strict(),
27
+ z
28
+ .object({
29
+ ...common,
30
+ kind: z.literal("score"),
31
+ levels: z.array(text).min(2).max(10),
32
+ })
33
+ .strict(),
34
+ z.object({ ...common, kind: z.literal("noul") }).strict(),
35
+ ]);
36
+ const payloadFields = {
37
+ state: z.string().max(65536),
38
+ instructions: text.optional(),
39
+ questions: z.array(decisionQuestionSchema).min(1).max(255),
40
+ effort: decisionEffortSchema.optional(),
41
+ };
42
+ /**
43
+ * Conservative serialized input accounting, including escaping and structure.
44
+ * Measures both the normalized payload and the provider's structured-question
45
+ * representation. Common instructions repeat per question there. A 255-byte
46
+ * model identity and 4096-byte gateway-policy allowance bound adapter metadata.
47
+ * Kaana must also check its final serialized body before sending it.
48
+ */
49
+ export function decisionInputBudget(payload) {
50
+ const bytes = (value) => new TextEncoder().encode(JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`)).length;
51
+ const model = "x".repeat(255);
52
+ const measure = (questions) => {
53
+ const wireQuestions = Object.fromEntries(questions.map((q) => [q.id, {
54
+ type: q.kind,
55
+ instructions: {
56
+ question: q.question,
57
+ ...(payload.instructions === undefined ? {} : { instructions: payload.instructions }),
58
+ ...(q.criteria === undefined ? {} : { criteria: q.criteria }),
59
+ },
60
+ ...(q.kind === "choice" ? { criteria: Object.fromEntries(q.options.map((label) => [label, null])) } :
61
+ q.kind === "score" ? { criteria: q.levels } : {}),
62
+ }]));
63
+ return Math.max(bytes({ ...payload, model, questions }), bytes({ model, state: payload.state, questions: wireQuestions, ...(payload.effort === undefined ? {} : { effort: payload.effort }) }));
64
+ };
65
+ const total = measure(payload.questions);
66
+ return { total, context: Math.max(...payload.questions.map((q) => measure([q]))), gateway: total + 4096 };
67
+ }
68
+ /** OpenRouter's TOTAL limit is separate from direct state-plus-longest context. */
69
+ export function decisionFitsGateway(payload) {
70
+ return decisionInputBudget(payload).gateway <= 32000;
71
+ }
72
+ function validateInput(payload, ctx) {
73
+ if (new Set(payload.questions.map((q) => q.id)).size !==
74
+ payload.questions.length)
75
+ ctx.addIssue({
76
+ code: "custom",
77
+ path: ["questions"],
78
+ message: "Question IDs must be unique.",
79
+ });
80
+ for (const [index, question] of payload.questions.entries()) {
81
+ const labels = question.kind === "choice"
82
+ ? question.options
83
+ : question.kind === "score"
84
+ ? question.levels
85
+ : [];
86
+ if (new Set(labels).size !== labels.length)
87
+ ctx.addIssue({
88
+ code: "custom",
89
+ path: ["questions", index],
90
+ message: "Options and levels must be unique.",
91
+ });
92
+ }
93
+ const budget = decisionInputBudget(payload);
94
+ if (budget.total > 64000 || budget.context > 32000)
95
+ ctx.addIssue({
96
+ code: "custom",
97
+ path: ["state"],
98
+ message: "Decisions exceed the serialized 64000-byte total or 32000-byte per-question context budget.",
99
+ });
100
+ }
101
+ export const decisionInputSchema = z
102
+ .object(payloadFields)
103
+ .strict()
104
+ .superRefine(validateInput);
105
+ export const decisionRequestSchema = z
106
+ .object({
107
+ model: modelReferenceSchema.refine((model) => model.includes("@"), "Decisions require a revision-pinned model."),
108
+ ...payloadFields,
109
+ })
110
+ .strict()
111
+ .superRefine(validateInput);
112
+ export const decisionAnswerSchema = z
113
+ .discriminatedUnion("kind", [
114
+ z
115
+ .object({
116
+ id,
117
+ kind: z.literal("choice"),
118
+ /** Actual provider-selected option. Never reconstructed from probabilities. */
119
+ reply: text,
120
+ /** Provider-returned confidence, not a correctness probability. */
121
+ confidence: probability,
122
+ probabilities: z.array(probability).min(2).max(255),
123
+ })
124
+ .strict(),
125
+ z
126
+ .object({
127
+ id,
128
+ kind: z.literal("score"),
129
+ /** Actual provider score. Kept separate from the validated expected index. */
130
+ reply: z.number().finite().min(0).max(9),
131
+ confidence: probability,
132
+ mean: z.number().finite().min(0).max(9),
133
+ distribution: z.array(probability).min(2).max(10),
134
+ })
135
+ .strict(),
136
+ z.object({ id, kind: z.literal("noul"), probability }).strict(),
137
+ ])
138
+ .superRefine((answer, ctx) => {
139
+ if (answer.kind === "noul")
140
+ return;
141
+ if (answer.kind === "score" && Math.abs(answer.reply - answer.mean) > tolerance) {
142
+ ctx.addIssue({ code: "custom", path: ["reply"], message: "Provider score must match the expected level index." });
143
+ }
144
+ const distribution = answer.kind === "choice" ? answer.probabilities : answer.distribution;
145
+ if (Math.abs(distribution.reduce((sum, value) => sum + value, 0) - 1) >
146
+ tolerance)
147
+ ctx.addIssue({
148
+ code: "custom",
149
+ message: "Probabilities must sum to one.",
150
+ });
151
+ if (answer.kind === "score" &&
152
+ Math.abs(distribution.reduce((sum, value, index) => sum + index * value, 0) -
153
+ answer.mean) > tolerance)
154
+ ctx.addIssue({
155
+ code: "custom",
156
+ path: ["mean"],
157
+ message: "Mean must equal the expected zero-based level index.",
158
+ });
159
+ });
160
+ const resultFields = {
161
+ schemaVersion: z.literal(1),
162
+ requestId: requestIdSchema,
163
+ model: modelReferenceSchema.refine((model) => model.includes("@"), "Decisions require an immutable model revision."),
164
+ data: z.array(decisionAnswerSchema).min(1).max(255),
165
+ };
166
+ function uniqueAnswers(result, ctx) {
167
+ if (new Set(result.data.map((answer) => answer.id)).size !== result.data.length)
168
+ ctx.addIssue({
169
+ code: "custom",
170
+ path: ["data"],
171
+ message: "Answer IDs must be unique.",
172
+ });
173
+ }
174
+ export const decisionResultSchema = z
175
+ .object({ ...resultFields, usage: normalizedUsageReportSchema })
176
+ .strict()
177
+ .superRefine(uniqueAnswers)
178
+ .superRefine((result, ctx) => {
179
+ if (result.usage.requestId !== result.requestId ||
180
+ result.usage.resolvedModelReference !== result.model ||
181
+ result.usage.outcome !== "completed") {
182
+ ctx.addIssue({
183
+ code: "custom",
184
+ path: ["usage"],
185
+ message: "Decision usage must describe this completed request and immutable model.",
186
+ });
187
+ }
188
+ });
189
+ /**
190
+ * Kaana's typed decisions failure. `usage` is present only when the provider
191
+ * measured something before failing; its absence means nothing was measured,
192
+ * never that the cost was zero. A failure carrying usage did execute.
193
+ */
194
+ export const decisionFailureSchema = z
195
+ .object({
196
+ schemaVersion: z.literal(1),
197
+ requestId: requestIdSchema,
198
+ error: inferenceErrorSchema,
199
+ usage: normalizedUsageReportSchema.optional(),
200
+ })
201
+ .strict()
202
+ .superRefine((failure, ctx) => {
203
+ if (failure.error.requestId !== failure.requestId) {
204
+ ctx.addIssue({ code: "custom", path: ["error", "requestId"], message: "The error must answer this request." });
205
+ }
206
+ if (failure.usage !== undefined &&
207
+ (failure.usage.requestId !== failure.requestId || failure.usage.outcome === "completed")) {
208
+ ctx.addIssue({ code: "custom", path: ["usage"], message: "Failure usage must describe this request and an incomplete outcome." });
209
+ }
210
+ });
211
+ export const decisionSuccessSchema = z
212
+ .object({
213
+ ...resultFields,
214
+ usage: z.array(usageQuantitySchema),
215
+ routingPolicy: routingPolicyReferenceSchema,
216
+ })
217
+ .strict()
218
+ .superRefine(uniqueAnswers);
219
+ /** Bind output to the exact request. Provider order is not semantic. */
220
+ export function decisionAnswersMatch(input, answers) {
221
+ if (answers.length !== input.questions.length ||
222
+ new Set(answers.map((a) => a.id)).size !== answers.length)
223
+ return false;
224
+ return input.questions.every((question) => {
225
+ const answer = answers.find((candidate) => candidate.id === question.id);
226
+ if (!answer ||
227
+ !decisionAnswerSchema.safeParse(answer).success ||
228
+ answer.kind !== question.kind)
229
+ return false;
230
+ if (question.kind === "choice" && answer.kind === "choice") {
231
+ const index = question.options.indexOf(answer.reply);
232
+ return question.options.length === answer.probabilities.length && index >= 0 &&
233
+ answer.probabilities[index] + tolerance >= Math.max(...answer.probabilities);
234
+ }
235
+ if (question.kind === "score" && answer.kind === "score")
236
+ return question.levels.length === answer.distribution.length;
237
+ return question.kind === "noul";
238
+ });
239
+ }
@@ -23,6 +23,7 @@
23
23
  * Decided in: docs/adr/0010-public-api-compatibility.md,
24
24
  * docs/adr/0017-authorized-routes-in-the-envelope.md.
25
25
  */
26
+ import { decisionInputSchema } from './decisions.js';
26
27
  import { z } from "zod";
27
28
  import { inferenceAttributionSchema } from "./attribution.js";
28
29
  import { inferenceApiFormatSchema, inferenceModalitySchema, reasoningEffortSchema, } from "./catalogue.js";
@@ -195,6 +196,7 @@ export const inferenceMessageSchema = z
195
196
  * that both metering and provider translation depend on.
196
197
  */
197
198
  export const inferenceInputSchema = z.discriminatedUnion("format", [
199
+ z.object({ format: z.literal("decisions"), decisions: decisionInputSchema }).strict(),
198
200
  z
199
201
  .object({
200
202
  format: z.literal("messages"),
@@ -403,6 +405,17 @@ export const inferenceRequestSchema = z
403
405
  authorizedRoutes: z.array(authorizedRouteSchema).min(1).optional(),
404
406
  })
405
407
  .superRefine((request, ctx) => {
408
+ if (request.input.format === "decisions" || request.client.apiFormat === "decisions") {
409
+ const exactModel = request.target.kind === "model" && request.target.modelReference.includes('@');
410
+ const foreignRoute = request.authorizedRoutes?.some((route) => request.target.kind !== "model" || route.modelReference !== request.target.modelReference || route.substitution !== "same_model");
411
+ if (request.input.format !== "decisions" || request.client.apiFormat !== "decisions" ||
412
+ request.modality !== "text" || request.stream || !exactModel || foreignRoute ||
413
+ request.maxOutputTokens !== undefined || Object.keys(request.sampling).length > 0 ||
414
+ request.tools.length > 0 || request.toolChoice !== undefined || request.responseFormat !== undefined ||
415
+ request.reasoning !== undefined || request.speech !== undefined || request.audioOutput !== undefined) {
416
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["input"], message: "Decisions require exact-model typed nonstreaming input without generation controls or model substitution." });
417
+ }
418
+ }
406
419
  const isSpeech = request.client.apiFormat === "audio_speech";
407
420
  if (isSpeech && request.speech !== undefined && (request.modality !== "audio" || request.input.format !== "text" || request.stream)) {
408
421
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["speech"], message: "speech requires audio modality, text input, parameters and non-streaming output" });
@@ -99,4 +99,4 @@
99
99
  * change to, say, the catalogue reject every in-flight inference request; the
100
100
  * per-shape `schemaVersion` is what a message is validated against.
101
101
  */
102
- export const INFERENCE_CONTRACT_VERSION = '3.4.0';
102
+ export const INFERENCE_CONTRACT_VERSION = '3.5.0';