@oxy.so/core 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,7 +49,7 @@
49
49
  * schema, so a rename on either side fails a build rather than a customer's
50
50
  * request.
51
51
  */
52
- import type { CurrencyCode, ExactDecimal, InferenceEnvironment, InferenceErrorCode, InferenceFinishReason, InferenceMessage, InferenceRequestOutcome, InferenceAudioMediaType, InferenceStreamEvent, InferenceSpeechParameters, ModelCatalogueEntry, ResponseFormat, RoutingPolicyReference, RoutingProfile, ToolChoice, ToolDefinition, UnitPrice, UsageQuantity, UsageSource } from '@oxy.so/contracts';
52
+ import type { CurrencyCode, ExactDecimal, InferenceEnvironment, InferenceErrorCode, InferenceFinishReason, InferenceMessage, InferenceReasoning, InferenceRequestOutcome, InferenceAudioMediaType, InferenceStreamEvent, InferenceSpeechParameters, ModelCatalogueEntry, ResponseFormat, RoutingPolicyReference, RoutingProfile, ToolChoice, ToolDefinition, UnitPrice, UsageQuantity, UsageSource } from '@oxy.so/contracts';
53
53
  /** The base URL of the Oxy API, when a caller names none. */
54
54
  export declare const OXY_INFERENCE_BASE_URL = "https://api.oxy.so";
55
55
  /**
@@ -105,6 +105,12 @@ export interface OxyResponsesRequest {
105
105
  readonly tools?: readonly ToolDefinition[];
106
106
  readonly toolChoice?: ToolChoice;
107
107
  readonly responseFormat?: ResponseFormat;
108
+ /**
109
+ * `{ effort: 'low' | 'medium' | 'high' }`. Name only an effort the model
110
+ * lists in its catalogue entry's `capabilities.reasoningEfforts`; any other
111
+ * is refused with `invalid_request` (HTTP 400) before anything is charged.
112
+ */
113
+ readonly reasoning?: InferenceReasoning;
108
114
  /** Cost-attribution tags, echoed back on the receipt. At most 16. */
109
115
  readonly labels?: Readonly<Record<string, string>>;
110
116
  /** Your own correlation id, echoed on the response. */
@@ -282,9 +288,14 @@ export declare class OxyInferenceClient {
282
288
  * both see the PUBLIC catalogue; only an internal/system application's
283
289
  * service token sees internal-only routes.
284
290
  *
285
- * **`[]` is a normal answer**, and is the only answer today: the catalogue
286
- * is populated by operators, and a route is not publicly exposed until
287
- * somebody has reviewed the right to resell it.
291
+ * **`[]` is a normal answer**: a route is not publicly exposed until
292
+ * somebody has reviewed the right to resell it. An official application's
293
+ * credential also sees the internal catalogue Oxy keeps in step with
294
+ * Kaana automatically.
295
+ *
296
+ * Each entry's `capabilities.reasoningEfforts` lists the efforts a request
297
+ * may name in `reasoning.effort` (empty: none), and `releasedAt`, when
298
+ * present, is the date the upstream provider published the model.
288
299
  */
289
300
  listModels(options?: {
290
301
  signal?: AbortSignal;
@@ -391,10 +391,13 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
391
391
  /**
392
392
  * Whether this process can prove what it is (ADR 0026).
393
393
  *
394
- * The internal package subpath selects a client implementation for native
395
- * and browser bundlers, and the signer only for Node hosts. The runtime
396
- * Node guard avoids calling attestation on clients; the module boundary
397
- * keeps node:crypto out of their bundle graphs (a guard alone cannot).
394
+ * `#workload-identity` (package.json `imports`) selects a client
395
+ * implementation for native and browser bundlers, and the signer only for
396
+ * Node hosts. The runtime Node guard avoids calling attestation on clients;
397
+ * the module boundary keeps node:crypto out of their bundle graphs (a guard
398
+ * alone cannot). It is a package import, not a self-reference to an export
399
+ * subpath: a self-reference only resolves where `@oxy.so/core` is reachable
400
+ * through `node_modules`, which bundlers inside this workspace are not.
398
401
  *
399
402
  * @internal
400
403
  */
@@ -47,6 +47,14 @@ export interface ServiceApp {
47
47
  appId: string;
48
48
  appName: string;
49
49
  scopes: string[];
50
+ /**
51
+ * `internal` when the caller is one of Oxy's own applications: app to app it
52
+ * is trusted outright — {@link requireScope} passes and it may act for a user
53
+ * without a delegation grant. `external` keeps every scope and grant rule.
54
+ * What the USER may do (their accounts, plan, limits) is still for the
55
+ * receiving service to decide. A token without the claim reads as `external`.
56
+ */
57
+ tier: 'internal' | 'external';
50
58
  /** The credentialId of the specific service credential that minted this token. */
51
59
  credentialId: string;
52
60
  /**
@@ -24,6 +24,8 @@ export interface OxyServiceAppContext {
24
24
  */
25
25
  ownerAccountId: string;
26
26
  environment: OxyServiceEnvironment;
27
+ /** `internal` for one of Oxy's own applications, else `external` (see `ServiceApp.tier`). */
28
+ tier: 'internal' | 'external';
27
29
  }
28
30
  export interface OxyServiceActingAsContext {
29
31
  userId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -59,17 +59,7 @@
59
59
  },
60
60
  "default": "./dist/esm/logger/index.js"
61
61
  },
62
- "./package.json": "./package.json",
63
- "./internal/workload-identity": {
64
- "types": "./dist/types/server/workloadIdentity.d.ts",
65
- "react-native": "./dist/esm/server/workloadIdentity.client.js",
66
- "browser": "./dist/esm/server/workloadIdentity.client.js",
67
- "node": {
68
- "import": "./dist/esm/server/workloadIdentity.js",
69
- "require": "./dist/cjs/server/workloadIdentity.js"
70
- },
71
- "default": "./dist/esm/server/workloadIdentity.client.js"
72
- }
62
+ "./package.json": "./package.json"
73
63
  },
74
64
  "files": [
75
65
  "NOTICE",
@@ -128,7 +118,7 @@
128
118
  "@oxy.so/telemetry": "^1.1.1",
129
119
  "@noble/ciphers": "^1.3.0",
130
120
  "@noble/hashes": "^1.8.0",
131
- "@oxy.so/contracts": "^1.3.0",
121
+ "@oxy.so/contracts": "^1.4.0",
132
122
  "@oxy.so/protocol": "^1.1.0",
133
123
  "@scure/bip39": "^1.6.0",
134
124
  "buffer": "^6.0.3",
@@ -57,6 +57,7 @@ import type {
57
57
  InferenceErrorCode,
58
58
  InferenceFinishReason,
59
59
  InferenceMessage,
60
+ InferenceReasoning,
60
61
  InferenceRequestOutcome,
61
62
  InferenceAudioMediaType,
62
63
  InferenceStreamEvent,
@@ -140,6 +141,12 @@ export interface OxyResponsesRequest {
140
141
  readonly tools?: readonly ToolDefinition[];
141
142
  readonly toolChoice?: ToolChoice;
142
143
  readonly responseFormat?: ResponseFormat;
144
+ /**
145
+ * `{ effort: 'low' | 'medium' | 'high' }`. Name only an effort the model
146
+ * lists in its catalogue entry's `capabilities.reasoningEfforts`; any other
147
+ * is refused with `invalid_request` (HTTP 400) before anything is charged.
148
+ */
149
+ readonly reasoning?: InferenceReasoning;
143
150
  /** Cost-attribution tags, echoed back on the receipt. At most 16. */
144
151
  readonly labels?: Readonly<Record<string, string>>;
145
152
  /** Your own correlation id, echoed on the response. */
@@ -370,9 +377,14 @@ export class OxyInferenceClient {
370
377
  * both see the PUBLIC catalogue; only an internal/system application's
371
378
  * service token sees internal-only routes.
372
379
  *
373
- * **`[]` is a normal answer**, and is the only answer today: the catalogue
374
- * is populated by operators, and a route is not publicly exposed until
375
- * somebody has reviewed the right to resell it.
380
+ * **`[]` is a normal answer**: a route is not publicly exposed until
381
+ * somebody has reviewed the right to resell it. An official application's
382
+ * credential also sees the internal catalogue Oxy keeps in step with
383
+ * Kaana automatically.
384
+ *
385
+ * Each entry's `capabilities.reasoningEfforts` lists the efforts a request
386
+ * may name in `reasoning.effort` (empty: none), and `releasedAt`, when
387
+ * present, is the date the upstream provider published the model.
376
388
  */
377
389
  async listModels(options: { signal?: AbortSignal } = {}): Promise<ModelCatalogueEntry[]> {
378
390
  const body = await this.#request<CatalogueCollection<ModelCatalogueEntry>>(
@@ -296,6 +296,23 @@ describe('OxyInferenceClient', () => {
296
296
  expect(answer.requestId).toBe('req-1');
297
297
  });
298
298
 
299
+ it('sends a reasoning effort as the edge expects it', async () => {
300
+ const { impl, calls } = stubFetch([{ status: 200, body: {} }]);
301
+ const client = new OxyInferenceClient({
302
+ credential: 'k',
303
+ baseURL: 'http://test.invalid',
304
+ fetch: impl,
305
+ });
306
+
307
+ await client.respond({ model: 'acme/thinker', input: 'hello', reasoning: { effort: 'high' } });
308
+
309
+ expect(JSON.parse(String(calls[0].init.body))).toEqual({
310
+ model: 'acme/thinker',
311
+ input: 'hello',
312
+ reasoning: { effort: 'high' },
313
+ });
314
+ });
315
+
299
316
  it('serializes an exact routing-profile ID byte-for-byte', async () => {
300
317
  const { impl, calls } = stubFetch([{ status: 200, body: {} }]);
301
318
  const client = new OxyInferenceClient({
@@ -647,17 +647,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
647
647
  /**
648
648
  * Whether this process can prove what it is (ADR 0026).
649
649
  *
650
- * The internal package subpath selects a client implementation for native
651
- * and browser bundlers, and the signer only for Node hosts. The runtime
652
- * Node guard avoids calling attestation on clients; the module boundary
653
- * keeps node:crypto out of their bundle graphs (a guard alone cannot).
650
+ * `#workload-identity` (package.json `imports`) selects a client
651
+ * implementation for native and browser bundlers, and the signer only for
652
+ * Node hosts. The runtime Node guard avoids calling attestation on clients;
653
+ * the module boundary keeps node:crypto out of their bundle graphs (a guard
654
+ * alone cannot). It is a package import, not a self-reference to an export
655
+ * subpath: a self-reference only resolves where `@oxy.so/core` is reachable
656
+ * through `node_modules`, which bundlers inside this workspace are not.
654
657
  *
655
658
  * @internal
656
659
  */
657
660
  async _canUseWorkloadIdentity(): Promise<boolean> {
658
661
  if (typeof process === 'undefined' || !process.versions?.node) return false;
659
662
  try {
660
- const { canAttestWorkloadIdentity } = await import('@oxy.so/core/internal/workload-identity');
663
+ const { canAttestWorkloadIdentity } = await import('#workload-identity');
661
664
  return canAttestWorkloadIdentity();
662
665
  } catch {
663
666
  // A bundler that dropped the server subpath, or a runtime without it.
@@ -684,7 +687,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
684
687
  if (entry?.token && entry.expiresAt > now + 60_000) return entry.token;
685
688
  if (entry?.pending) return entry.pending;
686
689
 
687
- const { requestWorkloadServiceToken } = await import('@oxy.so/core/internal/workload-identity');
690
+ const { requestWorkloadServiceToken } = await import('#workload-identity');
688
691
  const seeded = entry ?? {
689
692
  token: '',
690
693
  expiresAt: 0,
@@ -24,6 +24,7 @@ interface JwtPayload {
24
24
  ownerAccountId?: string;
25
25
  appName?: string;
26
26
  scopes?: string[];
27
+ tier?: string;
27
28
  aud?: string | string[];
28
29
  iss?: string;
29
30
  environment?: string;
@@ -78,6 +79,14 @@ export interface ServiceApp {
78
79
  appId: string;
79
80
  appName: string;
80
81
  scopes: string[];
82
+ /**
83
+ * `internal` when the caller is one of Oxy's own applications: app to app it
84
+ * is trusted outright — {@link requireScope} passes and it may act for a user
85
+ * without a delegation grant. `external` keeps every scope and grant rule.
86
+ * What the USER may do (their accounts, plan, limits) is still for the
87
+ * receiving service to decide. A token without the claim reads as `external`.
88
+ */
89
+ tier: 'internal' | 'external';
81
90
  /** The credentialId of the specific service credential that minted this token. */
82
91
  credentialId: string;
83
92
  /**
@@ -705,6 +714,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
705
714
  return res.status(401).json(error);
706
715
  }
707
716
 
717
+ const tier: ServiceApp['tier'] = decoded.tier === 'internal' ? 'internal' : 'external';
718
+
708
719
  // Read delegated user ID from header
709
720
  const oxyUserIdRaw = req.headers['x-oxy-user-id'];
710
721
  const oxyUserId = isExactNonEmptyServiceClaim(oxyUserIdRaw) ? oxyUserIdRaw : null;
@@ -725,12 +736,20 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
725
736
  return res.status(401).json(error);
726
737
  }
727
738
 
728
- // C3: a service may only act as a user when an explicit
729
- // ServiceActingAs grant exists for that (appId, userId) pair.
730
- // Without the grant we MUST refuse — silently attaching
731
- // `req.userId = oxyUserId` would let any service impersonate
732
- // any user simply by setting the header.
733
- if (oxyUserId) {
739
+ // One of Oxy's own applications acts for a user without a grant:
740
+ // inside the ecosystem that is trust, not consent. It still acts
741
+ // with the USER's authority only — what that user may do is for the
742
+ // receiving service to check.
743
+ if (oxyUserId && tier === 'internal') {
744
+ req.userId = oxyUserId;
745
+ req.user = { id: oxyUserId } as User;
746
+ req.serviceActingAs = { userId: oxyUserId, scopes: [] };
747
+ } else if (oxyUserId) {
748
+ // C3: an EXTERNAL service may only act as a user when an explicit
749
+ // ServiceActingAs grant exists for that (appId, userId) pair.
750
+ // Without the grant we MUST refuse — silently attaching
751
+ // `req.userId = oxyUserId` would let any service impersonate
752
+ // any user simply by setting the header.
734
753
  const grant = await oxyInstance.verifyServiceActingAs(appId, oxyUserId);
735
754
  if (!grant || !grant.authorized) {
736
755
  logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
@@ -781,6 +800,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
781
800
  ownerAccountId,
782
801
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
783
802
  environment,
803
+ tier,
784
804
  };
785
805
 
786
806
  if (debug) {
@@ -1255,6 +1275,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
1255
1275
  return;
1256
1276
  }
1257
1277
 
1278
+ // Oxy's own applications are trusted app to app; scopes are the
1279
+ // external lane.
1280
+ if (req.serviceApp.tier === 'internal') {
1281
+ next();
1282
+ return;
1283
+ }
1284
+
1258
1285
  const appHasScope = appScopes.includes(scope);
1259
1286
  const delegationHasScope = delegatedScopes.includes(scope);
1260
1287
  const hasRequiredScope = req.serviceActingAs
@@ -189,9 +189,42 @@ describe('C3: service-token acting-as enforcement', () => {
189
189
  ownerAccountId: 'owner-account-1',
190
190
  scopes: ['user:read'],
191
191
  environment: 'production',
192
+ // No `tier` claim: a token minted before it existed is external.
193
+ tier: 'external',
192
194
  });
193
195
  });
194
196
 
197
+ it("lets one of Oxy's own applications act for a user without a grant", async () => {
198
+ const verifySpy = jest.spyOn(oxy, 'verifyServiceActingAs');
199
+ const token = signServiceToken(
200
+ { appId: 'alia', appName: 'Alia', scopes: [], tier: 'internal' },
201
+ SERVICE_SECRET,
202
+ );
203
+ const req = makeReq({ headers: { authorization: `Bearer ${token}`, 'x-oxy-user-id': 'user-1' } });
204
+ const res = makeRes();
205
+ const next = jest.fn();
206
+
207
+ await oxy.auth({ jwtSecret: SERVICE_SECRET })(req as unknown as never, res as unknown as never, next as unknown as never);
208
+
209
+ expect(verifySpy).not.toHaveBeenCalled();
210
+ expect(next).toHaveBeenCalledTimes(1);
211
+ expect(req.userId).toBe('user-1');
212
+ expect(req.serviceApp?.tier).toBe('internal');
213
+ });
214
+
215
+ it('still refuses an EXTERNAL application acting for a user without a grant', async () => {
216
+ jest.spyOn(oxy, 'verifyServiceActingAs').mockResolvedValue(null);
217
+ const token = signServiceToken({ appId: 'app-1', appName: 'third-party', tier: 'external' }, SERVICE_SECRET);
218
+ const req = makeReq({ headers: { authorization: `Bearer ${token}`, 'x-oxy-user-id': 'user-1' } });
219
+ const res = makeRes();
220
+ const next = jest.fn();
221
+
222
+ await oxy.auth({ jwtSecret: SERVICE_SECRET })(req as unknown as never, res as unknown as never, next as unknown as never);
223
+
224
+ expect(next).not.toHaveBeenCalled();
225
+ expect(res.statusCode).toBe(403);
226
+ });
227
+
195
228
  it('does NOT call verifyServiceActingAs when X-Oxy-User-Id is absent (service acts as itself)', async () => {
196
229
  const verifySpy = jest
197
230
  .spyOn(oxy, 'verifyServiceActingAs')
@@ -682,6 +715,30 @@ describe('requireScope() middleware', () => {
682
715
  oxy = new OxyServices({ baseURL: 'http://test.invalid' });
683
716
  });
684
717
 
718
+ it("passes one of Oxy's own applications whatever scope is asked for", () => {
719
+ const req = makeReq();
720
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: [], tier: 'internal' };
721
+ req.serviceActingAs = { userId: 'u-1', scopes: [] };
722
+ const res = makeRes();
723
+ const next = jest.fn();
724
+
725
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
726
+
727
+ expect(next).toHaveBeenCalledTimes(1);
728
+ });
729
+
730
+ it('holds an external application to its scopes', () => {
731
+ const req = makeReq();
732
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'], tier: 'external' };
733
+ const res = makeRes();
734
+ const next = jest.fn();
735
+
736
+ oxy.requireScope('files:write')(req as unknown as never, res as unknown as never, next as unknown as never);
737
+
738
+ expect(next).not.toHaveBeenCalled();
739
+ expect(res.statusCode).toBe(403);
740
+ });
741
+
685
742
  it('allows requests where the app holds the required scope', () => {
686
743
  const req = makeReq({
687
744
  // Simulate a fully-authenticated service request — auth() has already
@@ -28,6 +28,8 @@ export interface OxyServiceAppContext {
28
28
  */
29
29
  ownerAccountId: string;
30
30
  environment: OxyServiceEnvironment;
31
+ /** `internal` for one of Oxy's own applications, else `external` (see `ServiceApp.tier`). */
32
+ tier: 'internal' | 'external';
31
33
  }
32
34
 
33
35
  export interface OxyServiceActingAsContext {