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