@oxyhq/contracts 0.27.0 → 0.29.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 (34) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +44 -1
  3. package/dist/cjs/inference/accountBilling.js +334 -0
  4. package/dist/cjs/inference/attribution.js +9 -4
  5. package/dist/cjs/inference/entitlement.js +217 -0
  6. package/dist/cjs/inference/errors.js +123 -9
  7. package/dist/cjs/inference/identifiers.js +11 -3
  8. package/dist/cjs/inference/money.js +43 -0
  9. package/dist/cjs/inference/streamEvents.js +11 -1
  10. package/dist/cjs/inference/usage.js +6 -0
  11. package/dist/cjs/inference/version.js +38 -3
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/index.js +9 -0
  14. package/dist/esm/inference/accountBilling.js +331 -0
  15. package/dist/esm/inference/attribution.js +9 -4
  16. package/dist/esm/inference/entitlement.js +214 -0
  17. package/dist/esm/inference/errors.js +123 -9
  18. package/dist/esm/inference/identifiers.js +11 -3
  19. package/dist/esm/inference/money.js +43 -0
  20. package/dist/esm/inference/streamEvents.js +11 -1
  21. package/dist/esm/inference/usage.js +6 -0
  22. package/dist/esm/inference/version.js +38 -3
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/index.d.ts +4 -0
  25. package/dist/types/inference/accountBilling.d.ts +738 -0
  26. package/dist/types/inference/attribution.d.ts +9 -4
  27. package/dist/types/inference/entitlement.d.ts +519 -0
  28. package/dist/types/inference/errors.d.ts +59 -10
  29. package/dist/types/inference/identifiers.d.ts +11 -3
  30. package/dist/types/inference/money.d.ts +43 -0
  31. package/dist/types/inference/streamEvents.d.ts +31 -22
  32. package/dist/types/inference/usage.d.ts +6 -0
  33. package/dist/types/inference/version.d.ts +38 -3
  34. package/package.json +1 -1
@@ -32,6 +32,17 @@ import { inferenceProviderSlugSchema, requestIdSchema } from './identifiers.js';
32
32
  * `quota_exceeded`, `byok_credential_invalid`), routing/permission policy
33
33
  * (`policy_violation`, `commercial_permission_denied`, `no_route_available`),
34
34
  * and the platform or its upstreams (everything from `deployment_unavailable`).
35
+ *
36
+ * The platform group is NOT uniformly retryable, and that is the point of
37
+ * `provider_credential_invalid` sitting in it: an upstream that refuses the
38
+ * PLATFORM's own credential fails every identical retry until an operator
39
+ * rotates a key, so classifying it as `provider_error` would send every client
40
+ * into a retry loop against a request that cannot succeed.
41
+ *
42
+ * `provider_billing_refused` is in that group for the same reason and was found
43
+ * the same way — an upstream declining to bill OXY (Anthropic answers 402) has
44
+ * to be distinguishable from the customer's own balance running out, or the
45
+ * error tells them to go and top up an account that is not the one at fault.
35
46
  */
36
47
  export const INFERENCE_ERROR_CODES = [
37
48
  'invalid_request',
@@ -58,6 +69,8 @@ export const INFERENCE_ERROR_CODES = [
58
69
  'provider_error',
59
70
  'provider_timeout',
60
71
  'provider_overloaded',
72
+ 'provider_credential_invalid',
73
+ 'provider_billing_refused',
61
74
  'service_unavailable',
62
75
  'internal_error',
63
76
  ];
@@ -70,6 +83,19 @@ export const inferenceErrorCodeSchema = z.enum(INFERENCE_ERROR_CODES);
70
83
  * names, while a quota is an account-level ceiling that only a human raises.
71
84
  * `cancelled` is here because the caller already withdrew the request; a client
72
85
  * that retries it is contradicting its own cancellation.
86
+ *
87
+ * `byok_credential_invalid` and `provider_credential_invalid` are the same
88
+ * failure seen from the two sides of the BYOK boundary — the customer's own
89
+ * upstream credential and the platform's — and they are two codes rather than
90
+ * one because only the first names an action the customer can take. Both are
91
+ * non-retryable for the same reason: a credential an upstream has refused keeps
92
+ * being refused until somebody replaces it.
93
+ *
94
+ * `quota_exceeded` and `provider_billing_refused` divide along the same line:
95
+ * both are money, but one is the CUSTOMER's ceiling and the other is Oxy's
96
+ * account with an upstream. Reporting the second as the first is retryability-
97
+ * correct and diagnostically wrong, which is the worst combination — it reads
98
+ * as actionable and the action does nothing.
73
99
  */
74
100
  export const NON_RETRYABLE_INFERENCE_ERROR_CODES = [
75
101
  'invalid_request',
@@ -91,27 +117,115 @@ export const NON_RETRYABLE_INFERENCE_ERROR_CODES = [
91
117
  'no_route_available',
92
118
  'upstream_content_filtered',
93
119
  'cancelled',
120
+ 'provider_credential_invalid',
121
+ 'provider_billing_refused',
94
122
  ];
95
123
  const NON_RETRYABLE_CODE_SET = new Set(NON_RETRYABLE_INFERENCE_ERROR_CODES);
124
+ /* -------------------------------------------------------------------------- */
125
+ /* Credential-shaped text */
126
+ /* -------------------------------------------------------------------------- */
127
+ /**
128
+ * A run of characters long enough and opaque enough to BE a credential.
129
+ *
130
+ * The alphabet every bearer token, API key and base64/base64url secret is
131
+ * written in. The LENGTH floors below are what keep this from being an entropy
132
+ * heuristic: nothing here fires on a short word, so `authorization: none` and
133
+ * `api_key=***` read as what they are.
134
+ */
135
+ const OPAQUE_ALPHABET = '[A-Za-z0-9][A-Za-z0-9._~+/=-]';
136
+ /**
137
+ * Words a producer substitutes FOR a credential.
138
+ *
139
+ * Excluded at the value position so a message whose secret has already been
140
+ * replaced is accepted. That acceptance is deliberate and is half the fix for
141
+ * issue #1027: the previous pattern refused `Authorization: [redacted]` — a
142
+ * correctly redacted string — which is precisely what pushed a producer into
143
+ * redacting the MARKER instead, and a marker-redacted string carries the secret
144
+ * and passes.
145
+ */
146
+ const PLACEHOLDER_WORDS = 'redacted|removed|hidden|masked|scrubbed|elided|omitted|filtered|sanitized|sanitised|none|null|undefined|empty';
147
+ /** A value position whose contents are a placeholder rather than a secret. */
148
+ const NOT_A_PLACEHOLDER = `(?!(?:${PLACEHOLDER_WORDS})\\b)`;
96
149
  /**
97
- * Text that looks like it carries a credential.
150
+ * Header and parameter names that carry a credential, as any provider spells
151
+ * them.
98
152
  *
99
- * A deliberately narrow set of literal markers the shapes upstream providers
100
- * actually echo rather than an entropy heuristic, which would reject
101
- * legitimate error text (a request id, a base64 image fragment) and teach
102
- * producers to strip messages until they pass.
153
+ * The prefix group is the whole point of the rewrite: `authorization` and
154
+ * `api_key` were matched literally, so `x-api-key`, `anthropic-api-key`,
155
+ * `x-goog-api-key` and `proxy-authorization` the spellings an upstream
156
+ * actually echoes went unrecognised.
103
157
  */
104
- const CREDENTIAL_LIKE_TEXT = /(?:bearer\s+[a-z0-9._~+/=-]{8,}|authorization\s*[:=]|api[_-]?key\s*[:=]|\bsk-[a-z0-9_-]{8,}|\bsk_(?:live|test)_[a-z0-9]{8,})/i;
158
+ const CREDENTIAL_NAME = '(?:[a-z0-9]{1,20}[-_]){0,3}(?:api[-_]?(?:key|token|secret)|authorization|auth[-_]?(?:token|key)?|access[-_]?token|id[-_]?token|refresh[-_]?token|bearer[-_]?token|secret[-_]?key|private[-_]?key|client[-_]?secret|session[-_]?(?:id|key|token)|passwords?|passwd|cookie|credentials?|tokens?|secrets?)';
159
+ /** An auth scheme sitting between the marker and the value. */
160
+ const AUTH_SCHEME = '(?:(?:bearer|basic|token|apikey|digest)\\s+)?';
105
161
  /**
106
- * Free text that is safe to hand a customer: bounded, and refused outright if a
107
- * credential marker appears in it. Applied to BOTH the Oxy message and the
162
+ * The four ways a credential is recognisable in free text.
163
+ *
164
+ * Each is checked independently, so removing one signal does not clear the
165
+ * string — which is the failure #1027 reported. All four are load-bearing:
166
+ * `inference.errors.test.ts` has a case that only one of them catches, and
167
+ * deleting any one entry turns a test red.
168
+ */
169
+ const CREDENTIAL_PATTERNS = [
170
+ // 1. A credential-bearing name ASSIGNED a value that is long enough to be a
171
+ // credential. The value is anchored to the separator so a placeholder at
172
+ // that position ends the match rather than being skipped over.
173
+ new RegExp(`(?:^|[^a-z0-9])${CREDENTIAL_NAME}["']?\\s*[:=]\\s*["']?${AUTH_SCHEME}${NOT_A_PLACEHOLDER}${OPAQUE_ALPHABET}{7,}`, 'i'),
174
+ // 2. A bearer token with no marker in front of it, which is how an upstream
175
+ // quotes the header value alone.
176
+ new RegExp(`\\bbearer\\s+${NOT_A_PLACEHOLDER}${OPAQUE_ALPHABET}{7,}`, 'i'),
177
+ // 3. Token grammars that ARE credentials wherever they appear, marker or not.
178
+ // This is the layer that survives a producer stripping the marker, and it
179
+ // is a closed list of issued shapes rather than an entropy score, so a
180
+ // request id or a base64 image fragment is unaffected.
181
+ //
182
+ // Case-SENSITIVE on purpose: `AKIA`, `AIza` and `gh[pousr]_` are issued in
183
+ // exactly that case, and matching them case-insensitively would start
184
+ // firing on ordinary words.
185
+ /\b(?:sk-[A-Za-z0-9_-]{8,}|[sprk]k_(?:live|test)_[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{12,}|ASIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|xox[abeprs]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{16,}|npm_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,})/,
186
+ // 4. A redaction placeholder standing NEXT TO a surviving opaque value — the
187
+ // exact residue of the span redaction in #1027 (`{x-[redacted] <key>}`).
188
+ // A correct redaction puts the placeholder WHERE the value was, so the two
189
+ // never appear side by side; a marker-span redaction leaves them adjacent.
190
+ // Both signals are required, which is what keeps an ordinary redacted
191
+ // message from being refused.
192
+ new RegExp(`(?:[[<({]\\s*(?:${PLACEHOLDER_WORDS})[^\\])}>]{0,16}[\\])}>]|\\*{3,})[^A-Za-z0-9]{0,4}${OPAQUE_ALPHABET}{11,}`, 'i'),
193
+ ];
194
+ /**
195
+ * Free text that is safe to hand a customer: bounded, and refused if it still
196
+ * looks like it carries a credential. Applied to BOTH the Oxy message and the
108
197
  * upstream one — a leak is no less a leak for having been written by a provider.
198
+ *
199
+ * ## This is a last-resort REFUSAL, not protection
200
+ *
201
+ * A pattern over the OUTPUT cannot be the control that keeps a credential out of
202
+ * an error, and a producer that treats it as one has the hole #1027 reported.
203
+ * The only reliable control is redacting the KNOWN SECRET VALUE at the point
204
+ * where the producer still holds the bytes it sent — which is an adapter's job
205
+ * and is available to nobody else. This refinement exists to catch what that
206
+ * control missed, and nothing here is a licence to skip it.
207
+ *
208
+ * Two rules follow, and they are the whole reason this text is longer than the
209
+ * pattern it describes:
210
+ *
211
+ * - **Never redact by replacing the span this pattern matched.** The span is
212
+ * the MARKER; the secret is what follows it. OxyHQ/Relay#3 measured the
213
+ * result: `{x-api-key: <key>}` is refused, `{x-[redacted] <key>}` was
214
+ * accepted, and both carry the key. Redaction made the leak worse by
215
+ * converting "this string is dangerous" into "this string is fine".
216
+ * - **This package deliberately ships no redaction helper.** One keyed on these
217
+ * patterns would rebuild the same defect one layer up, and one that took the
218
+ * secret as an argument would only restate what the producer already has.
219
+ *
220
+ * What it still cannot see, stated so nobody relies on it: a credential with no
221
+ * marker, no issued-token prefix and no placeholder beside it is bytes that look
222
+ * like a request id, and refusing those means refusing request ids.
109
223
  */
110
224
  export const safeErrorTextSchema = z
111
225
  .string()
112
226
  .min(1)
113
227
  .max(2000)
114
- .refine((value) => !CREDENTIAL_LIKE_TEXT.test(value), 'error text must not contain credential-shaped material');
228
+ .refine((value) => !CREDENTIAL_PATTERNS.some((pattern) => pattern.test(value)), 'error text must not contain credential-shaped material');
115
229
  /**
116
230
  * A coarse classification of an upstream failure (ADR 0010's `upstreamCategory`).
117
231
  *
@@ -54,9 +54,17 @@ export const oxyApplicationIdSchema = z.string().min(1).max(64);
54
54
  /** An Oxy `ApplicationCredential._id` — the credential used for this request. */
55
55
  export const oxyCredentialIdSchema = z.string().min(1).max(64);
56
56
  /**
57
- * A request id generated by the data plane. Correlates the Oxy edge, the data
58
- * plane, the financial ledger and the customer-visible receipt, so it appears on
59
- * every stream event and every ledger record.
57
+ * A request id allocated by the Oxy EDGE, at admission and BEFORE
58
+ * authentication, so that a request rejected for a bad credential is as
59
+ * traceable as one that was served (ADR 0007, and step 1 of ADR 0010's edge
60
+ * order). It is required on the inbound envelope, which is what makes the data
61
+ * plane a consumer of this id rather than its source: the data plane echoes it
62
+ * on every stream event, on the usage report and on anything it can be asked
63
+ * about later.
64
+ *
65
+ * Correlates the Oxy edge, the data plane, the financial ledger and the
66
+ * customer-visible receipt, so it appears on every stream event and every
67
+ * ledger record.
60
68
  */
61
69
  export const requestIdSchema = z.string().min(1).max(128);
62
70
  /**
@@ -85,6 +85,49 @@ export const moneySchema = z
85
85
  /**
86
86
  * The closed set of units inference is metered in.
87
87
  *
88
+ * **The units PARTITION a request: every unit counts material no other unit
89
+ * counts.** `cached_input_tokens` is not part of `input_tokens`, and
90
+ * `reasoning_tokens` is not part of `output_tokens` — they are siblings, not
91
+ * subsets. A request whose 10 000-token prompt was served 9 000 tokens from
92
+ * cache is reported as `input_tokens: 1000` beside `cached_input_tokens: 9000`,
93
+ * never as `input_tokens: 10000` beside it.
94
+ *
95
+ * That belongs to the definition rather than to a convention somewhere else,
96
+ * because settlement applies a price to EVERY reported unit and sums them
97
+ * (`inferenceLedger.service.ts`'s `computeCharge`). Under the partition rule
98
+ * that sum IS the request's cost, and a cached token can carry its own — lower
99
+ * — price. Under the nested reading the same sum charges the cached and
100
+ * reasoning tokens twice: once inside their parent and once on their own line.
101
+ * It fails silently, because every total still looks plausible and the receipt
102
+ * is still internally consistent, and on a reasoning model the reasoning tokens
103
+ * can dominate the completion, so the error is not marginal.
104
+ *
105
+ * **Every OpenAI-compatible provider reports the other way round**:
106
+ * `prompt_tokens` INCLUDES `prompt_tokens_details.cached_tokens`, and
107
+ * `completion_tokens` INCLUDES `completion_tokens_details.reasoning_tokens`.
108
+ * Normalising is the data plane's job and it is subtraction:
109
+ *
110
+ * ```text
111
+ * input_tokens = prompt_tokens - prompt_tokens_details.cached_tokens
112
+ * output_tokens = completion_tokens - completion_tokens_details.reasoning_tokens
113
+ * ```
114
+ *
115
+ * No refinement in this package can enforce it, and saying so is part of the
116
+ * rule: a nested report and a disjoint one are the same four non-negative
117
+ * integers, so no predicate over a single report can tell them apart. The two
118
+ * structural guards that DO exist — refining `cached <= input` and
119
+ * `reasoning <= output`, or deriving the parents instead of reporting them —
120
+ * both encode the nested reading, which is the one this rule rejects. What IS
121
+ * enforceable is the arithmetic that depends on the rule, and that is where the
122
+ * enforcement lives — `inferenceLedger.service.test.ts` prices a report in
123
+ * which cached and reasoning tokens are both non-zero and asserts the exact
124
+ * total, which the nested reading cannot produce.
125
+ *
126
+ * Where the public surface has to speak a nested dialect, the sum is put back
127
+ * at the boundary rather than the internal reading being bent to it
128
+ * (`routes/inferenceEdge.ts` renders `prompt_tokens` as
129
+ * `input_tokens + cached_input_tokens`).
130
+ *
88
131
  * Time is carried in integer MILLISECONDS rather than seconds so that no unit
89
132
  * quantity is ever fractional: a 12.5-second transcription is `12500`, exactly,
90
133
  * and the "units are integers" rule holds for every modality instead of holding
@@ -174,12 +174,22 @@ export const inferenceStreamErrorEventSchema = z.object({
174
174
  /** Carries its own `schemaVersion`: the same body is returned non-streaming. */
175
175
  error: inferenceErrorSchema,
176
176
  });
177
- /** Why generation stopped. */
177
+ /**
178
+ * Why generation stopped.
179
+ *
180
+ * `refusal` and `content_filter` are separate members because they are separate
181
+ * events: the MODEL declining to answer is a property of the answer, while a
182
+ * filter is an upstream system removing one. The delta channels already carry
183
+ * that distinction (`channel: 'refusal'` beside the filter's own error code),
184
+ * so collapsing it here would have made the terminal event less specific than
185
+ * the stream that produced it.
186
+ */
178
187
  export const inferenceFinishReasonSchema = z.enum([
179
188
  'stop',
180
189
  'length',
181
190
  'tool_calls',
182
191
  'content_filter',
192
+ 'refusal',
183
193
  'cancelled',
184
194
  ]);
185
195
  /**
@@ -128,6 +128,12 @@ export const inferenceRequestOutcomeSchema = z.enum([
128
128
  * `usageSource` is load-bearing when a provider returns no usage at all: the
129
129
  * report still arrives, marked `estimated`, so settlement can apply the
130
130
  * estimation policy knowingly instead of treating a reconstruction as fact.
131
+ *
132
+ * `units` is a PARTITION of what the request consumed, not a set of totals with
133
+ * details hanging off them — see `USAGE_UNITS` in `money.ts`. Reporting a provider's
134
+ * nested `prompt_tokens`/`completion_tokens` verbatim charges the cached and
135
+ * reasoning tokens twice, so subtracting the children out is part of what
136
+ * "normalized" means in this shape's name.
131
137
  */
132
138
  export const normalizedUsageReportSchema = z
133
139
  .object({
@@ -34,6 +34,34 @@
34
34
  * field is additive and does not bump it, because a consumer on the previous
35
35
  * version parses the message correctly and simply does not read the new field.
36
36
  *
37
+ * ## Which shapes reject an unknown field
38
+ *
39
+ * That last rule is why the shapes EXCHANGED WITH THE DATA PLANE are not
40
+ * `.strict()` at their top level — the request envelope, the four usage records,
41
+ * the stream events, the error body, the catalogue descriptors, the price
42
+ * version. The split is a decision rather than an omission: `.strict()` and
43
+ * "adding an optional field is additive" cannot both hold on one shape, because
44
+ * a producer one minor version ahead would have its whole message REFUSED
45
+ * rather than its new field ignored. For a usage report that means a request
46
+ * already served upstream can never be settled and Oxy absorbs its cost, which
47
+ * is a worse failure than the one strictness would have caught.
48
+ *
49
+ * Their LEAVES are strict, and that is where the protection lives: a stripped
50
+ * field is the worse outcome exactly where it would be a leak or a second
51
+ * source of truth, because it disappears at this parse and survives in the
52
+ * producer, which is where somebody eventually reads it. So
53
+ * `clientRequestMetadataSchema` (no IP, ever), `moneySchema` (no convenience
54
+ * float beside the exact decimal), `providerErrorPassthroughSchema` (no
55
+ * upstream request or headers beside the message), `usageQuantitySchema` and
56
+ * `unitPriceSchema` all refuse an unknown field, while the envelope carrying
57
+ * them tolerates an additive one.
58
+ *
59
+ * A shape Oxy does NOT exchange with the data plane is strict at its top level
60
+ * too, since nothing there can run ahead of this package:
61
+ * `providerConnectionSchema`, where an unknown field is how a BYOK credential
62
+ * escapes, and the billing and entitlement records, where one is a second
63
+ * number beside an exact amount.
64
+ *
37
65
  * Decided in: docs/adr/0006-oxy-relay-boundary.md, docs/adr/0010-public-api-compatibility.md.
38
66
  */
39
67
  /**
@@ -43,12 +71,19 @@
43
71
  *
44
72
  * MAJOR is bumped when any individual shape's `schemaVersion` increments (at
45
73
  * least one message is now read differently by the two sides); MINOR when a
46
- * shape or an optional field is added; PATCH for documentation-only changes
47
- * that leave every parsed byte identical.
74
+ * shape or an optional field is added, when a CLOSED ENUM gains a member, or
75
+ * when a refinement changes which bytes parse; PATCH for documentation-only
76
+ * changes that leave every parsed byte identical.
77
+ *
78
+ * The last two are MINOR rather than PATCH because both produce the same
79
+ * failure: a producer on the newer set emits something the older set refuses,
80
+ * with no `schemaVersion` difference to explain it. A new enum member and a
81
+ * loosened refinement are exactly what the handshake exists to surface — a
82
+ * skew the per-message version cannot express.
48
83
  *
49
84
  * This constant is deliberately NOT embedded in the request envelope. Pinning a
50
85
  * request to the version of the whole set would make an unrelated additive
51
86
  * change to, say, the catalogue reject every in-flight inference request; the
52
87
  * per-shape `schemaVersion` is what a message is validated against.
53
88
  */
54
- export const INFERENCE_CONTRACT_VERSION = '1.0.0';
89
+ export const INFERENCE_CONTRACT_VERSION = '1.1.0';