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