@oxyhq/core 20.1.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 (109) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +47 -8
  3. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  4. package/dist/cjs/i18n/locales/en-US.json +26 -4
  5. package/dist/cjs/i18n/locales/es-ES.json +26 -4
  6. package/dist/cjs/i18n/locales/locales/en-US.json +26 -4
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +26 -4
  8. package/dist/cjs/index.js +57 -16
  9. package/dist/cjs/inference/OxyInferenceClient.js +330 -0
  10. package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
  11. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  12. package/dist/cjs/mixins/OxyServices.inference.js +59 -0
  13. package/dist/cjs/mixins/OxyServices.utility.js +18 -6
  14. package/dist/cjs/mixins/index.js +6 -0
  15. package/dist/cjs/server/auth.js +76 -0
  16. package/dist/cjs/server/index.js +5 -1
  17. package/dist/cjs/session/SessionClient.js +361 -1
  18. package/dist/cjs/session/accountDialogController.js +121 -147
  19. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  20. package/dist/cjs/session/deviceDirectory.js +143 -0
  21. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  22. package/dist/cjs/session/projectSessionState.js +8 -1
  23. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  24. package/dist/esm/.tsbuildinfo +1 -1
  25. package/dist/esm/HttpService.js +47 -8
  26. package/dist/esm/boot/sessionColdBoot.js +107 -8
  27. package/dist/esm/i18n/locales/en-US.json +26 -4
  28. package/dist/esm/i18n/locales/es-ES.json +26 -4
  29. package/dist/esm/i18n/locales/locales/en-US.json +26 -4
  30. package/dist/esm/i18n/locales/locales/es-ES.json +26 -4
  31. package/dist/esm/index.js +36 -10
  32. package/dist/esm/inference/OxyInferenceClient.js +325 -0
  33. package/dist/esm/mixins/OxyServices.accounts.js +5 -72
  34. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  35. package/dist/esm/mixins/OxyServices.inference.js +56 -0
  36. package/dist/esm/mixins/OxyServices.utility.js +18 -6
  37. package/dist/esm/mixins/index.js +6 -0
  38. package/dist/esm/server/auth.js +72 -0
  39. package/dist/esm/server/index.js +1 -1
  40. package/dist/esm/session/SessionClient.js +362 -2
  41. package/dist/esm/session/accountDialogController.js +121 -147
  42. package/dist/esm/session/accountSwitchTargets.js +71 -0
  43. package/dist/esm/session/deviceDirectory.js +135 -0
  44. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  45. package/dist/esm/session/projectSessionState.js +8 -2
  46. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  47. package/dist/types/.tsbuildinfo +1 -1
  48. package/dist/types/HttpService.d.ts +39 -1
  49. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  50. package/dist/types/index.d.ts +11 -4
  51. package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
  52. package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
  53. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  54. package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
  55. package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
  56. package/dist/types/mixins/index.d.ts +2 -1
  57. package/dist/types/models/session.d.ts +11 -0
  58. package/dist/types/server/auth.d.ts +80 -0
  59. package/dist/types/server/index.d.ts +2 -2
  60. package/dist/types/session/SessionClient.d.ts +202 -1
  61. package/dist/types/session/accountDialogController.d.ts +76 -64
  62. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  63. package/dist/types/session/deviceDirectory.d.ts +182 -0
  64. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  65. package/dist/types/session/projectSessionState.d.ts +29 -0
  66. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  67. package/package.json +3 -3
  68. package/src/HttpService.ts +50 -10
  69. package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
  70. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  71. package/src/boot/sessionColdBoot.ts +133 -9
  72. package/src/i18n/locales/en-US.json +26 -4
  73. package/src/i18n/locales/es-ES.json +26 -4
  74. package/src/index.ts +94 -25
  75. package/src/inference/OxyInferenceClient.ts +590 -0
  76. package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
  77. package/src/mixins/OxyServices.accounts.ts +75 -176
  78. package/src/mixins/OxyServices.auth.ts +67 -5
  79. package/src/mixins/OxyServices.inference.ts +57 -0
  80. package/src/mixins/OxyServices.utility.ts +58 -14
  81. package/src/mixins/__tests__/accounts.test.ts +57 -102
  82. package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
  83. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  84. package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
  85. package/src/mixins/index.ts +8 -0
  86. package/src/models/session.ts +11 -0
  87. package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
  88. package/src/server/auth.ts +118 -0
  89. package/src/server/index.ts +6 -0
  90. package/src/session/SessionClient.ts +386 -1
  91. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  92. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  93. package/src/session/__tests__/accountDialogShape.test.ts +118 -0
  94. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  95. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  96. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  97. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  98. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  99. package/src/session/accountDialogController.ts +141 -179
  100. package/src/session/accountSwitchTargets.ts +87 -0
  101. package/src/session/deviceDirectory.ts +269 -0
  102. package/src/session/deviceSwitcherRows.ts +145 -0
  103. package/src/session/projectSessionState.ts +9 -3
  104. package/src/session/sharedDeviceCredential.ts +349 -0
  105. package/dist/cjs/session/accountProjection.js +0 -213
  106. package/dist/esm/session/accountProjection.js +0 -207
  107. package/dist/types/session/accountProjection.d.ts +0 -198
  108. package/src/session/__tests__/accountProjection.test.ts +0 -447
  109. package/src/session/accountProjection.ts +0 -354
@@ -0,0 +1,325 @@
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
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
67
+ if (kind === "m") throw new TypeError("Private method is not writable");
68
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
69
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
70
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
71
+ };
72
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
73
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
74
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
75
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
76
+ };
77
+ var _OxyInferenceClient_instances, _OxyInferenceClient_baseURL, _OxyInferenceClient_credential, _OxyInferenceClient_fetch, _OxyInferenceClient_bearer, _OxyInferenceClient_request;
78
+ import { INFERENCE_ERROR_CODES, modelIdSchema } from '@oxyhq/contracts';
79
+ /** The base URL of the Oxy API, when a caller names none. */
80
+ export const OXY_INFERENCE_BASE_URL = 'https://api.oxy.so';
81
+ /**
82
+ * Anything the inference API refused.
83
+ *
84
+ * `retryable` is asserted by the server and looked up from a total map over the
85
+ * closed code set — never inferred here from the status. A client that decides
86
+ * retryability from an HTTP status is exactly what the contract's retryability
87
+ * rule exists to prevent, so this class carries the server's answer and does not
88
+ * compute one.
89
+ */
90
+ export class OxyInferenceError extends Error {
91
+ constructor(input) {
92
+ super(input.message);
93
+ this.name = 'OxyInferenceError';
94
+ this.code = input.code;
95
+ this.retryable = input.retryable;
96
+ this.requestId = input.requestId;
97
+ this.status = input.status;
98
+ if (input.retryAfterMs !== undefined)
99
+ this.retryAfterMs = input.retryAfterMs;
100
+ if (input.param !== undefined)
101
+ this.param = input.param;
102
+ }
103
+ }
104
+ /**
105
+ * The Oxy inference API.
106
+ *
107
+ * Stateless: it holds a base URL, a way to get a bearer and a `fetch`. Nothing
108
+ * is cached, because the two things worth caching here are a catalogue that is
109
+ * audience-scoped and a receipt that is immutable but rarely re-read.
110
+ *
111
+ * Successful responses are TYPED, not re-parsed. The server validates every one
112
+ * against its own schema before serving it, and a second client-side parse of a
113
+ * non-strict shape would silently DROP fields a newer API added — turning
114
+ * forward compatibility into data loss. Refusals are read defensively, because
115
+ * two routers answer under `/v1` and an unreadable failure must still reach the
116
+ * caller as one.
117
+ */
118
+ export class OxyInferenceClient {
119
+ constructor(options) {
120
+ _OxyInferenceClient_instances.add(this);
121
+ _OxyInferenceClient_baseURL.set(this, void 0);
122
+ _OxyInferenceClient_credential.set(this, void 0);
123
+ _OxyInferenceClient_fetch.set(this, void 0);
124
+ const baseURL = options.baseURL ?? OXY_INFERENCE_BASE_URL;
125
+ __classPrivateFieldSet(this, _OxyInferenceClient_baseURL, baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL, "f");
126
+ __classPrivateFieldSet(this, _OxyInferenceClient_credential, options.credential, "f");
127
+ const fetchImpl = options.fetch ?? globalThis.fetch;
128
+ if (fetchImpl === undefined) {
129
+ throw new Error('OxyInferenceClient needs a fetch implementation: this runtime has no global fetch, so pass one as `fetch`.');
130
+ }
131
+ __classPrivateFieldSet(this, _OxyInferenceClient_fetch, fetchImpl, "f");
132
+ }
133
+ /**
134
+ * The models this caller may use — `GET /v1/models`.
135
+ *
136
+ * Audience-scoped server-side. A machine credential and an anonymous caller
137
+ * both see the PUBLIC catalogue; only an internal/system application's
138
+ * service token sees internal-only routes.
139
+ *
140
+ * **`[]` is a normal answer**, and is the only answer today: the catalogue
141
+ * is populated by operators, and a route is not publicly exposed until
142
+ * somebody has reviewed the right to resell it.
143
+ */
144
+ async listModels(options = {}) {
145
+ const body = await __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_request).call(this, 'GET', '/v1/models', { ...(options.signal === undefined ? {} : { signal: options.signal }) });
146
+ return body.data;
147
+ }
148
+ /**
149
+ * One catalogue entry by its canonical id — `GET /v1/models/:publisher/:model`.
150
+ *
151
+ * The id is TWO path segments, because a canonical model id contains a slash
152
+ * and a single encoded segment would never match the route.
153
+ *
154
+ * A model you may not see answers 404 identically to one that does not
155
+ * exist, deliberately: the catalogue is never an existence oracle for what
156
+ * Oxy runs internally.
157
+ *
158
+ * @param modelId - `<publisher>/<model>`. A revision pin
159
+ * (`<publisher>/<model>@<revision>`) names a model REFERENCE rather than a
160
+ * model and is rejected here rather than sent, because the catalogue is
161
+ * keyed on models and a pinned reference would 404 indistinguishably from
162
+ * "no such model".
163
+ */
164
+ async getModel(modelId, options = {}) {
165
+ const parsed = modelIdSchema.safeParse(modelId);
166
+ if (!parsed.success) {
167
+ throw new Error(`Not a canonical model id: ${modelId}. Expected <publisher>/<model>, e.g. acme/some-model.`);
168
+ }
169
+ const [publisher, model] = parsed.data.split('/');
170
+ const body = await __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_request).call(this, 'GET', `/v1/models/${encodeURIComponent(publisher)}/${encodeURIComponent(model)}`, { ...(options.signal === undefined ? {} : { signal: options.signal }) });
171
+ return body.data;
172
+ }
173
+ /**
174
+ * The routing profiles this caller may select — `GET /v1/models/routing-profiles`.
175
+ *
176
+ * A profile is a named strategy for CHOOSING among routes, not a model: no
177
+ * publisher, no revision, no licence, no weights. Like the model list, `[]`
178
+ * is a normal answer.
179
+ */
180
+ async listRoutingProfiles(options = {}) {
181
+ const body = await __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_request).call(this, 'GET', '/v1/models/routing-profiles', { ...(options.signal === undefined ? {} : { signal: options.signal }) });
182
+ return body.data;
183
+ }
184
+ /**
185
+ * Send one non-streaming inference request — `POST /v1/responses`.
186
+ *
187
+ * **This refuses in every deployment today** with `service_unavailable`,
188
+ * because there is no data plane behind the edge. The spend held for the
189
+ * request is released before the refusal returns, so nothing is charged.
190
+ *
191
+ * @throws {OxyInferenceError} for every refusal, carrying the server's own
192
+ * `code`, `retryable` and `requestId`.
193
+ */
194
+ async respond(request, options = {}) {
195
+ return __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_request).call(this, 'POST', '/v1/responses', {
196
+ body: request,
197
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
198
+ ...(options.idempotencyKey === undefined
199
+ ? {}
200
+ : { idempotencyKey: options.idempotencyKey }),
201
+ ...(options.delegatedUserId === undefined
202
+ ? {}
203
+ : { delegatedUserId: options.delegatedUserId }),
204
+ });
205
+ }
206
+ /**
207
+ * Read back the settled receipt for one request —
208
+ * `GET /v1/generations/:id`.
209
+ *
210
+ * `id` is the `requestId` you already hold (it is on every response and
211
+ * every error) or the `generationId`. Requires the `inference:usage:read`
212
+ * scope; a caller without it, or one whose application did not make the
213
+ * request, is told the receipt does not exist rather than that it belongs to
214
+ * somebody else.
215
+ */
216
+ async getGeneration(id, options = {}) {
217
+ const body = await __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_request).call(this, 'GET', `/v1/generations/${encodeURIComponent(id)}`, { ...(options.signal === undefined ? {} : { signal: options.signal }) });
218
+ return body.data;
219
+ }
220
+ }
221
+ _OxyInferenceClient_baseURL = new WeakMap(), _OxyInferenceClient_credential = new WeakMap(), _OxyInferenceClient_fetch = new WeakMap(), _OxyInferenceClient_instances = new WeakSet(), _OxyInferenceClient_bearer =
222
+ /** The bearer for this request, from whichever lane was configured. */
223
+ async function _OxyInferenceClient_bearer() {
224
+ const value = typeof __classPrivateFieldGet(this, _OxyInferenceClient_credential, "f") === 'string'
225
+ ? __classPrivateFieldGet(this, _OxyInferenceClient_credential, "f")
226
+ : await __classPrivateFieldGet(this, _OxyInferenceClient_credential, "f").call(this);
227
+ if (value === null || value === undefined || value.length === 0) {
228
+ throw new Error('OxyInferenceClient has no bearer: the configured credential resolved to nothing. On the Oxy auth lane this usually means the session is not restored yet.');
229
+ }
230
+ return value;
231
+ }, _OxyInferenceClient_request =
232
+ /**
233
+ * One request, and the one place a refusal becomes an
234
+ * {@link OxyInferenceError}.
235
+ *
236
+ * Two error shapes arrive here, because two routers serve `/v1`. The edge
237
+ * returns the contract error at the top level; the catalogue returns the
238
+ * platform's `{ error, message }` envelope. Both are read, and a body that
239
+ * is neither still produces an `OxyInferenceError` — with the code the
240
+ * status maps to — rather than a bare `Error`, so a caller's `catch` never
241
+ * has to branch on which router answered.
242
+ */
243
+ async function _OxyInferenceClient_request(method, path, options) {
244
+ const headers = {
245
+ Authorization: `Bearer ${await __classPrivateFieldGet(this, _OxyInferenceClient_instances, "m", _OxyInferenceClient_bearer).call(this)}`,
246
+ Accept: 'application/json',
247
+ };
248
+ if (options.body !== undefined)
249
+ headers['Content-Type'] = 'application/json';
250
+ if (options.idempotencyKey !== undefined) {
251
+ headers['Idempotency-Key'] = options.idempotencyKey;
252
+ }
253
+ if (options.delegatedUserId !== undefined) {
254
+ headers['X-Oxy-User-Id'] = options.delegatedUserId;
255
+ }
256
+ const response = await __classPrivateFieldGet(this, _OxyInferenceClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _OxyInferenceClient_baseURL, "f")}${path}`, {
257
+ method,
258
+ headers,
259
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
260
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
261
+ });
262
+ const payload = await response.json().catch(() => undefined);
263
+ if (!response.ok) {
264
+ throw toInferenceError(payload, response.status, response.headers.get('X-Oxy-Request-Id'));
265
+ }
266
+ return payload;
267
+ };
268
+ /**
269
+ * Which code a status means when the body did not name one.
270
+ *
271
+ * Deliberately partial: only the statuses whose meaning is unambiguous without a
272
+ * body. Everything else becomes `internal_error`, which is non-retryable — the
273
+ * safe direction, since inventing a retryable code for an unreadable failure is
274
+ * how one outage becomes a retry storm.
275
+ */
276
+ const STATUS_FALLBACK_CODE = {
277
+ 400: 'invalid_request',
278
+ 401: 'authentication_failed',
279
+ 403: 'permission_denied',
280
+ 404: 'model_not_found',
281
+ 409: 'idempotency_conflict',
282
+ 413: 'request_too_large',
283
+ 429: 'rate_limited',
284
+ 502: 'provider_error',
285
+ 503: 'service_unavailable',
286
+ 504: 'provider_timeout',
287
+ };
288
+ /**
289
+ * The closed set the contract defines, as a lookup.
290
+ *
291
+ * A `code` outside it is a contract violation rather than a code this client
292
+ * has not caught up with — `INFERENCE_ERROR_CODES` and the version header move
293
+ * together — so an unrecognised one falls back to the status map instead of
294
+ * being asserted into the type.
295
+ */
296
+ const INFERENCE_ERROR_CODE_SET = new Set(INFERENCE_ERROR_CODES);
297
+ /** Read whichever error shape arrived into the one this client throws. */
298
+ function toInferenceError(payload, status, requestIdHeader) {
299
+ const body = (payload ?? {});
300
+ // The edge's own shape is the contract error at the top level; the
301
+ // catalogue's is the platform envelope, whose `error` is a string.
302
+ const code = typeof body.code === 'string' && INFERENCE_ERROR_CODE_SET.has(body.code)
303
+ ? body.code
304
+ : (STATUS_FALLBACK_CODE[status] ?? 'internal_error');
305
+ const message = typeof body.message === 'string' && body.message.length > 0
306
+ ? body.message
307
+ : typeof body.error === 'string' && body.error.length > 0
308
+ ? body.error
309
+ : `The inference API answered ${status}.`;
310
+ return new OxyInferenceError({
311
+ code,
312
+ message,
313
+ // A body that did not assert retryability is not retryable: the server
314
+ // is the only thing that may say a retry could succeed.
315
+ retryable: body.retryable === true,
316
+ requestId: typeof body.requestId === 'string' && body.requestId.length > 0
317
+ ? body.requestId
318
+ : (requestIdHeader ?? ''),
319
+ status,
320
+ ...(body.retryable === true && typeof body.retryAfterMs === 'number'
321
+ ? { retryAfterMs: body.retryAfterMs }
322
+ : {}),
323
+ ...(typeof body.param === 'string' ? { param: body.param } : {}),
324
+ });
325
+ }
@@ -330,76 +330,6 @@ export function OxyServicesAccountsMixin(Base) {
330
330
  }
331
331
  }
332
332
  // =========================================================================
333
- // Bot (account) service credentials — /accounts/:id/credentials
334
- // =========================================================================
335
- /**
336
- * List a bot account's service credentials. The response NEVER includes
337
- * secrets.
338
- * @param accountId - The account's Mongo `_id`.
339
- */
340
- async listAccountCredentials(accountId) {
341
- try {
342
- const res = await this.makeRequest('GET', `/accounts/${encodeURIComponent(accountId)}/credentials`, undefined, { cache: true, cacheTTL: CACHE_TIMES.MEDIUM });
343
- return res.credentials ?? [];
344
- }
345
- catch (error) {
346
- throw this.handleError(error);
347
- }
348
- }
349
- /**
350
- * Create a service credential for a bot account. The plaintext `secret` is
351
- * returned exactly ONCE; the server stores only a hash and will never return
352
- * it again.
353
- * @param accountId - The account's Mongo `_id`.
354
- * @param data - Credential configuration (`type` is always `service`).
355
- */
356
- async createAccountCredential(accountId, data) {
357
- try {
358
- const result = await this.makeRequest('POST', `/accounts/${encodeURIComponent(accountId)}/credentials`, data, { cache: false });
359
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
360
- return result;
361
- }
362
- catch (error) {
363
- throw this.handleError(error);
364
- }
365
- }
366
- /**
367
- * Rotate a bot credential's secret. The new plaintext `secret` is returned
368
- * exactly ONCE, along with audit fields: `rotatedFrom` (the previous
369
- * credentialId) and `graceExpiresAt` (ISO string for the grace window during
370
- * which the old credential is still honoured).
371
- * @param accountId - The account's Mongo `_id`.
372
- * @param credentialId - The credential's Mongo `_id`.
373
- */
374
- async rotateAccountCredential(accountId, credentialId) {
375
- try {
376
- const result = await this.makeRequest('POST', `/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}/rotate`, undefined, { cache: false });
377
- // Rotation changes credential status/audit fields surfaced by the list.
378
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
379
- return result;
380
- }
381
- catch (error) {
382
- throw this.handleError(error);
383
- }
384
- }
385
- /**
386
- * Revoke a bot credential (`status='revoked'`). Revoked credentials can no
387
- * longer authenticate.
388
- * @param accountId - The account's Mongo `_id`.
389
- * @param credentialId - The credential's Mongo `_id`.
390
- */
391
- async revokeAccountCredential(accountId, credentialId) {
392
- try {
393
- const result = await this.makeRequest('DELETE', `/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}`, undefined, { cache: false });
394
- // Revocation flips the credential's status in the cached list.
395
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
396
- return result;
397
- }
398
- catch (error) {
399
- throw this.handleError(error);
400
- }
401
- }
402
- // =========================================================================
403
333
  // Applications owned by an account — /applications
404
334
  // =========================================================================
405
335
  /**
@@ -520,10 +450,13 @@ export function OxyServicesAccountsMixin(Base) {
520
450
  * which the old credential is still honoured).
521
451
  * @param applicationId - The application's Mongo `_id`.
522
452
  * @param credentialId - The credential's Mongo `_id`.
453
+ * @param options - `graceSeconds` keeps a superseded `machine` token working
454
+ * for that long. Omitted, the previous token dies the moment the
455
+ * replacement is minted.
523
456
  */
524
- async rotateAppCredential(applicationId, credentialId) {
457
+ async rotateAppCredential(applicationId, credentialId, options) {
525
458
  try {
526
- const result = await this.makeRequest('POST', `/applications/${encodeURIComponent(applicationId)}/credentials/${encodeURIComponent(credentialId)}/rotate`, undefined, { cache: false });
459
+ const result = await this.makeRequest('POST', `/applications/${encodeURIComponent(applicationId)}/credentials/${encodeURIComponent(credentialId)}/rotate`, options, { cache: false });
527
460
  // Rotation changes credential status/audit fields surfaced by the list.
528
461
  this.clearCacheEntry(`GET:/applications/${encodeURIComponent(applicationId)}/credentials`);
529
462
  return result;
@@ -1140,6 +1140,23 @@ export function OxyServicesAuthMixin(Base) {
1140
1140
  * response this method used before were an Oxy invention no OAuth library
1141
1141
  * could interoperate with; the endpoint no longer accepts them. The method's
1142
1142
  * OWN signature is unchanged, so callers are unaffected.
1143
+ *
1144
+ * `deviceId` + `deviceSecret` are OPTIONAL and their absence is a valid
1145
+ * outcome, not an error. A third-party grant is meant to be isolated from the
1146
+ * browser's shared DeviceSession, so the token endpoint must be free to return
1147
+ * no device credential at all — the guard that used to require the pair made
1148
+ * that omission unshippable, since it turned every third-party sign-in through
1149
+ * the SDK into a silent `exchange-failed`.
1150
+ *
1151
+ * The cost is real and deliberate: a DEVICE-LESS session cannot use the
1152
+ * zero-cookie mint lane (`POST /session/device/token`), because that lane's
1153
+ * whole proof is possession of a `deviceSecret`. Its lifetime is therefore the
1154
+ * access token itself — nothing persists a restore credential, the cold boot's
1155
+ * `device-secret-mint` step reports `no-secret` and skips, and the refresh
1156
+ * scheduler has nothing to re-mint from. When the token expires the session
1157
+ * ends LOUDLY: the 401 lane clears the tokens and the provider resolves signed
1158
+ * out, so the app can run the OAuth flow again. It never degrades into a
1159
+ * session that looks alive and cannot refresh.
1143
1160
  */
1144
1161
  async exchangeOAuthCode(params) {
1145
1162
  try {
@@ -1161,7 +1178,9 @@ export function OxyServicesAuthMixin(Base) {
1161
1178
  const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
1162
1179
  const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
1163
1180
  const userRaw = record.user;
1164
- if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
1181
+ // The device pair is NOT part of this guard see the note above. What is
1182
+ // still mandatory is what identifies the session at all.
1183
+ if (!sessionId || !userRaw || typeof userRaw !== 'object') {
1165
1184
  throw new Error('auth/oauth/token returned an incomplete session payload');
1166
1185
  }
1167
1186
  const userObj = userRaw;
@@ -1174,12 +1193,17 @@ export function OxyServicesAuthMixin(Base) {
1174
1193
  if (accessToken) {
1175
1194
  this.setTokens(accessToken);
1176
1195
  }
1196
+ if (!deviceId || !deviceSecret) {
1197
+ logger.debug('auth/oauth/token returned no device credential — this session lives only as long as its access token', { component: 'oxy.auth', method: 'exchangeOAuthCode' });
1198
+ }
1177
1199
  return {
1178
1200
  sessionId,
1179
- deviceId,
1180
1201
  expiresAt,
1181
1202
  accessToken,
1182
- deviceSecret,
1203
+ // Omitted rather than set to `undefined` when the server sent no device
1204
+ // credential, so a device-less grant serializes as the absence it is.
1205
+ ...(deviceId ? { deviceId } : {}),
1206
+ ...(deviceSecret ? { deviceSecret } : {}),
1183
1207
  user: {
1184
1208
  id: userId,
1185
1209
  username: typeof userObj.username === 'string' ? userObj.username : undefined,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The inference API, reached with whatever bearer this session already holds
3
+ * (issue #972, workstream 15).
4
+ *
5
+ * ```typescript
6
+ * const models = await oxyServices.inference().listModels();
7
+ * ```
8
+ *
9
+ * One method, and it is a FACTORY rather than a set of inference methods on
10
+ * `OxyServices`. The calls themselves live once, in
11
+ * {@link OxyInferenceClient} — which an external developer holding only an
12
+ * `oxy_sk_…` machine key constructs directly, with no Oxy session anywhere in
13
+ * the picture. Declaring the same calls a second time here would give the
14
+ * ecosystem two spellings of one request, and only one of them would stay
15
+ * correct.
16
+ *
17
+ * This is the reasoning `createLinkedClient` is already built on: the plumbing
18
+ * that binds an Oxy bearer to a client belongs in core, once, rather than in
19
+ * each app.
20
+ *
21
+ * The credential is a FUNCTION, not the current token: a session bearer rotates
22
+ * on refresh and on account switch, and a client that captured one at
23
+ * construction would start answering 401 an hour into the process's life.
24
+ */
25
+ import { OxyInferenceClient } from '../inference/OxyInferenceClient.js';
26
+ export function OxyServicesInferenceMixin(Base) {
27
+ return class extends Base {
28
+ constructor() {
29
+ super(...arguments);
30
+ /** @internal Memoized so repeated calls return one object identity. */
31
+ this._inferenceClient = null;
32
+ }
33
+ /**
34
+ * The inference client for this session.
35
+ *
36
+ * Bound to this instance's base URL and to `getAccessToken()`, so it
37
+ * follows every refresh, sign-in and account switch without being
38
+ * rebuilt.
39
+ *
40
+ * A service-authenticated process wants a different credential and
41
+ * builds {@link OxyInferenceClient} directly:
42
+ * `new OxyInferenceClient({ credential: () => oxy.getServiceToken() })`.
43
+ * The mint is asynchronous and cached, which is exactly what a
44
+ * credential function is for.
45
+ */
46
+ inference() {
47
+ if (this._inferenceClient === null) {
48
+ this._inferenceClient = new OxyInferenceClient({
49
+ baseURL: this.getBaseURL(),
50
+ credential: () => this.getAccessToken(),
51
+ });
52
+ }
53
+ return this._inferenceClient;
54
+ }
55
+ };
56
+ }
@@ -149,7 +149,7 @@ export function OxyServicesUtilityMixin(Base) {
149
149
  * additionally checked for `aud`, `iss`, and `type` claims to prevent
150
150
  * cross-token-type confusion attacks.
151
151
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
152
- * direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
152
+ * direct access to `ACCESS_TOKEN_SECRET`.
153
153
  *
154
154
  * **Why session-less user tokens are refused rather than trusted:**
155
155
  * every user access token the Oxy API issues carries a `sessionId` (see
@@ -181,7 +181,7 @@ export function OxyServicesUtilityMixin(Base) {
181
181
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
182
182
  *
183
183
  * // Protect all routes under /protected
184
- * app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
184
+ * app.use('/protected', oxy.auth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
185
185
  *
186
186
  * // Access user in route handler
187
187
  * app.get('/protected/me', (req, res) => {
@@ -195,7 +195,7 @@ export function OxyServicesUtilityMixin(Base) {
195
195
  * app.use('/public', oxy.auth({ optional: true }));
196
196
  *
197
197
  * // Require a specific scope on a service-token-protected route
198
- * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
198
+ * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }), oxy.requireScope('files:write'));
199
199
  * ```
200
200
  *
201
201
  * @param options Optional configuration
@@ -360,13 +360,19 @@ export function OxyServicesUtilityMixin(Base) {
360
360
  return onError(error);
361
361
  return res.status(401).json(error);
362
362
  }
363
- // Validate required service token fields
363
+ // Validate required service token fields. All of them are
364
+ // required, `ownerAccountId` included: an optional billing
365
+ // principal is one fallback away from being resolved from the
366
+ // delegated user, which is the exact confusion ADR 0007 forbids.
364
367
  const appId = decoded.appId;
365
368
  const credentialId = decoded.credentialId;
369
+ const ownerAccountId = decoded.ownerAccountId;
366
370
  const environment = decoded.environment;
367
371
  if (!appId ||
368
372
  typeof credentialId !== 'string' ||
369
373
  credentialId.length === 0 ||
374
+ typeof ownerAccountId !== 'string' ||
375
+ ownerAccountId.length === 0 ||
370
376
  !isOxyServiceEnvironment(environment)) {
371
377
  if (optional) {
372
378
  req.userId = null;
@@ -405,6 +411,11 @@ export function OxyServicesUtilityMixin(Base) {
405
411
  return onError(error);
406
412
  return res.status(403).json(error);
407
413
  }
414
+ // ATTRIBUTION ONLY. `req.userId` answers "on whose behalf", never
415
+ // "who pays": the billing principal stays `req.serviceApp
416
+ // .ownerAccountId`, which this branch does not touch. Read it
417
+ // through `getOxyBillingPrincipal` (`@oxyhq/core/server`), whose
418
+ // return type a user id cannot satisfy (ADR 0007).
408
419
  req.userId = oxyUserId;
409
420
  req.user = { id: oxyUserId };
410
421
  req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
@@ -419,6 +430,7 @@ export function OxyServicesUtilityMixin(Base) {
419
430
  appId,
420
431
  appName: decoded.appName || 'unknown',
421
432
  credentialId,
433
+ ownerAccountId,
422
434
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
423
435
  environment,
424
436
  };
@@ -735,7 +747,7 @@ export function OxyServicesUtilityMixin(Base) {
735
747
  * @example
736
748
  * ```typescript
737
749
  * // Protect internal endpoints
738
- * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
750
+ * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
739
751
  *
740
752
  * app.post('/internal/trigger', (req, res) => {
741
753
  * console.log('Service app:', req.serviceApp);
@@ -773,7 +785,7 @@ export function OxyServicesUtilityMixin(Base) {
773
785
  * ```typescript
774
786
  * app.use(
775
787
  * '/internal/files',
776
- * oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
788
+ * oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }),
777
789
  * oxy.requireScope('files:write'),
778
790
  * );
779
791
  * ```
@@ -32,6 +32,7 @@ import { OxyServicesChainsMixin } from './OxyServices.chains.js';
32
32
  import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
33
33
  import { OxyServicesLinksMixin } from './OxyServices.links.js';
34
34
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph.js';
35
+ import { OxyServicesInferenceMixin } from './OxyServices.inference.js';
35
36
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot.js';
36
37
  import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer.js';
37
38
  /**
@@ -96,6 +97,11 @@ const MIXIN_PIPELINE = [
96
97
  // The user-owned follow graph (#809). One relationship per user and target,
97
98
  // shared across applications, with per-application context on top.
98
99
  OxyServicesFollowGraphMixin,
100
+ // The inference model catalogue (#972). Reads only, and deliberately no
101
+ // request/stream/receipt methods — the public inference edge those would
102
+ // call is workstream 4 and does not exist yet. See
103
+ // `docs/inference/README.md` for what is and is not built.
104
+ OxyServicesInferenceMixin,
99
105
  // Device-first token mint: the client half of the zero-cookie transport
100
106
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
101
107
  OxyServicesDeviceBootMixin,