@oxyhq/contracts 0.26.0 → 0.28.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 (52) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/accountGraph.js +4 -3
  3. package/dist/cjs/index.js +186 -1
  4. package/dist/cjs/inference/accountBilling.js +334 -0
  5. package/dist/cjs/inference/attribution.js +106 -0
  6. package/dist/cjs/inference/catalogue.js +482 -0
  7. package/dist/cjs/inference/entitlement.js +217 -0
  8. package/dist/cjs/inference/errors.js +210 -0
  9. package/dist/cjs/inference/identifiers.js +197 -0
  10. package/dist/cjs/inference/money.js +188 -0
  11. package/dist/cjs/inference/priceVersion.js +110 -0
  12. package/dist/cjs/inference/providerConnection.js +142 -0
  13. package/dist/cjs/inference/request.js +288 -0
  14. package/dist/cjs/inference/routingPolicy.js +213 -0
  15. package/dist/cjs/inference/streamEvents.js +219 -0
  16. package/dist/cjs/inference/usage.js +297 -0
  17. package/dist/cjs/inference/version.js +85 -0
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/accountGraph.js +4 -3
  20. package/dist/esm/index.js +54 -0
  21. package/dist/esm/inference/accountBilling.js +331 -0
  22. package/dist/esm/inference/attribution.js +103 -0
  23. package/dist/esm/inference/catalogue.js +479 -0
  24. package/dist/esm/inference/entitlement.js +214 -0
  25. package/dist/esm/inference/errors.js +207 -0
  26. package/dist/esm/inference/identifiers.js +194 -0
  27. package/dist/esm/inference/money.js +185 -0
  28. package/dist/esm/inference/priceVersion.js +107 -0
  29. package/dist/esm/inference/providerConnection.js +139 -0
  30. package/dist/esm/inference/request.js +285 -0
  31. package/dist/esm/inference/routingPolicy.js +210 -0
  32. package/dist/esm/inference/streamEvents.js +216 -0
  33. package/dist/esm/inference/usage.js +294 -0
  34. package/dist/esm/inference/version.js +82 -0
  35. package/dist/types/.tsbuildinfo +1 -1
  36. package/dist/types/accountGraph.d.ts +6 -5
  37. package/dist/types/index.d.ts +27 -0
  38. package/dist/types/inference/accountBilling.d.ts +738 -0
  39. package/dist/types/inference/attribution.d.ts +176 -0
  40. package/dist/types/inference/catalogue.d.ts +1612 -0
  41. package/dist/types/inference/entitlement.d.ts +519 -0
  42. package/dist/types/inference/errors.d.ts +206 -0
  43. package/dist/types/inference/identifiers.d.ts +157 -0
  44. package/dist/types/inference/money.d.ts +185 -0
  45. package/dist/types/inference/priceVersion.d.ts +182 -0
  46. package/dist/types/inference/providerConnection.d.ts +297 -0
  47. package/dist/types/inference/request.d.ts +2364 -0
  48. package/dist/types/inference/routingPolicy.d.ts +426 -0
  49. package/dist/types/inference/streamEvents.d.ts +906 -0
  50. package/dist/types/inference/usage.d.ts +1139 -0
  51. package/dist/types/inference/version.d.ts +82 -0
  52. package/package.json +1 -1
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ /**
3
+ * Normalized stream events — what the data plane emits and the Oxy edge
4
+ * forwards as SSE.
5
+ *
6
+ * One discriminated union, seven shapes, all carrying `requestId` and a
7
+ * monotonic `sequence`. `requestId` is on EVERY event rather than only the
8
+ * first because a proxy that re-frames or a client that reconnects would
9
+ * otherwise be holding events it cannot attribute; `sequence` is what makes a
10
+ * redelivered event detectable as a duplicate rather than as new output.
11
+ *
12
+ * The events are versioned INDIVIDUALLY (see `version.ts`): a stream is a long
13
+ * sequence of small messages from a producer that may be redeployed mid-stream,
14
+ * so the alternative — one version for the whole union — would force every
15
+ * event to move whenever any one of them changed.
16
+ *
17
+ * `route_switch` is the customer-visible receipt for an allowed re-route, and
18
+ * its shape carries the invariant: a switch to a DIFFERENT model can only be
19
+ * expressed with `authorizedByPolicy: true`, so an unauthorized substitution is
20
+ * not a thing the contract can say.
21
+ *
22
+ * Decided in: docs/adr/0010-public-api-compatibility.md, docs/adr/0008-catalogue-concept-separation.md.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.inferenceStreamEventSchema = exports.inferenceStreamDoneEventSchema = exports.inferenceFinishReasonSchema = exports.inferenceStreamErrorEventSchema = exports.inferenceStreamRouteSwitchEventSchema = exports.inferenceRouteSwitchReasonSchema = exports.inferenceRouteSwitchDetailSchema = exports.inferenceStreamUsageEventSchema = exports.inferenceStreamToolCallEventSchema = exports.inferenceStreamDeltaEventSchema = exports.inferenceStreamStartEventSchema = void 0;
26
+ const zod_1 = require("zod");
27
+ const identifiers_1 = require("./identifiers");
28
+ const errors_1 = require("./errors");
29
+ const money_1 = require("./money");
30
+ /**
31
+ * The first event of every stream: what was actually resolved.
32
+ *
33
+ * Names the revision-pinned model and the serving provider, so a customer who
34
+ * asked for `<publisher>/<model>` learns which revision answered without having
35
+ * to wait for the receipt. Carries no deployment health, no route id and no
36
+ * upstream cost.
37
+ */
38
+ exports.inferenceStreamStartEventSchema = zod_1.z.object({
39
+ /** See `version.ts`: each stream event is a whole message on the wire. */
40
+ schemaVersion: zod_1.z.literal(1),
41
+ type: zod_1.z.literal('start'),
42
+ requestId: identifiers_1.requestIdSchema,
43
+ sequence: zod_1.z.number().int().nonnegative().safe(),
44
+ generationId: identifiers_1.generationIdSchema.optional(),
45
+ /** Always revision-pinned, even when the request named only the model. */
46
+ resolvedModelReference: identifiers_1.modelReferenceSchema,
47
+ servingProvider: identifiers_1.inferenceProviderSlugSchema,
48
+ startedAt: identifiers_1.inferenceTimestampSchema,
49
+ });
50
+ /**
51
+ * A chunk of output.
52
+ *
53
+ * `channel` separates visible output from reasoning and refusals, because a
54
+ * client that renders reasoning as answer text is a product bug, not a display
55
+ * preference.
56
+ */
57
+ exports.inferenceStreamDeltaEventSchema = zod_1.z.object({
58
+ /** See `version.ts`: each stream event is a whole message on the wire. */
59
+ schemaVersion: zod_1.z.literal(1),
60
+ type: zod_1.z.literal('delta'),
61
+ requestId: identifiers_1.requestIdSchema,
62
+ sequence: zod_1.z.number().int().nonnegative().safe(),
63
+ /** Which output of a multi-output response this chunk belongs to. */
64
+ outputIndex: zod_1.z.number().int().nonnegative().safe(),
65
+ channel: zod_1.z.enum(['output_text', 'reasoning', 'refusal']),
66
+ text: zod_1.z.string(),
67
+ });
68
+ /**
69
+ * A tool call being streamed.
70
+ *
71
+ * `argumentsDelta` accumulates; `complete` marks the call finished so a client
72
+ * knows when the accumulated JSON text is worth parsing.
73
+ */
74
+ exports.inferenceStreamToolCallEventSchema = zod_1.z.object({
75
+ /** See `version.ts`: each stream event is a whole message on the wire. */
76
+ schemaVersion: zod_1.z.literal(1),
77
+ type: zod_1.z.literal('tool_call'),
78
+ requestId: identifiers_1.requestIdSchema,
79
+ sequence: zod_1.z.number().int().nonnegative().safe(),
80
+ toolCallId: zod_1.z.string().min(1).max(128),
81
+ /** Present on the first event of a call. */
82
+ name: zod_1.z.string().min(1).max(128).optional(),
83
+ argumentsDelta: zod_1.z.string().optional(),
84
+ complete: zod_1.z.boolean(),
85
+ });
86
+ /**
87
+ * Metered units for the request so far.
88
+ *
89
+ * Units only — no money. What a customer is charged is the ledger's answer,
90
+ * derived from these units and a price version at settlement; a cost quoted by
91
+ * the data plane would be a second, unauthoritative answer to the same
92
+ * question.
93
+ */
94
+ exports.inferenceStreamUsageEventSchema = zod_1.z.object({
95
+ /** See `version.ts`: each stream event is a whole message on the wire. */
96
+ schemaVersion: zod_1.z.literal(1),
97
+ type: zod_1.z.literal('usage'),
98
+ requestId: identifiers_1.requestIdSchema,
99
+ sequence: zod_1.z.number().int().nonnegative().safe(),
100
+ units: zod_1.z.array(money_1.usageQuantitySchema).min(1),
101
+ usageSource: money_1.usageSourceSchema,
102
+ });
103
+ /**
104
+ * What kind of re-route happened.
105
+ *
106
+ * `deployment` is same-model failover: the same revision, served somewhere else.
107
+ * `model` is a substitution, and is expressible ONLY with
108
+ * `authorizedByPolicy: true` — the routing policy's `authorizedCrossModel` list
109
+ * is the only thing that can produce one, so "a concrete model was silently
110
+ * replaced" has no representation in this contract.
111
+ */
112
+ exports.inferenceRouteSwitchDetailSchema = zod_1.z.discriminatedUnion('scope', [
113
+ zod_1.z
114
+ .object({
115
+ scope: zod_1.z.literal('deployment'),
116
+ modelReference: identifiers_1.modelReferenceSchema,
117
+ toProvider: identifiers_1.inferenceProviderSlugSchema,
118
+ toDeploymentId: identifiers_1.deploymentIdSchema.optional(),
119
+ })
120
+ .strict(),
121
+ zod_1.z
122
+ .object({
123
+ scope: zod_1.z.literal('model'),
124
+ /**
125
+ * The UNPINNED model line the customer asked for (`<publisher>/<model>`,
126
+ * never `@revision`). A request that pinned a revision asked for exactly
127
+ * those weights and is served or refused, never substituted — so for such
128
+ * a request there is no value that satisfies this field, and the event
129
+ * cannot be constructed at all.
130
+ */
131
+ requestedModelId: identifiers_1.modelIdSchema,
132
+ fromModelReference: identifiers_1.modelReferenceSchema,
133
+ toModelReference: identifiers_1.modelReferenceSchema,
134
+ toProvider: identifiers_1.inferenceProviderSlugSchema,
135
+ /** Literal `true`: an unauthorized cross-model switch cannot be reported. */
136
+ authorizedByPolicy: zod_1.z.literal(true),
137
+ })
138
+ .strict(),
139
+ ]);
140
+ /** Why a route changed mid-request. */
141
+ exports.inferenceRouteSwitchReasonSchema = zod_1.z.enum([
142
+ 'deployment_unavailable',
143
+ 'provider_error',
144
+ 'provider_timeout',
145
+ 'provider_overloaded',
146
+ 'rate_limited',
147
+ 'capacity',
148
+ 'policy_preference',
149
+ ]);
150
+ /**
151
+ * The customer-visible notice that an allowed route switch occurred.
152
+ *
153
+ * Emitted in-stream rather than only recorded on the receipt, because a
154
+ * customer comparing two answers needs to know that the second one came from
155
+ * somewhere else while they are reading it.
156
+ */
157
+ exports.inferenceStreamRouteSwitchEventSchema = zod_1.z.object({
158
+ /** See `version.ts`: each stream event is a whole message on the wire. */
159
+ schemaVersion: zod_1.z.literal(1),
160
+ type: zod_1.z.literal('route_switch'),
161
+ requestId: identifiers_1.requestIdSchema,
162
+ sequence: zod_1.z.number().int().nonnegative().safe(),
163
+ reason: exports.inferenceRouteSwitchReasonSchema,
164
+ detail: exports.inferenceRouteSwitchDetailSchema,
165
+ occurredAt: identifiers_1.inferenceTimestampSchema,
166
+ });
167
+ /**
168
+ * A terminal error. The stream ends here; no `done` follows, so a client that
169
+ * saw an error never also has to reconcile a success.
170
+ */
171
+ exports.inferenceStreamErrorEventSchema = zod_1.z.object({
172
+ /** See `version.ts`: each stream event is a whole message on the wire. */
173
+ schemaVersion: zod_1.z.literal(1),
174
+ type: zod_1.z.literal('error'),
175
+ requestId: identifiers_1.requestIdSchema,
176
+ sequence: zod_1.z.number().int().nonnegative().safe(),
177
+ /** Carries its own `schemaVersion`: the same body is returned non-streaming. */
178
+ error: errors_1.inferenceErrorSchema,
179
+ });
180
+ /** Why generation stopped. */
181
+ exports.inferenceFinishReasonSchema = zod_1.z.enum([
182
+ 'stop',
183
+ 'length',
184
+ 'tool_calls',
185
+ 'content_filter',
186
+ 'cancelled',
187
+ ]);
188
+ /**
189
+ * The successful terminal event.
190
+ *
191
+ * `receiptId` is present once settlement has produced one, giving a customer a
192
+ * direct handle on the exact amount charged rather than a telemetry estimate.
193
+ */
194
+ exports.inferenceStreamDoneEventSchema = zod_1.z.object({
195
+ /** See `version.ts`: each stream event is a whole message on the wire. */
196
+ schemaVersion: zod_1.z.literal(1),
197
+ type: zod_1.z.literal('done'),
198
+ requestId: identifiers_1.requestIdSchema,
199
+ sequence: zod_1.z.number().int().nonnegative().safe(),
200
+ generationId: identifiers_1.generationIdSchema.optional(),
201
+ finishReason: exports.inferenceFinishReasonSchema,
202
+ receiptId: zod_1.z.string().min(1).max(128).optional(),
203
+ completedAt: identifiers_1.inferenceTimestampSchema,
204
+ });
205
+ /**
206
+ * Every event a normalized stream can carry.
207
+ *
208
+ * Discriminated on `type`, so a consumer that meets an unknown event fails at
209
+ * the parse instead of falling into a default branch that treats it as output.
210
+ */
211
+ exports.inferenceStreamEventSchema = zod_1.z.discriminatedUnion('type', [
212
+ exports.inferenceStreamStartEventSchema,
213
+ exports.inferenceStreamDeltaEventSchema,
214
+ exports.inferenceStreamToolCallEventSchema,
215
+ exports.inferenceStreamUsageEventSchema,
216
+ exports.inferenceStreamRouteSwitchEventSchema,
217
+ exports.inferenceStreamErrorEventSchema,
218
+ exports.inferenceStreamDoneEventSchema,
219
+ ]);
@@ -0,0 +1,297 @@
1
+ "use strict";
2
+ /**
3
+ * The reserve → settle → refund protocol.
4
+ *
5
+ * Four records, in the order they happen:
6
+ *
7
+ * 1. **Reservation** — before a request enters the data plane, Oxy holds the
8
+ * MAXIMUM the request could cost (input units + maximum output + the
9
+ * allowed route's price ceiling). A request whose account cannot cover that
10
+ * hold is rejected before anything is spent upstream.
11
+ * 2. **Usage report** — the data plane's technical account of what was
12
+ * consumed. Units and route, never money: the data plane measures, the
13
+ * control plane prices.
14
+ * 3. **Receipt** — the immutable settlement. Carries the exact units, a COPY
15
+ * of the prices they were charged at, and the amount booked. It is never
16
+ * edited afterwards.
17
+ * 4. **Refund/reversal** — an equally immutable entry that releases an unused
18
+ * hold or reverses a settled charge. Settled history is compensated, never
19
+ * rewritten, because an invoice a customer already received must remain
20
+ * reconstructible.
21
+ *
22
+ * Every one of them is keyed by an idempotency key, so a retried call, a
23
+ * redelivered event or a duplicated webhook produces the same record rather than
24
+ * a second charge.
25
+ *
26
+ * Money is an exact decimal string throughout (ADR 0009 — never integer minor
27
+ * units, never a float), and unit counts are carried separately from it (see
28
+ * `money.ts`).
29
+ *
30
+ * Decided in: docs/adr/0009-usage-reservation-and-settlement.md.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.usageRefundSchema = exports.usageRefundReasonSchema = exports.usageRefundSubjectSchema = exports.usageReceiptSchema = exports.normalizedUsageReportSchema = exports.inferenceRequestOutcomeSchema = exports.usageReservationSchema = exports.usageReservationStatusSchema = exports.usageReservationRequestSchema = void 0;
34
+ const zod_1 = require("zod");
35
+ const attribution_1 = require("./attribution");
36
+ const identifiers_1 = require("./identifiers");
37
+ const money_1 = require("./money");
38
+ const priceVersion_1 = require("./priceVersion");
39
+ /* -------------------------------------------------------------------------- */
40
+ /* 1. Reservation */
41
+ /* -------------------------------------------------------------------------- */
42
+ /**
43
+ * Ask the ledger to hold the maximum a request could cost.
44
+ *
45
+ * `maxAmount` is the number the balance check is made against, and it is
46
+ * computed from the three inputs beside it rather than guessed: the units
47
+ * already known (the prompt), the ceiling on units still to come
48
+ * (`maxOutputTokens`), and the most expensive route the policy allows. Sizing a
49
+ * hold from a typical response rather than the worst allowed one is how a
50
+ * balance goes negative on a long generation.
51
+ */
52
+ exports.usageReservationRequestSchema = zod_1.z.object({
53
+ /** See `version.ts`: exchanged between the edge and the ledger on its own. */
54
+ schemaVersion: zod_1.z.literal(1),
55
+ idempotencyKey: identifiers_1.idempotencyKeySchema,
56
+ attribution: attribution_1.inferenceAttributionSchema,
57
+ /** Units already determined by the request itself, e.g. input tokens. */
58
+ knownUnits: zod_1.z.array(money_1.usageQuantitySchema).default([]),
59
+ /** The ceiling on generated output, when the request set one. */
60
+ maxOutputTokens: zod_1.z.number().int().positive().safe().optional(),
61
+ /** The price version of the most expensive route the policy permits. */
62
+ ceilingPriceVersionId: zod_1.z.string().min(1).max(128),
63
+ maxAmount: money_1.exactDecimalSchema,
64
+ currency: money_1.currencyCodeSchema,
65
+ expiresInSeconds: zod_1.z.number().int().positive().max(86400),
66
+ });
67
+ /** Lifecycle of a hold. Terminal states are `settled`, `released`, `expired`. */
68
+ exports.usageReservationStatusSchema = zod_1.z.enum([
69
+ 'held',
70
+ 'settled',
71
+ 'released',
72
+ 'expired',
73
+ ]);
74
+ /**
75
+ * A hold placed against an account's balance.
76
+ *
77
+ * Reserved amounts are shown to customers distinctly from settled charges: a
78
+ * reservation is not money spent, and presenting the two as one number makes a
79
+ * balance appear to drop and then recover for every request.
80
+ */
81
+ exports.usageReservationSchema = zod_1.z
82
+ .object({
83
+ /** See `version.ts`: read back by the edge and the Console on its own. */
84
+ schemaVersion: zod_1.z.literal(1),
85
+ reservationId: zod_1.z.string().min(1).max(128),
86
+ idempotencyKey: identifiers_1.idempotencyKeySchema,
87
+ attribution: attribution_1.inferenceAttributionSchema,
88
+ status: exports.usageReservationStatusSchema,
89
+ reservedAmount: money_1.exactDecimalSchema,
90
+ currency: money_1.currencyCodeSchema,
91
+ ceilingPriceVersionId: zod_1.z.string().min(1).max(128),
92
+ createdAt: identifiers_1.inferenceTimestampSchema,
93
+ expiresAt: identifiers_1.inferenceTimestampSchema,
94
+ /** The receipt that consumed this hold. Present exactly when `settled`. */
95
+ settledReceiptId: zod_1.z.string().min(1).max(128).optional(),
96
+ })
97
+ .superRefine((reservation, ctx) => {
98
+ if (reservation.status === 'settled' && reservation.settledReceiptId === undefined) {
99
+ ctx.addIssue({
100
+ code: zod_1.z.ZodIssueCode.custom,
101
+ path: ['settledReceiptId'],
102
+ message: 'a settled reservation must name the receipt that settled it',
103
+ });
104
+ }
105
+ if (reservation.status !== 'settled' && reservation.settledReceiptId !== undefined) {
106
+ ctx.addIssue({
107
+ code: zod_1.z.ZodIssueCode.custom,
108
+ path: ['settledReceiptId'],
109
+ message: 'only a settled reservation has a settling receipt',
110
+ });
111
+ }
112
+ });
113
+ /* -------------------------------------------------------------------------- */
114
+ /* 2. Usage report (data plane → Oxy) */
115
+ /* -------------------------------------------------------------------------- */
116
+ /** How a request ended, from the data plane's point of view. */
117
+ exports.inferenceRequestOutcomeSchema = zod_1.z.enum([
118
+ 'completed',
119
+ 'partial',
120
+ 'cancelled',
121
+ 'failed',
122
+ ]);
123
+ /**
124
+ * The data plane's technical account of one request.
125
+ *
126
+ * No money and no price: the data plane measures units and names the route it
127
+ * used, and the control plane decides what that costs. Keeping the two apart is
128
+ * what allows a price to be corrected after the fact without re-running or
129
+ * re-measuring anything.
130
+ *
131
+ * `usageSource` is load-bearing when a provider returns no usage at all: the
132
+ * report still arrives, marked `estimated`, so settlement can apply the
133
+ * estimation policy knowingly instead of treating a reconstruction as fact.
134
+ *
135
+ * `units` is a PARTITION of what the request consumed, not a set of totals with
136
+ * details hanging off them — see `USAGE_UNITS` in `money.ts`. Reporting a provider's
137
+ * nested `prompt_tokens`/`completion_tokens` verbatim charges the cached and
138
+ * reasoning tokens twice, so subtracting the children out is part of what
139
+ * "normalized" means in this shape's name.
140
+ */
141
+ exports.normalizedUsageReportSchema = zod_1.z
142
+ .object({
143
+ /** See `version.ts`: emitted by the data plane as a whole message. */
144
+ schemaVersion: zod_1.z.literal(1),
145
+ requestId: identifiers_1.requestIdSchema,
146
+ generationId: identifiers_1.generationIdSchema.optional(),
147
+ attribution: attribution_1.inferenceAttributionSchema,
148
+ outcome: exports.inferenceRequestOutcomeSchema,
149
+ units: zod_1.z.array(money_1.usageQuantitySchema),
150
+ usageSource: money_1.usageSourceSchema,
151
+ resolvedModelReference: identifiers_1.modelReferenceSchema,
152
+ servingProvider: identifiers_1.inferenceProviderSlugSchema,
153
+ deploymentId: identifiers_1.deploymentIdSchema.optional(),
154
+ /** How many allowed route switches occurred while serving this request. */
155
+ routeSwitches: zod_1.z.number().int().nonnegative().max(100),
156
+ startedAt: identifiers_1.inferenceTimestampSchema,
157
+ completedAt: identifiers_1.inferenceTimestampSchema,
158
+ timeToFirstTokenMs: zod_1.z.number().int().nonnegative().safe().optional(),
159
+ })
160
+ .superRefine((report, ctx) => {
161
+ // Compared as instants: two spellings of one moment sort differently as text.
162
+ if (Date.parse(report.completedAt) < Date.parse(report.startedAt)) {
163
+ ctx.addIssue({
164
+ code: zod_1.z.ZodIssueCode.custom,
165
+ path: ['completedAt'],
166
+ message: 'a request cannot complete before it started',
167
+ });
168
+ }
169
+ const units = report.units.map((quantity) => quantity.unit);
170
+ if (new Set(units).size !== units.length) {
171
+ ctx.addIssue({
172
+ code: zod_1.z.ZodIssueCode.custom,
173
+ path: ['units'],
174
+ message: 'each unit is reported once, as a total',
175
+ });
176
+ }
177
+ });
178
+ /* -------------------------------------------------------------------------- */
179
+ /* 3. Receipt (settlement) */
180
+ /* -------------------------------------------------------------------------- */
181
+ /**
182
+ * The immutable record of what a request was actually charged.
183
+ *
184
+ * `priceSnapshot` is a COPY of the unit prices applied, not just the id of the
185
+ * price version they came from, so the arithmetic on this receipt can be
186
+ * checked years later without depending on any other record still existing.
187
+ *
188
+ * `platformFeeOnly` marks a BYOK request: the upstream provider billed the
189
+ * customer's own account directly, and `billedAmount` is Oxy's service fee
190
+ * rather than the cost of the tokens. Without the flag, the two look identical
191
+ * and a BYOK customer appears to have been charged twice for one request.
192
+ */
193
+ exports.usageReceiptSchema = zod_1.z
194
+ .object({
195
+ /** See `version.ts`: returned to customers by `GET /v1/generations/:id`. */
196
+ schemaVersion: zod_1.z.literal(1),
197
+ receiptId: zod_1.z.string().min(1).max(128),
198
+ /** The hold this settled against. Absent for a charge with no reservation. */
199
+ reservationId: zod_1.z.string().min(1).max(128).optional(),
200
+ idempotencyKey: identifiers_1.idempotencyKeySchema,
201
+ attribution: attribution_1.inferenceAttributionSchema,
202
+ outcome: exports.inferenceRequestOutcomeSchema,
203
+ units: zod_1.z.array(money_1.usageQuantitySchema).min(1),
204
+ usageSource: money_1.usageSourceSchema,
205
+ priceSnapshot: priceVersion_1.priceSnapshotSchema,
206
+ billedAmount: money_1.exactDecimalSchema,
207
+ currency: money_1.currencyCodeSchema,
208
+ platformFeeOnly: zod_1.z.boolean(),
209
+ resolvedModelReference: identifiers_1.modelReferenceSchema,
210
+ servingProvider: identifiers_1.inferenceProviderSlugSchema,
211
+ settledAt: identifiers_1.inferenceTimestampSchema,
212
+ })
213
+ .superRefine((receipt, ctx) => {
214
+ if (receipt.currency !== receipt.priceSnapshot.currency) {
215
+ ctx.addIssue({
216
+ code: zod_1.z.ZodIssueCode.custom,
217
+ path: ['priceSnapshot', 'currency'],
218
+ message: 'a receipt must be settled in the currency it was priced in',
219
+ });
220
+ }
221
+ const units = receipt.units.map((quantity) => quantity.unit);
222
+ if (new Set(units).size !== units.length) {
223
+ ctx.addIssue({
224
+ code: zod_1.z.ZodIssueCode.custom,
225
+ path: ['units'],
226
+ message: 'each unit is settled once, as a total',
227
+ });
228
+ }
229
+ });
230
+ /* -------------------------------------------------------------------------- */
231
+ /* 4. Refund / reversal */
232
+ /* -------------------------------------------------------------------------- */
233
+ /**
234
+ * What a reversal acts on: an unused hold, or a settled charge.
235
+ *
236
+ * A discriminated union, so "release the rest of the hold" and "give back money
237
+ * already booked" can never be confused for one another — they hit different
238
+ * ledger accounts and only the second one is visible on an invoice.
239
+ */
240
+ exports.usageRefundSubjectSchema = zod_1.z.discriminatedUnion('kind', [
241
+ zod_1.z
242
+ .object({ kind: zod_1.z.literal('reservation'), reservationId: zod_1.z.string().min(1).max(128) })
243
+ .strict(),
244
+ zod_1.z.object({ kind: zod_1.z.literal('receipt'), receiptId: zod_1.z.string().min(1).max(128) }).strict(),
245
+ ]);
246
+ /** Why money was given back or a hold was released. */
247
+ exports.usageRefundReasonSchema = zod_1.z.enum([
248
+ 'unused_reservation',
249
+ 'client_cancelled',
250
+ 'upstream_failure',
251
+ 'partial_stream',
252
+ 'usage_unavailable',
253
+ 'billing_correction',
254
+ 'duplicate_charge',
255
+ ]);
256
+ /** Reasons that can only ever act on a settled charge. */
257
+ const RECEIPT_ONLY_REFUND_REASONS = new Set([
258
+ 'billing_correction',
259
+ 'duplicate_charge',
260
+ ]);
261
+ /**
262
+ * An immutable reversal entry.
263
+ *
264
+ * `amount` is non-negative like every other amount in this contract: the
265
+ * direction is carried by the record being a refund, not by a sign that a
266
+ * consumer could read the wrong way round. Idempotent by key, so a retried
267
+ * refund releases the same money once.
268
+ */
269
+ exports.usageRefundSchema = zod_1.z
270
+ .object({
271
+ /** See `version.ts`: a ledger record read back on its own. */
272
+ schemaVersion: zod_1.z.literal(1),
273
+ refundId: zod_1.z.string().min(1).max(128),
274
+ idempotencyKey: identifiers_1.idempotencyKeySchema,
275
+ attribution: attribution_1.inferenceAttributionSchema,
276
+ subject: exports.usageRefundSubjectSchema,
277
+ reason: exports.usageRefundReasonSchema,
278
+ amount: money_1.exactDecimalSchema,
279
+ currency: money_1.currencyCodeSchema,
280
+ createdAt: identifiers_1.inferenceTimestampSchema,
281
+ })
282
+ .superRefine((refund, ctx) => {
283
+ if (refund.reason === 'unused_reservation' && refund.subject.kind !== 'reservation') {
284
+ ctx.addIssue({
285
+ code: zod_1.z.ZodIssueCode.custom,
286
+ path: ['reason'],
287
+ message: 'an unused reservation is released against the reservation, not a receipt',
288
+ });
289
+ }
290
+ if (RECEIPT_ONLY_REFUND_REASONS.has(refund.reason) && refund.subject.kind !== 'receipt') {
291
+ ctx.addIssue({
292
+ code: zod_1.z.ZodIssueCode.custom,
293
+ path: ['reason'],
294
+ message: `${refund.reason} reverses a settled charge, so it acts on a receipt`,
295
+ });
296
+ }
297
+ });
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ /**
3
+ * Version rule for the Oxy↔data-plane inference contracts.
4
+ *
5
+ * Oxy is the control plane; the inference data plane is a separate service.
6
+ * They are deployed independently, in different repositories, possibly in
7
+ * different languages.
8
+ * Every shape they exchange therefore carries its version IN THE PARSED DATA,
9
+ * never as a comment or an out-of-band assumption, so a producer running ahead
10
+ * of a consumer fails loudly at the parse instead of being silently reinterpreted.
11
+ *
12
+ * The rule, enforced by `src/__tests__/inference.compatibility.test.ts`:
13
+ *
14
+ * - A schema carries `schemaVersion: z.literal(<n>)` **if and only if** it can
15
+ * appear on the wire as a whole message — a request envelope, a stream event,
16
+ * a catalogue descriptor, a ledger record, an error body.
17
+ * - A schema that only ever appears EMBEDDED inside such a message (the
18
+ * attribution block, one message part, one usage quantity, a data-retention
19
+ * policy) carries no version of its own: it inherits the version of the
20
+ * envelope it rides in. Versioning it separately would create two versions
21
+ * that can disagree about one byte stream.
22
+ * - A shape that is BOTH — `inferenceErrorSchema` is returned as an HTTP body
23
+ * and also rides inside the stream's error event — keeps its own version.
24
+ * The envelope's version then governs the envelope and the payload's governs
25
+ * the payload, which is two versions of two things rather than two versions
26
+ * of one.
27
+ * - Every exported object schema in `src/inference/` must fall into exactly one
28
+ * of those groups. The compatibility test holds both lists as exact
29
+ * equalities, so a new shape that is in neither fails the build rather than
30
+ * quietly shipping unversioned.
31
+ *
32
+ * A shape's own version is bumped when its meaning changes in a way a consumer
33
+ * pinned to the previous version would misread — a field removed, a field's
34
+ * units changed, a closed enum's member given a new meaning. Adding an OPTIONAL
35
+ * field is additive and does not bump it, because a consumer on the previous
36
+ * version parses the message correctly and simply does not read the new field.
37
+ *
38
+ * ## Which shapes reject an unknown field
39
+ *
40
+ * That last rule is why the shapes EXCHANGED WITH THE DATA PLANE are not
41
+ * `.strict()` at their top level — the request envelope, the four usage records,
42
+ * the stream events, the error body, the catalogue descriptors, the price
43
+ * version. The split is a decision rather than an omission: `.strict()` and
44
+ * "adding an optional field is additive" cannot both hold on one shape, because
45
+ * a producer one minor version ahead would have its whole message REFUSED
46
+ * rather than its new field ignored. For a usage report that means a request
47
+ * already served upstream can never be settled and Oxy absorbs its cost, which
48
+ * is a worse failure than the one strictness would have caught.
49
+ *
50
+ * Their LEAVES are strict, and that is where the protection lives: a stripped
51
+ * field is the worse outcome exactly where it would be a leak or a second
52
+ * source of truth, because it disappears at this parse and survives in the
53
+ * producer, which is where somebody eventually reads it. So
54
+ * `clientRequestMetadataSchema` (no IP, ever), `moneySchema` (no convenience
55
+ * float beside the exact decimal), `providerErrorPassthroughSchema` (no
56
+ * upstream request or headers beside the message), `usageQuantitySchema` and
57
+ * `unitPriceSchema` all refuse an unknown field, while the envelope carrying
58
+ * them tolerates an additive one.
59
+ *
60
+ * A shape Oxy does NOT exchange with the data plane is strict at its top level
61
+ * too, since nothing there can run ahead of this package:
62
+ * `providerConnectionSchema`, where an unknown field is how a BYOK credential
63
+ * escapes, and the billing and entitlement records, where one is a second
64
+ * number beside an exact amount.
65
+ *
66
+ * Decided in: docs/adr/0006-oxy-relay-boundary.md, docs/adr/0010-public-api-compatibility.md.
67
+ */
68
+ Object.defineProperty(exports, "__esModule", { value: true });
69
+ exports.INFERENCE_CONTRACT_VERSION = void 0;
70
+ /**
71
+ * Version of the contract SET as a whole — the value the control plane and the
72
+ * data plane exchange in a startup/health handshake to establish that they were built against
73
+ * compatible definitions before a single inference request is served.
74
+ *
75
+ * MAJOR is bumped when any individual shape's `schemaVersion` increments (at
76
+ * 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.
79
+ *
80
+ * This constant is deliberately NOT embedded in the request envelope. Pinning a
81
+ * request to the version of the whole set would make an unrelated additive
82
+ * change to, say, the catalogue reject every in-flight inference request; the
83
+ * per-shape `schemaVersion` is what a message is validated against.
84
+ */
85
+ exports.INFERENCE_CONTRACT_VERSION = '1.0.0';