@oxyhq/contracts 0.28.0 → 0.30.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.
Files changed (43) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/identity.js +33 -1
  3. package/dist/cjs/index.js +20 -7
  4. package/dist/cjs/inference/aliaModelRelease.js +250 -0
  5. package/dist/cjs/inference/catalogue.js +1 -4
  6. package/dist/cjs/inference/errors.js +108 -9
  7. package/dist/cjs/inference/identifiers.js +23 -3
  8. package/dist/cjs/inference/request.js +188 -5
  9. package/dist/cjs/inference/routingPolicy.js +81 -2
  10. package/dist/cjs/inference/streamEvents.js +38 -1
  11. package/dist/cjs/inference/usage.js +32 -1
  12. package/dist/cjs/inference/version.js +24 -4
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/identity.js +32 -0
  15. package/dist/esm/index.js +8 -3
  16. package/dist/esm/inference/aliaModelRelease.js +247 -0
  17. package/dist/esm/inference/catalogue.js +2 -5
  18. package/dist/esm/inference/errors.js +108 -9
  19. package/dist/esm/inference/identifiers.js +22 -2
  20. package/dist/esm/inference/request.js +189 -6
  21. package/dist/esm/inference/routingPolicy.js +81 -2
  22. package/dist/esm/inference/streamEvents.js +38 -1
  23. package/dist/esm/inference/usage.js +32 -1
  24. package/dist/esm/inference/version.js +24 -4
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/identity.d.ts +59 -1
  27. package/dist/types/index.d.ts +7 -5
  28. package/dist/types/inference/accountBilling.d.ts +26 -26
  29. package/dist/types/inference/aliaModelRelease.d.ts +597 -0
  30. package/dist/types/inference/catalogue.d.ts +6 -6
  31. package/dist/types/inference/entitlement.d.ts +4 -4
  32. package/dist/types/inference/errors.d.ts +46 -10
  33. package/dist/types/inference/identifiers.d.ts +20 -2
  34. package/dist/types/inference/money.d.ts +4 -4
  35. package/dist/types/inference/priceVersion.d.ts +14 -14
  36. package/dist/types/inference/providerConnection.d.ts +4 -4
  37. package/dist/types/inference/request.d.ts +291 -5
  38. package/dist/types/inference/routingPolicy.d.ts +109 -13
  39. package/dist/types/inference/streamEvents.d.ts +84 -48
  40. package/dist/types/inference/usage.d.ts +103 -79
  41. package/dist/types/inference/version.d.ts +24 -4
  42. package/dist/types/keyRecovery.d.ts +4 -4
  43. package/package.json +1 -1
@@ -11,17 +11,23 @@
11
11
  *
12
12
  * What the envelope carries that a provider request does not: the resolved
13
13
  * attribution block (who pays, which application, which credential, which
14
- * delegated user), the exact routing policy reference and the customer's
15
- * idempotency key. Those are the fields that make a request billable and
16
- * explainable, and they are resolved BEFORE the request enters the data plane.
14
+ * delegated user), the exact routing policy reference, the routes that policy
15
+ * has already authorized, and the customer's idempotency key. Those are the
16
+ * fields that make a request billable and explainable, and they are resolved
17
+ * BEFORE the request enters the data plane.
17
18
  *
18
- * Decided in: docs/adr/0010-public-api-compatibility.md.
19
+ * The policy VALUES never travel. `routingPolicy` is a reference — provenance
20
+ * for the receipt — and `authorizedRoutes` is the result of applying the policy,
21
+ * in preference order, so the data plane needs no policy semantics to fail over.
22
+ *
23
+ * Decided in: docs/adr/0010-public-api-compatibility.md,
24
+ * docs/adr/0017-authorized-routes-in-the-envelope.md.
19
25
  */
20
26
  import { z } from 'zod';
21
27
  import { inferenceAttributionSchema } from './attribution.js';
22
28
  import { inferenceModalitySchema } from './catalogue.js';
23
29
  import { idempotencyKeySchema, inferenceTimestampSchema } from './identifiers.js';
24
- import { routingPolicyReferenceSchema, routingTargetSchema } from './routingPolicy.js';
30
+ import { authorizedRouteSchema, routingPolicyReferenceSchema, routingTargetSchema, } from './routingPolicy.js';
25
31
  /* -------------------------------------------------------------------------- */
26
32
  /* Input */
27
33
  /* -------------------------------------------------------------------------- */
@@ -42,7 +48,38 @@ export const inferenceContentSourceSchema = z.discriminatedUnion('kind', [
42
48
  })
43
49
  .strict(),
44
50
  ]);
45
- /** One part of a message's content. A message is always a list of parts. */
51
+ /**
52
+ * One part of a message's content. A message is always a list of parts.
53
+ *
54
+ * ## `refusal` is a member and `reasoning` is not, and the asymmetry is the point
55
+ *
56
+ * A model that DECLINES says why, and those words are meant for the customer:
57
+ * they are the difference between "rephrase this" and "stop asking". Before this
58
+ * member existed there was nowhere in an {@link InferenceMessage} to put them, so
59
+ * a non-streaming fold kept `finishReason: 'refusal'` and dropped the sentence —
60
+ * the customer learned they were refused and not why, while a streaming caller of
61
+ * the same request got the whole explanation on the `refusal` delta channel.
62
+ *
63
+ * Reasoning is the opposite case and stays absent. It is the model's private
64
+ * working, and a `text` part is where somebody would put it — which renders
65
+ * private reasoning to the customer AS the answer, the product bug the delta
66
+ * channels exist to prevent. An opaque per-block reasoning blob crossing this
67
+ * boundary needs a home nobody has chosen yet, and inventing one here would
68
+ * choose it by accident.
69
+ *
70
+ * Both public dialects can carry a refusal, which is what separates the two
71
+ * cases at the boundary as well as in principle: `refusal` is OpenAI's OWN field
72
+ * in both of its shapes (`delta.refusal` streaming, `message.refusal`
73
+ * non-streaming), while reasoning has no OpenAI field at all — the
74
+ * `reasoning_content`/`reasoning` spellings an OpenAI-compatible provider emits
75
+ * are provider extensions. So carrying the refusal costs no dialect its
76
+ * standard-client parseability, and carrying reasoning would.
77
+ *
78
+ * The part is a MEMBER rather than a field because a refusal need not have text:
79
+ * an Anthropic `stop_reason: "refusal"` maps to the finish reason and separates
80
+ * no words from the answer, while an OpenAI-compatible `refusal` does. A required
81
+ * field would force the first provider to invent a sentence.
82
+ */
46
83
  export const inferenceContentPartSchema = z.discriminatedUnion('type', [
47
84
  z.object({ type: z.literal('text'), text: z.string() }).strict(),
48
85
  z
@@ -61,6 +98,11 @@ export const inferenceContentPartSchema = z.discriminatedUnion('type', [
61
98
  filename: z.string().max(255).optional(),
62
99
  })
63
100
  .strict(),
101
+ /**
102
+ * A model's explanation for declining. Its own part, never a `text` one, so no
103
+ * renderer can present a refusal as the answer that was asked for.
104
+ */
105
+ z.object({ type: z.literal('refusal'), text: z.string() }).strict(),
64
106
  ]);
65
107
  /**
66
108
  * A tool call an assistant made, in the normalized form.
@@ -125,6 +167,20 @@ export const inferenceMessageSchema = z
125
167
  message: 'only an assistant message makes tool calls',
126
168
  });
127
169
  }
170
+ // Same rule as `toolCalls`, for the same reason: only the assistant can
171
+ // decline, so a refusal on any other role is a part every provider would
172
+ // silently ignore — and one a renderer might not.
173
+ if (message.role !== 'assistant') {
174
+ for (const [index, part] of message.content.entries()) {
175
+ if (part.type === 'refusal') {
176
+ ctx.addIssue({
177
+ code: z.ZodIssueCode.custom,
178
+ path: ['content', index, 'type'],
179
+ message: 'only an assistant message carries a refusal',
180
+ });
181
+ }
182
+ }
183
+ }
128
184
  });
129
185
  /**
130
186
  * The request's input.
@@ -238,6 +294,17 @@ export const clientRequestMetadataSchema = z
238
294
  /* -------------------------------------------------------------------------- */
239
295
  /* The envelope */
240
296
  /* -------------------------------------------------------------------------- */
297
+ /**
298
+ * The model LINE a reference names, with any pinned revision dropped.
299
+ *
300
+ * Substitution is a question about the line — `anthropic/claude-opus-5` — never
301
+ * about the revision, so the comparisons below have to be made on it. Same split
302
+ * `resolveEdgeRoute` makes on the way in.
303
+ */
304
+ const modelLineOf = (reference) => {
305
+ const at = reference.indexOf('@');
306
+ return at === -1 ? reference : reference.slice(0, at);
307
+ };
241
308
  /**
242
309
  * The canonical internal request Oxy forwards to the data plane.
243
310
  *
@@ -265,6 +332,38 @@ export const inferenceRequestSchema = z
265
332
  idempotencyKey: idempotencyKeySchema.optional(),
266
333
  /** The exact policy revision this request is served under. */
267
334
  routingPolicy: routingPolicyReferenceSchema,
335
+ /**
336
+ * The routes the control plane has already authorized for this request, in
337
+ * PREFERENCE ORDER. The first entry is the primary route Oxy resolved; the
338
+ * data plane fails over by taking the next one.
339
+ *
340
+ * This is what closes the gap ADR 0010's amendment left open. That amendment
341
+ * assigns the data plane "failover within the destinations the policy
342
+ * authorized" — and the envelope named no destinations, so a data plane could
343
+ * only fail over by re-deriving the customer's policy from values it does not
344
+ * have. Enumerating the survivors instead means a route switch outside the
345
+ * policy is impossible BY CONSTRUCTION rather than by two enforcement engines
346
+ * agreeing in two languages.
347
+ *
348
+ * **Absent means no failover is authorized, never "choose freely."** It is
349
+ * the state every envelope built before this field existed is in, and the
350
+ * behaviour a data plane that reads no list must already have: resolve the
351
+ * `target` and serve it or fail. Permission is granted by an ENTRY, so its
352
+ * absence can only ever narrow, and there is no reading of an absent list
353
+ * that widens what may be served.
354
+ *
355
+ * An EMPTY list is refused rather than treated as that state. `[]` would say
356
+ * "no route is authorized at all", which contradicts an envelope that was
357
+ * built to be served, and it is exactly the "permission granted, destination
358
+ * unnamed" shape `authorizedRouteSchema` exists to make unrepresentable.
359
+ *
360
+ * No price rides here. Oxy sized the hold against the most expensive route
361
+ * the policy permits (`usageReservationRequestSchema.ceilingPriceVersionId`),
362
+ * and every entry is one that policy permitted, so no failover among them can
363
+ * exceed it. A per-route price would also be a second authority for ranking,
364
+ * beside the order this list already carries.
365
+ */
366
+ authorizedRoutes: z.array(authorizedRouteSchema).min(1).optional(),
268
367
  })
269
368
  .superRefine((request, ctx) => {
270
369
  if (request.toolChoice !== undefined && request.tools.length === 0) {
@@ -282,4 +381,88 @@ export const inferenceRequestSchema = z
282
381
  message: 'tool names must be unique within one request',
283
382
  });
284
383
  }
384
+ const routes = request.authorizedRoutes;
385
+ // The emptiness is RE-CHECKED rather than assumed away by `.min(1)`. A
386
+ // failed `.min()` marks the parse dirty rather than aborting it, so zod runs
387
+ // this refinement with the empty array still in hand; `[]` is already
388
+ // refused above, and reading `routes[0]` here would throw instead.
389
+ if (routes === undefined || routes.length === 0)
390
+ return;
391
+ const primary = routes[0];
392
+ // The primary is not a substitution for itself, and every `substitution`
393
+ // value is read RELATIVE to it. A list whose first entry claims to be a
394
+ // cross-model substitute names no original to have substituted for.
395
+ if (primary.substitution !== 'same_model') {
396
+ ctx.addIssue({
397
+ code: z.ZodIssueCode.custom,
398
+ path: ['authorizedRoutes', 0, 'substitution'],
399
+ message: 'the first authorized route is the primary and cannot be a substitution',
400
+ });
401
+ }
402
+ const primaryLine = modelLineOf(primary.modelReference);
403
+ // A request that named a concrete model is served or refused, never
404
+ // substituted, and one that PINNED a revision is served on exactly those
405
+ // weights. Both checks are on the primary, because a primary that already
406
+ // drifted makes every entry after it a substitution nobody labelled.
407
+ if (request.target.kind === 'model') {
408
+ const targetReference = request.target.modelReference;
409
+ const targetIsPinned = targetReference.includes('@');
410
+ if (targetIsPinned && primary.modelReference !== targetReference) {
411
+ ctx.addIssue({
412
+ code: z.ZodIssueCode.custom,
413
+ path: ['authorizedRoutes', 0, 'modelReference'],
414
+ message: 'a pinned request is served on exactly the revision it pinned',
415
+ });
416
+ }
417
+ if (!targetIsPinned && primaryLine !== targetReference) {
418
+ ctx.addIssue({
419
+ code: z.ZodIssueCode.custom,
420
+ path: ['authorizedRoutes', 0, 'modelReference'],
421
+ message: 'the primary authorized route must serve the model the request named',
422
+ });
423
+ }
424
+ if (targetIsPinned) {
425
+ for (const [index, route] of routes.entries()) {
426
+ if (route.substitution === 'cross_model') {
427
+ ctx.addIssue({
428
+ code: z.ZodIssueCode.custom,
429
+ path: ['authorizedRoutes', index, 'substitution'],
430
+ message: 'a request that pinned a revision authorizes no cross-model substitute',
431
+ });
432
+ }
433
+ }
434
+ }
435
+ }
436
+ for (const [index, route] of routes.entries()) {
437
+ const line = modelLineOf(route.modelReference);
438
+ // A mislabelled entry is the whole failure mode: `same_model` on a
439
+ // different model line is a substitution wearing the label that needs no
440
+ // authorization, and `cross_model` on the same line claims an
441
+ // authorization the customer never had to give.
442
+ if (route.substitution === 'same_model' && line !== primaryLine) {
443
+ ctx.addIssue({
444
+ code: z.ZodIssueCode.custom,
445
+ path: ['authorizedRoutes', index, 'substitution'],
446
+ message: `route ${index} serves ${line}, not ${primaryLine}, so it is a cross-model substitute`,
447
+ });
448
+ }
449
+ if (route.substitution === 'cross_model' && line === primaryLine) {
450
+ ctx.addIssue({
451
+ code: z.ZodIssueCode.custom,
452
+ path: ['authorizedRoutes', index, 'substitution'],
453
+ message: `route ${index} serves ${primaryLine}, so it is same-model failover`,
454
+ });
455
+ }
456
+ }
457
+ // Failing over to the deployment that just failed is not failover. The
458
+ // duplicate would also make `routeSwitches` count a switch that changed
459
+ // nothing.
460
+ const deployments = routes.map((route) => route.deploymentId);
461
+ if (new Set(deployments).size !== deployments.length) {
462
+ ctx.addIssue({
463
+ code: z.ZodIssueCode.custom,
464
+ path: ['authorizedRoutes'],
465
+ message: 'each deployment appears at most once in the authorized route list',
466
+ });
467
+ }
285
468
  });
@@ -22,10 +22,17 @@
22
22
  * REJECTED, rather than being quietly resolved by whichever field the executor
23
23
  * happens to read first. That rejection is `routingPolicySchema`'s refinement.
24
24
  *
25
- * Decided in: docs/adr/0008-catalogue-concept-separation.md, issue #972 workstream 6.
25
+ * **The policy itself never crosses to the data plane.** What crosses is
26
+ * {@link authorizedRouteSchema} — the candidate routes that SURVIVED these
27
+ * controls, in preference order — plus {@link routingPolicyReferenceSchema} as
28
+ * provenance for the receipt. The data plane holds no control value and needs
29
+ * none: it fails over by taking the next entry. See ADR 0017.
30
+ *
31
+ * Decided in: docs/adr/0008-catalogue-concept-separation.md,
32
+ * docs/adr/0017-authorized-routes-in-the-envelope.md, issue #972 workstream 6.
26
33
  */
27
34
  import { z } from 'zod';
28
- import { inferenceProviderSlugSchema, inferenceRegionSchema, inferenceTimestampSchema, modelReferenceSchema, oxyAccountIdSchema, oxyApplicationIdSchema, routingProfileSlugSchema, } from './identifiers.js';
35
+ import { deploymentIdSchema, inferenceProviderSlugSchema, inferenceRegionSchema, inferenceTimestampSchema, modelReferenceSchema, oxyAccountIdSchema, oxyApplicationIdSchema, routingProfileSlugSchema, } from './identifiers.js';
29
36
  import { exactDecimalSchema, currencyCodeSchema, unitPriceSchema } from './money.js';
30
37
  /**
31
38
  * What a policy resolves to when a caller names no model.
@@ -208,3 +215,75 @@ export const routingPolicyReferenceSchema = z
208
215
  policyVersion: z.number().int().positive().safe(),
209
216
  })
210
217
  .strict();
218
+ /* -------------------------------------------------------------------------- */
219
+ /* Pre-authorized routes */
220
+ /* -------------------------------------------------------------------------- */
221
+ /**
222
+ * What every authorized route carries, whichever kind it is.
223
+ *
224
+ * Exactly what EXECUTING a route needs, and nothing a policy could be
225
+ * re-derived from. There is no price, no data-retention flag, no licence id and
226
+ * no availability scope here: those are the values the control plane already
227
+ * evaluated to put this entry in the list, and repeating them would invite the
228
+ * data plane to evaluate them a second time — differently, in another language.
229
+ *
230
+ * `regions` is plural and matches `modelDeploymentSchema.regions`, because a
231
+ * deployment declares every region it MAY serve from and choosing among them is
232
+ * routing execution (ADR 0006). Oxy checked the whole set against the customer's
233
+ * residency controls as a SUBSET, so any region in this list is one the policy
234
+ * permits and the data plane's choice among them cannot escape it. Collapsing it
235
+ * to one region would make Oxy take a decision the boundary assigns elsewhere.
236
+ */
237
+ const authorizedRouteFields = {
238
+ /** Which concrete endpoint. Opaque to customers; the data plane's own key. */
239
+ deploymentId: deploymentIdSchema,
240
+ /** Always revision-pinned: the entry names the exact weights to serve. */
241
+ modelReference: modelReferenceSchema,
242
+ provider: inferenceProviderSlugSchema,
243
+ regions: z.array(inferenceRegionSchema).min(1),
244
+ };
245
+ /**
246
+ * One route the control plane has already authorized for one request.
247
+ *
248
+ * **Authorization is an ENTRY, never a boolean.** The same stance
249
+ * `inference_routing_policy_fallbacks` takes in storage: being allowed to serve
250
+ * a route IS appearing here, and every entry names its destination. A flag
251
+ * saying "substitution allowed" without naming the destination is exactly the
252
+ * silent substitution the platform forbids, and it invents a "flag set, list
253
+ * empty" state somebody then has to decide what to do with.
254
+ *
255
+ * Discriminated on `substitution`, relative to the FIRST entry — the primary
256
+ * route Oxy resolved:
257
+ *
258
+ * - `same_model` serves the same model line as the primary. This is
259
+ * availability failover between deployments of one model.
260
+ * - `cross_model` serves a DIFFERENT model line, and is expressible only with
261
+ * `authorizedByPolicy: true` as a literal. So "a model was substituted
262
+ * without the customer authorizing it" is not a sentence this contract can
263
+ * say — the same construction `inferenceRouteSwitchDetailSchema` uses to make
264
+ * the resulting route-switch event unreportable.
265
+ */
266
+ export const authorizedRouteSchema = z
267
+ .discriminatedUnion('substitution', [
268
+ z.object({ substitution: z.literal('same_model'), ...authorizedRouteFields }).strict(),
269
+ z
270
+ .object({
271
+ substitution: z.literal('cross_model'),
272
+ ...authorizedRouteFields,
273
+ /** Literal `true`: an unauthorized substitution cannot be expressed. */
274
+ authorizedByPolicy: z.literal(true),
275
+ })
276
+ .strict(),
277
+ ])
278
+ .superRefine((route, ctx) => {
279
+ // Same rule as `modelDeploymentSchema`: a route serves specific weights. An
280
+ // unpinned entry would leave the data plane choosing a revision, which is
281
+ // the one substitution the customer never authorized by naming a model.
282
+ if (!route.modelReference.includes('@')) {
283
+ ctx.addIssue({
284
+ code: z.ZodIssueCode.custom,
285
+ path: ['modelReference'],
286
+ message: 'an authorized route must pin an immutable revision (<publisher>/<model>@<revision>)',
287
+ });
288
+ }
289
+ });
@@ -87,6 +87,33 @@ export const inferenceStreamToolCallEventSchema = z.object({
87
87
  * derived from these units and a price version at settlement; a cost quoted by
88
88
  * the data plane would be a second, unauthoritative answer to the same
89
89
  * question.
90
+ *
91
+ * ## This event is MEASUREMENT EVIDENCE, never a settleable record
92
+ *
93
+ * `normalizedUsageReportSchema` is the only shape a settlement is written from,
94
+ * and this event is deliberately NOT a subset of it that could be widened into
95
+ * one. It carries units and a source; a report additionally carries the
96
+ * attribution block, the outcome, the resolved route, the route-switch count and
97
+ * the two timestamps. Every one of those is knowable only by one END of the
98
+ * request rather than by the frame: the outcome only by the edge, which is the
99
+ * only party that knows whether the CLIENT cancelled, and the route record only
100
+ * by the data plane.
101
+ *
102
+ * So the event is not widened, and that is a decision rather than an omission.
103
+ * Adding the route record and the attribution block here would repeat both on
104
+ * EVERY usage frame of every stream, and each repetition is one more place two
105
+ * frames of one stream could disagree about which route served it — a second
106
+ * source of truth per frame, for fields no consumer of a progress signal reads.
107
+ *
108
+ * **What the edge may do with it, and what it may not.** The units are exact and
109
+ * may be settled; the record around them may not be inferred. When no terminal
110
+ * report arrives — the ordinary case for a client disconnect, since the report
111
+ * frame can no longer be delivered to a connection that is gone — the edge
112
+ * settles the units from the last such event and takes the OUTCOME from itself,
113
+ * never from the event. Its outcome is then `cancelled`, `partial` or `failed`;
114
+ * it is never `completed`, because nothing here can witness that the customer
115
+ * received the whole answer. Zero units marked `estimated` is the arm for no
116
+ * evidence at ALL, not for a disconnect that reported some.
90
117
  */
91
118
  export const inferenceStreamUsageEventSchema = z.object({
92
119
  /** See `version.ts`: each stream event is a whole message on the wire. */
@@ -174,12 +201,22 @@ export const inferenceStreamErrorEventSchema = z.object({
174
201
  /** Carries its own `schemaVersion`: the same body is returned non-streaming. */
175
202
  error: inferenceErrorSchema,
176
203
  });
177
- /** Why generation stopped. */
204
+ /**
205
+ * Why generation stopped.
206
+ *
207
+ * `refusal` and `content_filter` are separate members because they are separate
208
+ * events: the MODEL declining to answer is a property of the answer, while a
209
+ * filter is an upstream system removing one. The delta channels already carry
210
+ * that distinction (`channel: 'refusal'` beside the filter's own error code),
211
+ * so collapsing it here would have made the terminal event less specific than
212
+ * the stream that produced it.
213
+ */
178
214
  export const inferenceFinishReasonSchema = z.enum([
179
215
  'stop',
180
216
  'length',
181
217
  'tool_calls',
182
218
  'content_filter',
219
+ 'refusal',
183
220
  'cancelled',
184
221
  ]);
185
222
  /**
@@ -118,7 +118,13 @@ export const inferenceRequestOutcomeSchema = z.enum([
118
118
  'failed',
119
119
  ]);
120
120
  /**
121
- * The data plane's technical account of one request.
121
+ * The data plane's technical account of one request, and the ONLY shape a
122
+ * settlement is written from.
123
+ *
124
+ * `inferenceStreamUsageEventSchema` is not a narrower version of this one that
125
+ * could be widened into it — see that event's own comment. Its units are exact
126
+ * and settleable; the record around them is not inferable, so an edge settling
127
+ * from a stream event supplies the outcome itself and never promotes the event.
122
128
  *
123
129
  * No money and no price: the data plane measures units and names the route it
124
130
  * used, and the control plane decides what that costs. Keeping the two apart is
@@ -134,6 +140,24 @@ export const inferenceRequestOutcomeSchema = z.enum([
134
140
  * nested `prompt_tokens`/`completion_tokens` verbatim charges the cached and
135
141
  * reasoning tokens twice, so subtracting the children out is part of what
136
142
  * "normalized" means in this shape's name.
143
+ *
144
+ * **A `completed` report carries at least one unit, and the other outcomes need
145
+ * not.** `completed` is the one outcome that asserts the customer received the
146
+ * whole answer, so "delivered in full, consumed nothing measurable" is a
147
+ * contradiction — and it is one that BILLS NOTHING: settlement prices every
148
+ * reported unit and sums, so an empty list is a free request produced by a
149
+ * provider that simply omitted its usage block. The refinement makes that shape
150
+ * unparseable, and the policy behind it is refuse-and-release: the report is
151
+ * rejected and the hold is released, never estimated and charged.
152
+ *
153
+ * The three other outcomes legitimately carry none, which is why the rule is
154
+ * conditional rather than a `.min(1)` on the field. In the reference data plane
155
+ * `failed` is DERIVED from having no units — `outcomeFor` in
156
+ * `internal/relay/executor.go` returns `partial` when units exist and `failed`
157
+ * when they do not — and `cancelled` is reported for a client that stopped
158
+ * before anything was measured. An unconditional minimum would refuse those
159
+ * reports, and a refused report is a request that ran, cost money upstream and
160
+ * can never be settled or refunded.
137
161
  */
138
162
  export const normalizedUsageReportSchema = z
139
163
  .object({
@@ -171,6 +195,13 @@ export const normalizedUsageReportSchema = z
171
195
  message: 'each unit is reported once, as a total',
172
196
  });
173
197
  }
198
+ if (report.outcome === 'completed' && report.units.length === 0) {
199
+ ctx.addIssue({
200
+ code: z.ZodIssueCode.custom,
201
+ path: ['units'],
202
+ message: 'a completed request consumed something; report at least one unit',
203
+ });
204
+ }
174
205
  });
175
206
  /* -------------------------------------------------------------------------- */
176
207
  /* 3. Receipt (settlement) */
@@ -62,7 +62,20 @@
62
62
  * escapes, and the billing and entitlement records, where one is a second
63
63
  * number beside an exact amount.
64
64
  *
65
- * Decided in: docs/adr/0006-oxy-relay-boundary.md, docs/adr/0010-public-api-compatibility.md.
65
+ * A SIGNED document is strict at its top level for a third reason, and there it
66
+ * is forced rather than chosen. `aliaModelReleaseManifestSchema` carries
67
+ * signatures over its own canonical bytes, so a field stripped at this parse is a
68
+ * field missing from the bytes a verifier re-canonicalizes: a tolerant parse
69
+ * would report an invalid SIGNATURE where the truth is that this build does not
70
+ * understand the DOCUMENT. Its producer can run ahead of this package — Alia's
71
+ * release tooling is deployed independently — so the usual argument applies here
72
+ * and is outweighed, because the refusal costs an operator one retry after Oxy
73
+ * takes the newer contract, while the tolerant parse costs a misdiagnosis of a
74
+ * cryptographic failure.
75
+ *
76
+ * Decided in: docs/adr/0006-oxy-relay-boundary.md,
77
+ * docs/adr/0010-public-api-compatibility.md,
78
+ * docs/adr/0017-authorized-routes-in-the-envelope.md.
66
79
  */
67
80
  /**
68
81
  * Version of the contract SET as a whole — the value the control plane and the
@@ -71,12 +84,19 @@
71
84
  *
72
85
  * MAJOR is bumped when any individual shape's `schemaVersion` increments (at
73
86
  * least one message is now read differently by the two sides); MINOR when a
74
- * shape or an optional field is added; PATCH for documentation-only changes
75
- * that leave every parsed byte identical.
87
+ * shape or an optional field is added, when a CLOSED ENUM gains a member, or
88
+ * when a refinement changes which bytes parse; PATCH for documentation-only
89
+ * changes that leave every parsed byte identical.
90
+ *
91
+ * The last two are MINOR rather than PATCH because both produce the same
92
+ * failure: a producer on the newer set emits something the older set refuses,
93
+ * with no `schemaVersion` difference to explain it. A new enum member and a
94
+ * loosened refinement are exactly what the handshake exists to surface — a
95
+ * skew the per-message version cannot express.
76
96
  *
77
97
  * This constant is deliberately NOT embedded in the request envelope. Pinning a
78
98
  * request to the version of the whole set would make an unrelated additive
79
99
  * change to, say, the catalogue reject every in-flight inference request; the
80
100
  * per-shape `schemaVersion` is what a message is validated against.
81
101
  */
82
- export const INFERENCE_CONTRACT_VERSION = '1.0.0';
102
+ export const INFERENCE_CONTRACT_VERSION = '1.2.0';