@oxyhq/core 21.0.0 → 21.0.1

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 (57) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +47 -8
  3. package/dist/cjs/i18n/locales/en-US.json +7 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +7 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +7 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -2
  7. package/dist/cjs/index.js +8 -1
  8. package/dist/cjs/inference/OxyInferenceClient.js +330 -0
  9. package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
  10. package/dist/cjs/mixins/OxyServices.inference.js +59 -0
  11. package/dist/cjs/mixins/OxyServices.utility.js +18 -6
  12. package/dist/cjs/mixins/index.js +6 -0
  13. package/dist/cjs/server/auth.js +76 -0
  14. package/dist/cjs/server/index.js +5 -1
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/HttpService.js +47 -8
  17. package/dist/esm/i18n/locales/en-US.json +7 -2
  18. package/dist/esm/i18n/locales/es-ES.json +7 -2
  19. package/dist/esm/i18n/locales/locales/en-US.json +7 -2
  20. package/dist/esm/i18n/locales/locales/es-ES.json +7 -2
  21. package/dist/esm/index.js +4 -0
  22. package/dist/esm/inference/OxyInferenceClient.js +325 -0
  23. package/dist/esm/mixins/OxyServices.accounts.js +5 -72
  24. package/dist/esm/mixins/OxyServices.inference.js +56 -0
  25. package/dist/esm/mixins/OxyServices.utility.js +18 -6
  26. package/dist/esm/mixins/index.js +6 -0
  27. package/dist/esm/server/auth.js +72 -0
  28. package/dist/esm/server/index.js +1 -1
  29. package/dist/types/.tsbuildinfo +1 -1
  30. package/dist/types/HttpService.d.ts +39 -1
  31. package/dist/types/index.d.ts +3 -1
  32. package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
  33. package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
  34. package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
  35. package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
  36. package/dist/types/mixins/index.d.ts +2 -1
  37. package/dist/types/server/auth.d.ts +80 -0
  38. package/dist/types/server/index.d.ts +2 -2
  39. package/package.json +2 -2
  40. package/src/HttpService.ts +50 -10
  41. package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
  42. package/src/i18n/locales/en-US.json +7 -2
  43. package/src/i18n/locales/es-ES.json +7 -2
  44. package/src/index.ts +19 -7
  45. package/src/inference/OxyInferenceClient.ts +590 -0
  46. package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
  47. package/src/mixins/OxyServices.accounts.ts +75 -176
  48. package/src/mixins/OxyServices.inference.ts +57 -0
  49. package/src/mixins/OxyServices.utility.ts +58 -14
  50. package/src/mixins/__tests__/accounts.test.ts +57 -102
  51. package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
  52. package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
  53. package/src/mixins/index.ts +8 -0
  54. package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
  55. package/src/server/auth.ts +118 -0
  56. package/src/server/index.ts +6 -0
  57. package/src/session/__tests__/accountDialogShape.test.ts +118 -0
@@ -0,0 +1,590 @@
1
+ /**
2
+ * The Oxy inference client — one surface, two credential lanes (issue #972,
3
+ * workstream 15).
4
+ *
5
+ * ```typescript
6
+ * // An OpenAI-style machine key: one bearer string, no session, no exchange.
7
+ * const oxy = new OxyInferenceClient({ credential: process.env.OXY_API_KEY });
8
+ *
9
+ * // Oxy auth: whatever bearer the session or the service-token mint holds.
10
+ * const oxy = oxyServices.inference();
11
+ * ```
12
+ *
13
+ * Both lanes reach the SAME endpoints and are told apart only by how the bearer
14
+ * is produced: a machine key is a constant string, and an Oxy bearer rotates, so
15
+ * it is a function this client calls on every request rather than a value it
16
+ * captures once. There is no third lane, and no method behaves differently
17
+ * depending on which one you used.
18
+ *
19
+ * ## What you will observe today
20
+ *
21
+ * **Every invoke refuses.** `respond()` reaches the public edge, which
22
+ * authenticates the credential, resolves attribution, authorizes scopes, pins a
23
+ * routing policy and reserves spend — and then has no data plane to forward to,
24
+ * so it releases the hold and answers `service_unavailable`. That surfaces here
25
+ * as an {@link OxyInferenceError} with `code: 'service_unavailable'`,
26
+ * `retryable: false` and a `requestId`. It is the correct answer, not a
27
+ * misconfiguration of yours, and no balance is spent.
28
+ *
29
+ * **The catalogue is empty**, so {@link OxyInferenceClient.listModels} answers
30
+ * `[]` and {@link OxyInferenceClient.getModel} throws for every id. `[]` is a
31
+ * normal answer to render, not an error to retry.
32
+ *
33
+ * `docs/inference/README.md` is the status board; `docs/inference/sdk.md` is
34
+ * this client's page.
35
+ *
36
+ * ## Why this is a client and not more methods on `OxyServices`
37
+ *
38
+ * Two reasons, both structural. A machine-key holder has no Oxy session at all,
39
+ * so a surface reached only through the session client would be unreachable for
40
+ * exactly the developer this workstream exists to serve. And the `/v1` error
41
+ * body is the contract's `InferenceError` at the top level rather than the
42
+ * platform's `{ error, message }` envelope — it carries `requestId`, `retryable`
43
+ * and `retryAfterMs`, all of which `OxyServices.handleError` would flatten into a
44
+ * message string. `oxyServices.inference()` binds the session bearer into this
45
+ * client so a session-holding app writes no plumbing of its own.
46
+ *
47
+ * ## Streaming is absent on purpose
48
+ *
49
+ * There is no `stream()` method and no `stream` field on a request. The stream
50
+ * event union exists in `@oxyhq/contracts` and no endpoint emits one — the edge
51
+ * refuses `stream: true` with `invalid_request`. A method that always failed
52
+ * would be a worse artefact than an absent one. See
53
+ * `docs/inference/streaming.md`.
54
+ *
55
+ * ## Field names, and the one place they could drift
56
+ *
57
+ * Every VALUE type here comes from `@oxyhq/contracts` — messages, tools, tool
58
+ * choice, response format, usage quantities, unit prices, error codes. The
59
+ * request FIELD NAMES cannot: they belong to `responsesRequestSchema`, which
60
+ * lives in the API because it is a public dialect rather than an Oxy↔data-plane
61
+ * contract. `packages/api/src/schemas/__tests__/sdkRequestCompatibility.test.ts`
62
+ * is the gate — it parses a value of this module's request type against that
63
+ * schema, so a rename on either side fails a build rather than a customer's
64
+ * request.
65
+ */
66
+
67
+ import type {
68
+ CurrencyCode,
69
+ ExactDecimal,
70
+ InferenceEnvironment,
71
+ InferenceErrorCode,
72
+ InferenceFinishReason,
73
+ InferenceMessage,
74
+ InferenceRequestOutcome,
75
+ ModelCatalogueEntry,
76
+ ResponseFormat,
77
+ RoutingPolicyReference,
78
+ RoutingProfile,
79
+ ToolChoice,
80
+ ToolDefinition,
81
+ UnitPrice,
82
+ UsageQuantity,
83
+ UsageSource,
84
+ } from '@oxyhq/contracts';
85
+ import { INFERENCE_ERROR_CODES, modelIdSchema } from '@oxyhq/contracts';
86
+
87
+ /** The base URL of the Oxy API, when a caller names none. */
88
+ export const OXY_INFERENCE_BASE_URL = 'https://api.oxy.so';
89
+
90
+ /**
91
+ * How this client gets its bearer.
92
+ *
93
+ * A `string` is a static machine credential (`oxy_sk_…`) — presented verbatim,
94
+ * exactly as a stock OpenAI SDK would present it. A function is the Oxy auth
95
+ * lane and is called on EVERY request, because a session bearer and a service
96
+ * token both rotate and a captured one goes stale inside the hour.
97
+ */
98
+ export type OxyInferenceCredential =
99
+ | string
100
+ | (() => string | null | Promise<string | null>);
101
+
102
+ /**
103
+ * The `fetch` this client calls.
104
+ *
105
+ * The global signature rather than a narrowed one, so any drop-in
106
+ * implementation — a test double, an instrumented wrapper, a Node agent — is
107
+ * assignable without a cast at either end.
108
+ */
109
+ export type OxyInferenceFetch = typeof fetch;
110
+
111
+ export interface OxyInferenceClientOptions {
112
+ readonly credential: OxyInferenceCredential;
113
+ /** Defaults to {@link OXY_INFERENCE_BASE_URL}. A trailing slash is trimmed. */
114
+ readonly baseURL?: string;
115
+ /** Defaults to the global `fetch`. */
116
+ readonly fetch?: OxyInferenceFetch;
117
+ }
118
+
119
+ /**
120
+ * A request to `POST /v1/responses`.
121
+ *
122
+ * `model` and `routingProfile` are mutually exclusive and BOTH are optional: an
123
+ * application whose routing policy carries a `defaultTarget` may name neither,
124
+ * which is what "per-application default model or routing profile" means. Naming
125
+ * both is refused by the edge with `invalid_request`.
126
+ */
127
+ export interface OxyResponsesRequest {
128
+ /** `<publisher>/<model>` or `<publisher>/<model>@<revision>`. */
129
+ readonly model?: string;
130
+ /** A routing profile slug. Never contains a slash, so it is never a model id. */
131
+ readonly routingProfile?: string;
132
+ /** A prompt, or the message list it is shorthand for. */
133
+ readonly input: string | readonly InferenceMessage[];
134
+ readonly maxOutputTokens?: number;
135
+ readonly temperature?: number;
136
+ readonly topP?: number;
137
+ readonly topK?: number;
138
+ readonly frequencyPenalty?: number;
139
+ readonly presencePenalty?: number;
140
+ readonly seed?: number;
141
+ readonly stopSequences?: readonly string[];
142
+ readonly tools?: readonly ToolDefinition[];
143
+ readonly toolChoice?: ToolChoice;
144
+ readonly responseFormat?: ResponseFormat;
145
+ /** Cost-attribution tags, echoed back on the receipt. At most 16. */
146
+ readonly labels?: Readonly<Record<string, string>>;
147
+ /** Your own correlation id, echoed on the response. */
148
+ readonly clientRequestId?: string;
149
+ }
150
+
151
+ export interface OxyInferenceRequestOptions {
152
+ /**
153
+ * Abort the request. The edge treats a client disconnect as a cancellation:
154
+ * it settles what was produced and refunds the rest, so a cancelled request
155
+ * is a normal terminal state rather than an error to clean up after.
156
+ */
157
+ readonly signal?: AbortSignal;
158
+ /**
159
+ * `Idempotency-Key`. A key already bound to a reservation is REFUSED with
160
+ * `idempotency_conflict` rather than replayed — responses are not retained,
161
+ * so there is nothing to replay, and refusing is what makes "a retry never
162
+ * produces a second charge" structural. At most 128 characters.
163
+ */
164
+ readonly idempotencyKey?: string;
165
+ /**
166
+ * `X-Oxy-User-Id` — the end user this request is made on behalf of.
167
+ * ATTRIBUTION ONLY: it never changes which account is charged.
168
+ */
169
+ readonly delegatedUserId?: string;
170
+ }
171
+
172
+ /** The body of a successful `POST /v1/responses`. */
173
+ export interface OxyInferenceResponse {
174
+ readonly schemaVersion: 1;
175
+ /** Also on `X-Oxy-Request-Id`, on success and on every refusal. */
176
+ readonly requestId: string;
177
+ readonly generationId?: string;
178
+ /** Always revision-pinned, even when you named only the model line. */
179
+ readonly model: string;
180
+ readonly servingProvider: string;
181
+ readonly finishReason: InferenceFinishReason;
182
+ readonly output: readonly InferenceMessage[];
183
+ /** Metered quantities. Never money — the charge is on the receipt. */
184
+ readonly usage: readonly UsageQuantity[];
185
+ /** The exact policy version this request was admitted under. */
186
+ readonly routingPolicy: RoutingPolicyReference;
187
+ /**
188
+ * How long Oxy took over this request, in whole milliseconds — also on
189
+ * `X-Oxy-Latency-Ms`.
190
+ *
191
+ * Measured from the moment the edge received the request through
192
+ * authentication, admission, routing, the reservation, the call to the
193
+ * inference data plane and the settlement of the hold. Most of it is the
194
+ * upstream generating tokens; it does not separate the two.
195
+ *
196
+ * It is NOT the round trip you can measure yourself, which additionally
197
+ * covers DNS, TLS, both network legs and your own parse. Report them side by
198
+ * side rather than picking one — this figure has no network in it and yours
199
+ * cannot be attributed to the model.
200
+ *
201
+ * Optional because it is additive: an Oxy deployment older than the field
202
+ * omits it, and a streamed request never carries it (the head is written
203
+ * before the first frame arrives, so the number does not exist yet).
204
+ */
205
+ readonly latencyMs?: number;
206
+ }
207
+
208
+ /**
209
+ * A settled receipt, as `GET /v1/generations/:id` returns it.
210
+ *
211
+ * Carries the price SNAPSHOT rather than a reference to a price version, so the
212
+ * arithmetic stays checkable after that version has been superseded.
213
+ */
214
+ export interface OxyGenerationReceipt {
215
+ readonly schemaVersion: 1;
216
+ readonly receiptId: string;
217
+ readonly requestId: string;
218
+ readonly generationId?: string;
219
+ readonly applicationId: string;
220
+ readonly credentialId: string;
221
+ /** Attribution only. Never the billing identity. */
222
+ readonly delegatedUserId?: string;
223
+ readonly environment: InferenceEnvironment;
224
+ readonly outcome: InferenceRequestOutcome;
225
+ readonly usageSource: UsageSource;
226
+ /** EVERY unit, including the zeros — see `usageSource` for what a zero means. */
227
+ readonly units: readonly UsageQuantity[];
228
+ readonly resolvedModelReference: string;
229
+ readonly servingProvider: string;
230
+ readonly priceSnapshot: {
231
+ readonly priceVersionId: string;
232
+ readonly currency: CurrencyCode;
233
+ readonly unitPrices: readonly UnitPrice[];
234
+ };
235
+ readonly billedAmount: ExactDecimal;
236
+ readonly currency: CurrencyCode;
237
+ /** A BYOK route: `billedAmount` is Oxy's fee, not the cost of the tokens. */
238
+ readonly platformFeeOnly: boolean;
239
+ readonly settledAt: string;
240
+ }
241
+
242
+ /**
243
+ * Anything the inference API refused.
244
+ *
245
+ * `retryable` is asserted by the server and looked up from a total map over the
246
+ * closed code set — never inferred here from the status. A client that decides
247
+ * retryability from an HTTP status is exactly what the contract's retryability
248
+ * rule exists to prevent, so this class carries the server's answer and does not
249
+ * compute one.
250
+ */
251
+ export class OxyInferenceError extends Error {
252
+ readonly code: InferenceErrorCode;
253
+ readonly retryable: boolean;
254
+ readonly requestId: string;
255
+ readonly status: number;
256
+ /** How long to wait. Only ever present when `retryable`. */
257
+ readonly retryAfterMs?: number;
258
+ /** The request field at fault, for `invalid_request`. */
259
+ readonly param?: string;
260
+
261
+ constructor(input: {
262
+ code: InferenceErrorCode;
263
+ message: string;
264
+ retryable: boolean;
265
+ requestId: string;
266
+ status: number;
267
+ retryAfterMs?: number;
268
+ param?: string;
269
+ }) {
270
+ super(input.message);
271
+ this.name = 'OxyInferenceError';
272
+ this.code = input.code;
273
+ this.retryable = input.retryable;
274
+ this.requestId = input.requestId;
275
+ this.status = input.status;
276
+ if (input.retryAfterMs !== undefined) this.retryAfterMs = input.retryAfterMs;
277
+ if (input.param !== undefined) this.param = input.param;
278
+ }
279
+ }
280
+
281
+ /** `{ data, count }` — the catalogue's collection envelope. */
282
+ interface CatalogueCollection<T> {
283
+ data: T[];
284
+ count: number;
285
+ }
286
+
287
+ /** The shape `/v1/responses` and `/v1/generations/:id` return on a refusal. */
288
+ interface WireInferenceError {
289
+ schemaVersion?: number;
290
+ code?: string;
291
+ message?: string;
292
+ retryable?: boolean;
293
+ requestId?: string;
294
+ retryAfterMs?: number;
295
+ param?: string;
296
+ }
297
+
298
+ /**
299
+ * The Oxy inference API.
300
+ *
301
+ * Stateless: it holds a base URL, a way to get a bearer and a `fetch`. Nothing
302
+ * is cached, because the two things worth caching here are a catalogue that is
303
+ * audience-scoped and a receipt that is immutable but rarely re-read.
304
+ *
305
+ * Successful responses are TYPED, not re-parsed. The server validates every one
306
+ * against its own schema before serving it, and a second client-side parse of a
307
+ * non-strict shape would silently DROP fields a newer API added — turning
308
+ * forward compatibility into data loss. Refusals are read defensively, because
309
+ * two routers answer under `/v1` and an unreadable failure must still reach the
310
+ * caller as one.
311
+ */
312
+ export class OxyInferenceClient {
313
+ readonly #baseURL: string;
314
+ readonly #credential: OxyInferenceCredential;
315
+ readonly #fetch: OxyInferenceFetch;
316
+
317
+ constructor(options: OxyInferenceClientOptions) {
318
+ const baseURL = options.baseURL ?? OXY_INFERENCE_BASE_URL;
319
+ this.#baseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
320
+ this.#credential = options.credential;
321
+
322
+ const fetchImpl = options.fetch ?? globalThis.fetch;
323
+ if (fetchImpl === undefined) {
324
+ throw new Error(
325
+ 'OxyInferenceClient needs a fetch implementation: this runtime has no global fetch, so pass one as `fetch`.',
326
+ );
327
+ }
328
+ this.#fetch = fetchImpl;
329
+ }
330
+
331
+ /**
332
+ * The models this caller may use — `GET /v1/models`.
333
+ *
334
+ * Audience-scoped server-side. A machine credential and an anonymous caller
335
+ * both see the PUBLIC catalogue; only an internal/system application's
336
+ * service token sees internal-only routes.
337
+ *
338
+ * **`[]` is a normal answer**, and is the only answer today: the catalogue
339
+ * is populated by operators, and a route is not publicly exposed until
340
+ * somebody has reviewed the right to resell it.
341
+ */
342
+ async listModels(options: { signal?: AbortSignal } = {}): Promise<ModelCatalogueEntry[]> {
343
+ const body = await this.#request<CatalogueCollection<ModelCatalogueEntry>>(
344
+ 'GET',
345
+ '/v1/models',
346
+ { ...(options.signal === undefined ? {} : { signal: options.signal }) },
347
+ );
348
+ return body.data;
349
+ }
350
+
351
+ /**
352
+ * One catalogue entry by its canonical id — `GET /v1/models/:publisher/:model`.
353
+ *
354
+ * The id is TWO path segments, because a canonical model id contains a slash
355
+ * and a single encoded segment would never match the route.
356
+ *
357
+ * A model you may not see answers 404 identically to one that does not
358
+ * exist, deliberately: the catalogue is never an existence oracle for what
359
+ * Oxy runs internally.
360
+ *
361
+ * @param modelId - `<publisher>/<model>`. A revision pin
362
+ * (`<publisher>/<model>@<revision>`) names a model REFERENCE rather than a
363
+ * model and is rejected here rather than sent, because the catalogue is
364
+ * keyed on models and a pinned reference would 404 indistinguishably from
365
+ * "no such model".
366
+ */
367
+ async getModel(
368
+ modelId: string,
369
+ options: { signal?: AbortSignal } = {},
370
+ ): Promise<ModelCatalogueEntry> {
371
+ const parsed = modelIdSchema.safeParse(modelId);
372
+ if (!parsed.success) {
373
+ throw new Error(
374
+ `Not a canonical model id: ${modelId}. Expected <publisher>/<model>, e.g. acme/some-model.`,
375
+ );
376
+ }
377
+
378
+ const [publisher, model] = parsed.data.split('/');
379
+ const body = await this.#request<{ data: ModelCatalogueEntry }>(
380
+ 'GET',
381
+ `/v1/models/${encodeURIComponent(publisher)}/${encodeURIComponent(model)}`,
382
+ { ...(options.signal === undefined ? {} : { signal: options.signal }) },
383
+ );
384
+ return body.data;
385
+ }
386
+
387
+ /**
388
+ * The routing profiles this caller may select — `GET /v1/models/routing-profiles`.
389
+ *
390
+ * A profile is a named strategy for CHOOSING among routes, not a model: no
391
+ * publisher, no revision, no licence, no weights. Like the model list, `[]`
392
+ * is a normal answer.
393
+ */
394
+ async listRoutingProfiles(
395
+ options: { signal?: AbortSignal } = {},
396
+ ): Promise<RoutingProfile[]> {
397
+ const body = await this.#request<CatalogueCollection<RoutingProfile>>(
398
+ 'GET',
399
+ '/v1/models/routing-profiles',
400
+ { ...(options.signal === undefined ? {} : { signal: options.signal }) },
401
+ );
402
+ return body.data;
403
+ }
404
+
405
+ /**
406
+ * Send one non-streaming inference request — `POST /v1/responses`.
407
+ *
408
+ * **This refuses in every deployment today** with `service_unavailable`,
409
+ * because there is no data plane behind the edge. The spend held for the
410
+ * request is released before the refusal returns, so nothing is charged.
411
+ *
412
+ * @throws {OxyInferenceError} for every refusal, carrying the server's own
413
+ * `code`, `retryable` and `requestId`.
414
+ */
415
+ async respond(
416
+ request: OxyResponsesRequest,
417
+ options: OxyInferenceRequestOptions = {},
418
+ ): Promise<OxyInferenceResponse> {
419
+ return this.#request<OxyInferenceResponse>('POST', '/v1/responses', {
420
+ body: request,
421
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
422
+ ...(options.idempotencyKey === undefined
423
+ ? {}
424
+ : { idempotencyKey: options.idempotencyKey }),
425
+ ...(options.delegatedUserId === undefined
426
+ ? {}
427
+ : { delegatedUserId: options.delegatedUserId }),
428
+ });
429
+ }
430
+
431
+ /**
432
+ * Read back the settled receipt for one request —
433
+ * `GET /v1/generations/:id`.
434
+ *
435
+ * `id` is the `requestId` you already hold (it is on every response and
436
+ * every error) or the `generationId`. Requires the `inference:usage:read`
437
+ * scope; a caller without it, or one whose application did not make the
438
+ * request, is told the receipt does not exist rather than that it belongs to
439
+ * somebody else.
440
+ */
441
+ async getGeneration(
442
+ id: string,
443
+ options: { signal?: AbortSignal } = {},
444
+ ): Promise<OxyGenerationReceipt> {
445
+ const body = await this.#request<{ data: OxyGenerationReceipt }>(
446
+ 'GET',
447
+ `/v1/generations/${encodeURIComponent(id)}`,
448
+ { ...(options.signal === undefined ? {} : { signal: options.signal }) },
449
+ );
450
+ return body.data;
451
+ }
452
+
453
+ /** The bearer for this request, from whichever lane was configured. */
454
+ async #bearer(): Promise<string> {
455
+ const value =
456
+ typeof this.#credential === 'string'
457
+ ? this.#credential
458
+ : await this.#credential();
459
+ if (value === null || value === undefined || value.length === 0) {
460
+ throw new Error(
461
+ 'OxyInferenceClient has no bearer: the configured credential resolved to nothing. On the Oxy auth lane this usually means the session is not restored yet.',
462
+ );
463
+ }
464
+ return value;
465
+ }
466
+
467
+ /**
468
+ * One request, and the one place a refusal becomes an
469
+ * {@link OxyInferenceError}.
470
+ *
471
+ * Two error shapes arrive here, because two routers serve `/v1`. The edge
472
+ * returns the contract error at the top level; the catalogue returns the
473
+ * platform's `{ error, message }` envelope. Both are read, and a body that
474
+ * is neither still produces an `OxyInferenceError` — with the code the
475
+ * status maps to — rather than a bare `Error`, so a caller's `catch` never
476
+ * has to branch on which router answered.
477
+ */
478
+ async #request<T>(
479
+ method: 'GET' | 'POST',
480
+ path: string,
481
+ options: {
482
+ body?: unknown;
483
+ signal?: AbortSignal;
484
+ idempotencyKey?: string;
485
+ delegatedUserId?: string;
486
+ },
487
+ ): Promise<T> {
488
+ const headers: Record<string, string> = {
489
+ Authorization: `Bearer ${await this.#bearer()}`,
490
+ Accept: 'application/json',
491
+ };
492
+ if (options.body !== undefined) headers['Content-Type'] = 'application/json';
493
+ if (options.idempotencyKey !== undefined) {
494
+ headers['Idempotency-Key'] = options.idempotencyKey;
495
+ }
496
+ if (options.delegatedUserId !== undefined) {
497
+ headers['X-Oxy-User-Id'] = options.delegatedUserId;
498
+ }
499
+
500
+ const response = await this.#fetch(`${this.#baseURL}${path}`, {
501
+ method,
502
+ headers,
503
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
504
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
505
+ });
506
+
507
+ const payload: unknown = await response.json().catch(() => undefined);
508
+
509
+ if (!response.ok) {
510
+ throw toInferenceError(
511
+ payload,
512
+ response.status,
513
+ response.headers.get('X-Oxy-Request-Id'),
514
+ );
515
+ }
516
+
517
+ return payload as T;
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Which code a status means when the body did not name one.
523
+ *
524
+ * Deliberately partial: only the statuses whose meaning is unambiguous without a
525
+ * body. Everything else becomes `internal_error`, which is non-retryable — the
526
+ * safe direction, since inventing a retryable code for an unreadable failure is
527
+ * how one outage becomes a retry storm.
528
+ */
529
+ const STATUS_FALLBACK_CODE: Readonly<Record<number, InferenceErrorCode>> = {
530
+ 400: 'invalid_request',
531
+ 401: 'authentication_failed',
532
+ 403: 'permission_denied',
533
+ 404: 'model_not_found',
534
+ 409: 'idempotency_conflict',
535
+ 413: 'request_too_large',
536
+ 429: 'rate_limited',
537
+ 502: 'provider_error',
538
+ 503: 'service_unavailable',
539
+ 504: 'provider_timeout',
540
+ };
541
+
542
+ /**
543
+ * The closed set the contract defines, as a lookup.
544
+ *
545
+ * A `code` outside it is a contract violation rather than a code this client
546
+ * has not caught up with — `INFERENCE_ERROR_CODES` and the version header move
547
+ * together — so an unrecognised one falls back to the status map instead of
548
+ * being asserted into the type.
549
+ */
550
+ const INFERENCE_ERROR_CODE_SET: ReadonlySet<string> = new Set<string>(INFERENCE_ERROR_CODES);
551
+
552
+ /** Read whichever error shape arrived into the one this client throws. */
553
+ function toInferenceError(
554
+ payload: unknown,
555
+ status: number,
556
+ requestIdHeader: string | null,
557
+ ): OxyInferenceError {
558
+ const body = (payload ?? {}) as WireInferenceError & { error?: unknown };
559
+
560
+ // The edge's own shape is the contract error at the top level; the
561
+ // catalogue's is the platform envelope, whose `error` is a string.
562
+ const code =
563
+ typeof body.code === 'string' && INFERENCE_ERROR_CODE_SET.has(body.code)
564
+ ? (body.code as InferenceErrorCode)
565
+ : (STATUS_FALLBACK_CODE[status] ?? 'internal_error');
566
+
567
+ const message =
568
+ typeof body.message === 'string' && body.message.length > 0
569
+ ? body.message
570
+ : typeof body.error === 'string' && body.error.length > 0
571
+ ? body.error
572
+ : `The inference API answered ${status}.`;
573
+
574
+ return new OxyInferenceError({
575
+ code,
576
+ message,
577
+ // A body that did not assert retryability is not retryable: the server
578
+ // is the only thing that may say a retry could succeed.
579
+ retryable: body.retryable === true,
580
+ requestId:
581
+ typeof body.requestId === 'string' && body.requestId.length > 0
582
+ ? body.requestId
583
+ : (requestIdHeader ?? ''),
584
+ status,
585
+ ...(body.retryable === true && typeof body.retryAfterMs === 'number'
586
+ ? { retryAfterMs: body.retryAfterMs }
587
+ : {}),
588
+ ...(typeof body.param === 'string' ? { param: body.param } : {}),
589
+ });
590
+ }