@oxy.so/core 1.7.4 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/inference/OxyInferenceClient.js +8 -3
- package/dist/cjs/mixins/OxyServices.utility.js +23 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/inference/OxyInferenceClient.js +8 -3
- package/dist/esm/mixins/OxyServices.utility.js +23 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/inference/OxyInferenceClient.d.ts +15 -4
- package/dist/types/mixins/OxyServices.utility.d.ts +8 -0
- package/dist/types/server/auth.d.ts +2 -0
- package/package.json +2 -2
- package/src/inference/OxyInferenceClient.ts +15 -3
- package/src/inference/__tests__/OxyInferenceClient.test.ts +17 -0
- package/src/mixins/OxyServices.utility.ts +33 -6
- package/src/mixins/__tests__/serviceAuth.test.ts +57 -0
- package/src/server/auth.ts +2 -0
|
@@ -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
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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;
|
|
@@ -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.
|
|
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",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"@oxy.so/telemetry": "^1.1.1",
|
|
119
119
|
"@noble/ciphers": "^1.3.0",
|
|
120
120
|
"@noble/hashes": "^1.8.0",
|
|
121
|
-
"@oxy.so/contracts": "^1.
|
|
121
|
+
"@oxy.so/contracts": "^1.4.0",
|
|
122
122
|
"@oxy.so/protocol": "^1.1.0",
|
|
123
123
|
"@scure/bip39": "^1.6.0",
|
|
124
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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({
|
|
@@ -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
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
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
|
package/src/server/auth.ts
CHANGED
|
@@ -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 {
|