@vertesia/client 1.5.0-dev.20260901.045442Z → 1.5.0-dev.20260905.011013Z
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/lib/InteractionBase.d.ts +4 -2
- package/lib/InteractionBase.d.ts.map +1 -1
- package/lib/client.d.ts +0 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +42 -3
- package/lib/client.js.map +1 -1
- package/lib/store/AgentsApi.d.ts +5 -0
- package/lib/store/AgentsApi.d.ts.map +1 -1
- package/lib/store/AgentsApi.js +11 -4
- package/lib/store/AgentsApi.js.map +1 -1
- package/lib/store/DataApi.d.ts +8 -2
- package/lib/store/DataApi.d.ts.map +1 -1
- package/lib/store/DataApi.js +8 -2
- package/lib/store/DataApi.js.map +1 -1
- package/lib/store/IndexingApi.d.ts +2 -18
- package/lib/store/IndexingApi.d.ts.map +1 -1
- package/lib/store/IndexingApi.js +0 -45
- package/lib/store/IndexingApi.js.map +1 -1
- package/lib/store/client.d.ts.map +1 -1
- package/lib/store/client.js +13 -0
- package/lib/store/client.js.map +1 -1
- package/lib/unknown-options.d.ts +23 -0
- package/lib/unknown-options.d.ts.map +1 -0
- package/lib/unknown-options.js +51 -0
- package/lib/unknown-options.js.map +1 -0
- package/lib/vertesia-client.js +1 -1
- package/lib/vertesia-client.js.map +1 -1
- package/package.json +5 -5
- package/src/client.test.ts +114 -1
- package/src/client.ts +47 -4
- package/src/store/AgentsApi.artifacts.test.ts +119 -0
- package/src/store/AgentsApi.ts +12 -4
- package/src/store/DataApi.test.ts +42 -0
- package/src/store/DataApi.ts +8 -2
- package/src/store/IndexingApi.ts +1 -70
- package/src/store/client.ts +14 -0
- package/src/unknown-options.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertesia/client",
|
|
3
|
-
"version": "1.5.0-dev.
|
|
3
|
+
"version": "1.5.0-dev.20260905.011013Z",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"types": "./lib/index.d.ts",
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"eventsource": "^5.0.0",
|
|
22
|
-
"@
|
|
23
|
-
"@
|
|
24
|
-
"@vertesia/common": "1.5.0-dev.
|
|
22
|
+
"@vertesia/api-fetch-client": "1.5.0-dev.20260905.011013Z",
|
|
23
|
+
"@llumiverse/common": "1.5.0-dev.20260905.004250Z",
|
|
24
|
+
"@vertesia/common": "1.5.0-dev.20260905.011013Z"
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
|
27
27
|
".": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"ai",
|
|
49
49
|
"typescript"
|
|
50
50
|
],
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "ea74a29f6885cec132eec4b6b319e40137df3b40",
|
|
52
52
|
"scripts": {
|
|
53
53
|
"lint": "biome lint src",
|
|
54
54
|
"clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
|
package/src/client.test.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { APP_VERSION_HEADER } from '@vertesia/common';
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
3
|
-
import { isTokenExpired, VertesiaClient } from './client.js';
|
|
3
|
+
import { decodeJWT, isTokenExpired, VertesiaClient, type VertesiaClientProps } from './client.js';
|
|
4
|
+
import { ZenoClient, type ZenoClientProps } from './store/client.js';
|
|
5
|
+
import { resetUnknownOptionWarnings } from './unknown-options.js';
|
|
4
6
|
|
|
5
7
|
describe('Test Vertesia Client', () => {
|
|
6
8
|
test('Initialization with studio and zeno URLs', () => {
|
|
@@ -217,3 +219,114 @@ describe('isTokenExpired', () => {
|
|
|
217
219
|
expect(isTokenExpired(makeToken(exp))).toBe(true);
|
|
218
220
|
});
|
|
219
221
|
});
|
|
222
|
+
|
|
223
|
+
describe('unknown constructor options', () => {
|
|
224
|
+
// Model the real escape hatch rather than a cast: TypeScript's excess-property check only fires
|
|
225
|
+
// on an object literal, so a spread of a wider config object reaches the constructor unchecked.
|
|
226
|
+
function optionsWith(extra: Record<string, unknown>): VertesiaClientProps {
|
|
227
|
+
return {
|
|
228
|
+
serverUrl: 'https://api.vertesia.io',
|
|
229
|
+
storeUrl: 'https://api.vertesia.io',
|
|
230
|
+
...extra,
|
|
231
|
+
} as VertesiaClientProps;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
resetUnknownOptionWarnings();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
afterEach(() => {
|
|
239
|
+
vi.restoreAllMocks();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// The option names below are deliberately spelled the way the SDK does NOT accept them. Passing
|
|
243
|
+
// them used to produce a client that looked fine and then failed every request with
|
|
244
|
+
// `401 Unauthorized: Authorization token is required`, with nothing pointing at the constructor.
|
|
245
|
+
test('names the option and what the caller meant', () => {
|
|
246
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
247
|
+
|
|
248
|
+
new VertesiaClient(optionsWith({ token: 'jwt' }));
|
|
249
|
+
|
|
250
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
251
|
+
const message = warn.mock.calls[0][0] as string;
|
|
252
|
+
expect(message).toContain('[VertesiaClient]');
|
|
253
|
+
expect(message).toContain('token');
|
|
254
|
+
expect(message).toContain('`apikey`');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('reports each unknown option once, not once per client', () => {
|
|
258
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
259
|
+
const opts = optionsWith({ appVersion: 'v1' });
|
|
260
|
+
|
|
261
|
+
new VertesiaClient(opts);
|
|
262
|
+
new VertesiaClient(opts);
|
|
263
|
+
|
|
264
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(warn.mock.calls[0][0]).toContain('withAppVersion');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('stays silent for a fully valid options object', () => {
|
|
269
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
270
|
+
|
|
271
|
+
new VertesiaClient({
|
|
272
|
+
serverUrl: 'https://api.vertesia.io',
|
|
273
|
+
storeUrl: 'https://api.vertesia.io',
|
|
274
|
+
tokenServerUrl: 'https://sts.vertesia.io',
|
|
275
|
+
apikey: 'sk-1234',
|
|
276
|
+
sessionTags: 'test',
|
|
277
|
+
timeout: 1000,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(warn).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// `key in known` would treat these as known options, because every object inherits them.
|
|
284
|
+
test.each(['toString', 'constructor', 'valueOf', 'hasOwnProperty'])(
|
|
285
|
+
'reports %s, which is inherited from Object.prototype',
|
|
286
|
+
(key) => {
|
|
287
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
288
|
+
|
|
289
|
+
new VertesiaClient(optionsWith({ [key]: 'whatever' }));
|
|
290
|
+
|
|
291
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
292
|
+
expect(warn.mock.calls[0][0]).toContain(key);
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
test('covers the store client too', () => {
|
|
297
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
298
|
+
|
|
299
|
+
new ZenoClient({
|
|
300
|
+
serverUrl: 'https://api.vertesia.io',
|
|
301
|
+
...{ apiKey: 'sk-1234' },
|
|
302
|
+
} as ZenoClientProps);
|
|
303
|
+
|
|
304
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(warn.mock.calls[0][0]).toContain('[ZenoClient]');
|
|
306
|
+
expect(warn.mock.calls[0][0]).toContain('apikey');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('decodeJWT', () => {
|
|
311
|
+
// An empty or non-JWT credential used to reach `''.split('.')[1]` and die inside the base64
|
|
312
|
+
// decoder with a TypeError naming neither the token nor the caller.
|
|
313
|
+
test.each([
|
|
314
|
+
['an empty string', ''],
|
|
315
|
+
['an API key', 'sk-abcdef'],
|
|
316
|
+
['a two-segment string', 'header.payload'],
|
|
317
|
+
])('names the failure for %s', (_label, value) => {
|
|
318
|
+
expect(() => decodeJWT(value)).toThrowError(/Invalid auth token: expected a JWT/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('names a payload that is not JSON', () => {
|
|
322
|
+
expect(() => decodeJWT('aGVhZGVy.bm90LWpzb24.sig')).toThrowError(
|
|
323
|
+
'Invalid auth token: payload segment is not JSON',
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('rejects an empty token before decoding it', async () => {
|
|
328
|
+
await expect(VertesiaClient.fromAuthToken(' ')).rejects.toThrowError(
|
|
329
|
+
'VertesiaClient.fromAuthToken requires a non-empty auth token',
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { VERSION, VERSION_HEADER } from './store/version.js';
|
|
|
28
28
|
import ToolsApi from './ToolsApi.js';
|
|
29
29
|
import TrainingApi from './TrainingApi.js';
|
|
30
30
|
import UsersApi from './UsersApi.js';
|
|
31
|
+
import { warnUnknownOptions } from './unknown-options.js';
|
|
31
32
|
import ViewsApi from './ViewsApi.js';
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -63,7 +64,6 @@ export type VertesiaClientProps = {
|
|
|
63
64
|
storeUrl?: string;
|
|
64
65
|
tokenServerUrl?: string;
|
|
65
66
|
apikey?: string;
|
|
66
|
-
projectId?: string;
|
|
67
67
|
sessionTags?: string | string[];
|
|
68
68
|
onRequest?: (request: Request) => void;
|
|
69
69
|
onResponse?: (response: Response) => void;
|
|
@@ -77,6 +77,25 @@ export type VertesiaClientProps = {
|
|
|
77
77
|
fetch?: FETCH_FN | Promise<FETCH_FN>;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Every option the constructor reads. Typing it as `Record<keyof Required<VertesiaClientProps>, true>`
|
|
82
|
+
* keeps it exhaustive in both directions: a new prop that is missing here fails to compile, and a key
|
|
83
|
+
* here that is not a prop fails too. Without that, the table would silently rot into false warnings.
|
|
84
|
+
*/
|
|
85
|
+
const KNOWN_CLIENT_OPTIONS: Record<keyof Required<VertesiaClientProps>, true> = {
|
|
86
|
+
site: true,
|
|
87
|
+
serverUrl: true,
|
|
88
|
+
storeUrl: true,
|
|
89
|
+
tokenServerUrl: true,
|
|
90
|
+
apikey: true,
|
|
91
|
+
sessionTags: true,
|
|
92
|
+
onRequest: true,
|
|
93
|
+
onResponse: true,
|
|
94
|
+
retryPolicy: true,
|
|
95
|
+
timeout: true,
|
|
96
|
+
fetch: true,
|
|
97
|
+
};
|
|
98
|
+
|
|
80
99
|
export class VertesiaClient extends AbstractFetchClient<VertesiaClient> {
|
|
81
100
|
/**
|
|
82
101
|
* The JWT token linked to the API KEY (sk or pk)
|
|
@@ -119,6 +138,10 @@ export class VertesiaClient extends AbstractFetchClient<VertesiaClient> {
|
|
|
119
138
|
payload?: AuthTokenPayload,
|
|
120
139
|
endpoints?: { studio: string; store: string; token?: string; git?: string },
|
|
121
140
|
) {
|
|
141
|
+
if (!token?.trim()) {
|
|
142
|
+
throw new Error('VertesiaClient.fromAuthToken requires a non-empty auth token');
|
|
143
|
+
}
|
|
144
|
+
|
|
122
145
|
if (!payload) {
|
|
123
146
|
payload = decodeJWT(token);
|
|
124
147
|
}
|
|
@@ -138,6 +161,8 @@ export class VertesiaClient extends AbstractFetchClient<VertesiaClient> {
|
|
|
138
161
|
site: 'api.vertesia.io',
|
|
139
162
|
},
|
|
140
163
|
) {
|
|
164
|
+
warnUnknownOptions('VertesiaClient', opts, KNOWN_CLIENT_OPTIONS);
|
|
165
|
+
|
|
141
166
|
let studioServerUrl: string;
|
|
142
167
|
let zenoServerUrl: string;
|
|
143
168
|
|
|
@@ -455,9 +480,27 @@ export function isTokenExpired(token: string | null) {
|
|
|
455
480
|
}
|
|
456
481
|
|
|
457
482
|
export function decodeJWT(jwt: string): AuthTokenPayload {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
483
|
+
// Never echo the token itself in these messages: it is a live credential, and decode failures
|
|
484
|
+
// are routinely logged. The shape of the failure is enough to identify the caller's mistake.
|
|
485
|
+
const segments = typeof jwt === 'string' ? jwt.split('.') : [];
|
|
486
|
+
if (segments.length !== 3 || !segments[1]) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Invalid auth token: expected a JWT of three dot-separated segments, got ${segments.length || 'none'}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let decodedJson: string;
|
|
493
|
+
try {
|
|
494
|
+
decodedJson = base64UrlDecode(segments[1]);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
throw new Error(`Invalid auth token: payload segment is not base64url (${(err as Error).message})`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
return JSON.parse(decodedJson);
|
|
501
|
+
} catch {
|
|
502
|
+
throw new Error('Invalid auth token: payload segment is not JSON');
|
|
503
|
+
}
|
|
461
504
|
}
|
|
462
505
|
|
|
463
506
|
type RuntimeProcess = {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { escapeArtifactPathDelimiters } from './AgentsApi.js';
|
|
3
|
+
import { ZenoClient } from './client.js';
|
|
4
|
+
|
|
5
|
+
const SERVER_URL = 'https://store.test';
|
|
6
|
+
const RUN_ID = 'run-1';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A client whose injected fetch records the URL of every request it is handed. The URL is read off
|
|
10
|
+
* the Request object, so the assertions see the path after WHATWG URL parsing — which is where an
|
|
11
|
+
* unescaped `#` was being dropped.
|
|
12
|
+
*/
|
|
13
|
+
function clientRecordingUrls(body: unknown = { url: 'https://signed', path: 'p' }) {
|
|
14
|
+
const urls: string[] = [];
|
|
15
|
+
const client = new ZenoClient({
|
|
16
|
+
serverUrl: SERVER_URL,
|
|
17
|
+
apikey: 'token',
|
|
18
|
+
fetch: async (input: RequestInfo) => {
|
|
19
|
+
urls.push(input instanceof Request ? input.url : String(input));
|
|
20
|
+
return new Response(JSON.stringify(body), {
|
|
21
|
+
status: 200,
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return { client, urls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The artifact path the server reconstructs: the URL path after the route prefix, decoded per segment. */
|
|
30
|
+
function serverPath(url: string, prefix: string): string {
|
|
31
|
+
const tail = new URL(url).pathname.split(`/${prefix}/`)[1] ?? '';
|
|
32
|
+
return tail.split('/').map(decodeURIComponent).join('/');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('escapeArtifactPathDelimiters', () => {
|
|
36
|
+
it('escapes the two characters that truncate a path', () => {
|
|
37
|
+
expect(escapeArtifactPathDelimiters('files/tpl (YYYY-0#).docx')).toBe('files/tpl (YYYY-0%23).docx');
|
|
38
|
+
expect(escapeArtifactPathDelimiters('files/report?v2.docx')).toBe('files/report%3Fv2.docx');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Scope guard. Anything this helper touches changes the bytes on the wire for filenames that
|
|
42
|
+
// work today, so it must leave everything except `#` and `?` exactly as it found it.
|
|
43
|
+
it.each([
|
|
44
|
+
['percent', 'files/100% done.docx'],
|
|
45
|
+
['spaces', 'files/MHA - Client IQ FSD.docx'],
|
|
46
|
+
['non-ascii and accents', 'files/café résumé.docx'],
|
|
47
|
+
['backslash', 'files/back\\slash.docx'],
|
|
48
|
+
['segment separators', 'files/nested/a b.txt'],
|
|
49
|
+
['already-encoded sequence', 'files/report%23.md'],
|
|
50
|
+
])('leaves %s untouched', (_label, path) => {
|
|
51
|
+
expect(escapeArtifactPathDelimiters(path)).toBe(path);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('AgentsApi artifact paths', () => {
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.restoreAllMocks();
|
|
58
|
+
vi.unstubAllGlobals();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// A `#` in an uploaded filename used to be parsed as the URL fragment, so the server saw a
|
|
62
|
+
// truncated path and stored the object under a key nothing could read back.
|
|
63
|
+
it('uploadArtifact preserves a path containing #', async () => {
|
|
64
|
+
vi.stubGlobal(
|
|
65
|
+
'fetch',
|
|
66
|
+
vi.fn(async () => new Response(null, { status: 200 })),
|
|
67
|
+
);
|
|
68
|
+
const { client, urls } = clientRecordingUrls();
|
|
69
|
+
const path = 'files/Design Template (YYYY-0#).docx';
|
|
70
|
+
|
|
71
|
+
await client.agents.uploadArtifact(RUN_ID, path, 'content');
|
|
72
|
+
|
|
73
|
+
expect(urls).toHaveLength(1);
|
|
74
|
+
expect(urls[0]).not.toContain('#');
|
|
75
|
+
expect(serverPath(urls[0], 'artifacts')).toBe(path);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// The query is concatenated onto the path, so an unescaped delimiter took it down too.
|
|
79
|
+
it.each([
|
|
80
|
+
['hash', 'files/Design (YYYY-0#).docx'],
|
|
81
|
+
['question mark', 'files/report?v2.docx'],
|
|
82
|
+
])('getArtifactUrl round-trips %s and keeps the query intact', async (_label, path) => {
|
|
83
|
+
const { client, urls } = clientRecordingUrls();
|
|
84
|
+
|
|
85
|
+
await client.agents.getArtifactUrl(RUN_ID, path, 'attachment', 'download-name.docx');
|
|
86
|
+
|
|
87
|
+
const url = new URL(urls[0]);
|
|
88
|
+
expect(serverPath(urls[0], 'artifacts')).toBe(path);
|
|
89
|
+
expect(url.hash).toBe('');
|
|
90
|
+
expect(url.searchParams.get('url')).toBe('1');
|
|
91
|
+
expect(url.searchParams.get('disposition')).toBe('attachment');
|
|
92
|
+
expect(url.searchParams.get('filename')).toBe('download-name.docx');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Pins the scope boundary: `%` must go on the wire exactly as it does with no escaping at all.
|
|
96
|
+
it('sends a path containing % exactly as it did before, unescaped', async () => {
|
|
97
|
+
const { client, urls } = clientRecordingUrls();
|
|
98
|
+
const path = 'files/100% done.docx';
|
|
99
|
+
|
|
100
|
+
await client.agents.getArtifactUrl(RUN_ID, path);
|
|
101
|
+
|
|
102
|
+
const unescaped = new URL(`${SERVER_URL}/api/v1/agents/${RUN_ID}/artifacts/${path}?url=1`);
|
|
103
|
+
expect(new URL(urls[0]).pathname).toBe(unescaped.pathname);
|
|
104
|
+
expect(new URL(urls[0]).pathname).toContain('100%%20done.docx');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('getArtifactContent and updateArtifactContent preserve a path containing #', async () => {
|
|
108
|
+
const { client, urls } = clientRecordingUrls({ content: '', generation: '1' });
|
|
109
|
+
const path = 'files/notes (v0#1).md';
|
|
110
|
+
|
|
111
|
+
await client.agents.getArtifactContent(RUN_ID, path);
|
|
112
|
+
await client.agents.updateArtifactContent(RUN_ID, path, { content: 'x', generation: '1' });
|
|
113
|
+
|
|
114
|
+
expect(urls).toHaveLength(2);
|
|
115
|
+
for (const url of urls) {
|
|
116
|
+
expect(serverPath(url, 'artifact-content')).toBe(path);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/store/AgentsApi.ts
CHANGED
|
@@ -64,6 +64,14 @@ export interface AgentRunStreamMessagesOptions {
|
|
|
64
64
|
onHistoryError?: (error: unknown) => void;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* `#` and `?` are URL delimiters, not path content: either one truncates the path before the
|
|
69
|
+
* request is built. Nothing else is escaped — `%` keeps its existing rejection at upload.
|
|
70
|
+
*/
|
|
71
|
+
export function escapeArtifactPathDelimiters(path: string): string {
|
|
72
|
+
return path.replace(/#/g, '%23').replace(/\?/g, '%3F');
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
export class AgentsApi extends ApiTopic {
|
|
68
76
|
constructor(parent: ClientBase) {
|
|
69
77
|
super(parent, '/api/v1/agents');
|
|
@@ -795,7 +803,7 @@ export class AgentsApi extends ApiTopic {
|
|
|
795
803
|
|
|
796
804
|
/** Read a text artifact together with its conditional-write generation token. */
|
|
797
805
|
getArtifactContent(id: string, path: string): Promise<AgentArtifactContentResponse> {
|
|
798
|
-
return this.get(`/${id}/artifact-content/${path}`);
|
|
806
|
+
return this.get(`/${id}/artifact-content/${escapeArtifactPathDelimiters(path)}`);
|
|
799
807
|
}
|
|
800
808
|
|
|
801
809
|
/** Conditionally replace the text content of an agent artifact. */
|
|
@@ -804,7 +812,7 @@ export class AgentsApi extends ApiTopic {
|
|
|
804
812
|
path: string,
|
|
805
813
|
payload: UpdateAgentArtifactContentPayload,
|
|
806
814
|
): Promise<UpdateAgentArtifactContentResponse> {
|
|
807
|
-
return this.put(`/${id}/artifact-content/${path}`, { payload });
|
|
815
|
+
return this.put(`/${id}/artifact-content/${escapeArtifactPathDelimiters(path)}`, { payload });
|
|
808
816
|
}
|
|
809
817
|
|
|
810
818
|
/**
|
|
@@ -819,7 +827,7 @@ export class AgentsApi extends ApiTopic {
|
|
|
819
827
|
const query: Record<string, string> = { url: '1' };
|
|
820
828
|
if (disposition) query.disposition = disposition;
|
|
821
829
|
if (fileName) query.filename = fileName;
|
|
822
|
-
return this.get(`/${id}/artifacts/${path}`, { query });
|
|
830
|
+
return this.get(`/${id}/artifacts/${escapeArtifactPathDelimiters(path)}`, { query });
|
|
823
831
|
}
|
|
824
832
|
|
|
825
833
|
/**
|
|
@@ -840,7 +848,7 @@ export class AgentsApi extends ApiTopic {
|
|
|
840
848
|
const mimeType = contentType || 'application/octet-stream';
|
|
841
849
|
|
|
842
850
|
// 1. Get signed upload URL from the agents API
|
|
843
|
-
const result = (await this.put(`/${id}/artifacts/${path}`, {
|
|
851
|
+
const result = (await this.put(`/${id}/artifacts/${escapeArtifactPathDelimiters(path)}`, {
|
|
844
852
|
headers: { 'Content-Type': mimeType },
|
|
845
853
|
})) as AgentArtifactUrlResponse;
|
|
846
854
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ImportDataPayload } from '@vertesia/common';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ZenoClient } from './client.js';
|
|
4
|
+
|
|
5
|
+
describe('DataApi', () => {
|
|
6
|
+
// `import` is the name applications built against the 1.4 SDK call, through the client the
|
|
7
|
+
// platform serves them rather than one they bundle -- so renaming it (as 1.5 briefly did, to
|
|
8
|
+
// `importData`) breaks them at deploy time rather than at their next upgrade. This pins both
|
|
9
|
+
// the name and the request it issues.
|
|
10
|
+
it('exposes `import` as POST {store}/import with the data store header', async () => {
|
|
11
|
+
const requests: { url: string; method: string; storeHeader: string | null; body: string }[] = [];
|
|
12
|
+
const fetchImport = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
13
|
+
const request = new Request(input, init);
|
|
14
|
+
requests.push({
|
|
15
|
+
url: request.url,
|
|
16
|
+
method: request.method,
|
|
17
|
+
storeHeader: request.headers.get('x-data-store-id'),
|
|
18
|
+
body: await request.text(),
|
|
19
|
+
});
|
|
20
|
+
return new Response(JSON.stringify({ id: 'import-1', status: 'completed' }), {
|
|
21
|
+
status: 200,
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const client = new ZenoClient({ serverUrl: 'https://store.test', apikey: 'token', fetch: fetchImport });
|
|
27
|
+
const payload: ImportDataPayload = {
|
|
28
|
+
mode: 'append',
|
|
29
|
+
message: 'test import',
|
|
30
|
+
tables: { customers: { source: 'inline', data: [{ id: 1 }] } },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const job = await client.data.import('store-1', payload);
|
|
34
|
+
|
|
35
|
+
expect(job).toEqual({ id: 'import-1', status: 'completed' });
|
|
36
|
+
expect(requests).toHaveLength(1);
|
|
37
|
+
expect(requests[0].method).toBe('POST');
|
|
38
|
+
expect(requests[0].url).toBe('https://store.test/api/v1/data/store-1/import');
|
|
39
|
+
expect(requests[0].storeHeader).toBe('store-1');
|
|
40
|
+
expect(JSON.parse(requests[0].body)).toEqual(payload);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/store/DataApi.ts
CHANGED
|
@@ -255,7 +255,7 @@ export class DataApi extends ApiTopic {
|
|
|
255
255
|
*
|
|
256
256
|
* @example
|
|
257
257
|
* ```typescript
|
|
258
|
-
* const job = await client.data.
|
|
258
|
+
* const job = await client.data.import(storeId, {
|
|
259
259
|
* mode: 'append',
|
|
260
260
|
* message: 'Monthly data import',
|
|
261
261
|
* tables: {
|
|
@@ -271,8 +271,14 @@ export class DataApi extends ApiTopic {
|
|
|
271
271
|
* }
|
|
272
272
|
* });
|
|
273
273
|
* ```
|
|
274
|
+
*
|
|
275
|
+
* Do not rename this method. 1.5 renamed it to `importData` (composableai#1645) on the theory
|
|
276
|
+
* that the token sequence `import(` confused Vite/Rollup import analysis; it does not on the
|
|
277
|
+
* current toolchain. Applications built against the 1.4 SDK call this through the client the
|
|
278
|
+
* platform serves them rather than one they bundle themselves, so a rename would break them
|
|
279
|
+
* the moment 1.5 is deployed, not at their next upgrade.
|
|
274
280
|
*/
|
|
275
|
-
|
|
281
|
+
import(id: string, payload: ImportDataPayload): Promise<ImportJob> {
|
|
276
282
|
return this.post(`/${id}/import`, { payload, headers: this.storeHeaders(id) });
|
|
277
283
|
}
|
|
278
284
|
|
package/src/store/IndexingApi.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiTopic, type ClientBase
|
|
1
|
+
import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
|
|
2
2
|
import type {
|
|
3
3
|
AnalyzeDriftBatchResult,
|
|
4
4
|
BulkDeleteResult,
|
|
@@ -22,8 +22,6 @@ import type {
|
|
|
22
22
|
NextIndexCursorResult,
|
|
23
23
|
ReindexAgentRunsPayload,
|
|
24
24
|
ReindexAgentRunsResponse,
|
|
25
|
-
ReindexViaBulkRequest,
|
|
26
|
-
ReindexViaBulkResult,
|
|
27
25
|
StartProjectReindexPayload,
|
|
28
26
|
SwapAliasRequest,
|
|
29
27
|
SwapAliasResult,
|
|
@@ -345,71 +343,4 @@ export class IndexingApi extends ApiTopic {
|
|
|
345
343
|
backend,
|
|
346
344
|
} satisfies SwapAliasRequest);
|
|
347
345
|
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Full reindex of a tenant via zeno-bulk (all-in-one).
|
|
351
|
-
* The Go service handles sharding, indexing, catch-up, and alias swap internally.
|
|
352
|
-
*
|
|
353
|
-
* In JSON mode (default): waits for completion and returns the final result.
|
|
354
|
-
* In SSE mode (when onEvent is provided): streams progress events from zeno-bulk
|
|
355
|
-
* and returns the final result. The onEvent callback receives parsed SSE events
|
|
356
|
-
* with { event: "progress" | "done", data: string (JSON) }.
|
|
357
|
-
*/
|
|
358
|
-
async reindexViaBulk(
|
|
359
|
-
tenantId: string,
|
|
360
|
-
onEvent?: ((event: ServerSentEvent) => void) | null,
|
|
361
|
-
dryRun?: boolean,
|
|
362
|
-
backend?: ElasticsearchBackend,
|
|
363
|
-
projectId?: string,
|
|
364
|
-
tuning?: {
|
|
365
|
-
shardSize?: number;
|
|
366
|
-
shards?: number;
|
|
367
|
-
bulkConcurrency?: number;
|
|
368
|
-
bulkSizeBytes?: number;
|
|
369
|
-
bulkMaxDocs?: number;
|
|
370
|
-
},
|
|
371
|
-
): Promise<ReindexViaBulkResult> {
|
|
372
|
-
const bulkUrl = `${this.zenoBulkBaseUrl}/reindex`;
|
|
373
|
-
const params = {
|
|
374
|
-
tenant_id: tenantId,
|
|
375
|
-
project_id: projectId,
|
|
376
|
-
dry_run: dryRun ?? false,
|
|
377
|
-
backend,
|
|
378
|
-
shard_size: tuning?.shardSize,
|
|
379
|
-
shards: tuning?.shards,
|
|
380
|
-
bulk_concurrency: tuning?.bulkConcurrency,
|
|
381
|
-
bulk_size_bytes: tuning?.bulkSizeBytes,
|
|
382
|
-
bulk_max_docs: tuning?.bulkMaxDocs,
|
|
383
|
-
} satisfies ReindexViaBulkRequest;
|
|
384
|
-
|
|
385
|
-
if (!onEvent) {
|
|
386
|
-
return this.client.post(bulkUrl, { payload: { params } });
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// SSE mode: stream progress events from zeno-bulk
|
|
390
|
-
let lastResult: ReindexViaBulkResult | undefined;
|
|
391
|
-
|
|
392
|
-
await this.client.sseRequest(
|
|
393
|
-
'POST',
|
|
394
|
-
bulkUrl,
|
|
395
|
-
{
|
|
396
|
-
payload: { params },
|
|
397
|
-
},
|
|
398
|
-
(event) => {
|
|
399
|
-
onEvent(event);
|
|
400
|
-
if (event.type === 'event' && event.event === 'done') {
|
|
401
|
-
try {
|
|
402
|
-
lastResult = JSON.parse(event.data) as ReindexViaBulkResult;
|
|
403
|
-
} catch {
|
|
404
|
-
// data might not be valid JSON
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
},
|
|
408
|
-
);
|
|
409
|
-
|
|
410
|
-
if (!lastResult) {
|
|
411
|
-
throw new Error('zeno-bulk SSE stream ended without a done event');
|
|
412
|
-
}
|
|
413
|
-
return lastResult;
|
|
414
|
-
}
|
|
415
346
|
}
|
package/src/store/client.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type RequestError,
|
|
6
6
|
} from '@vertesia/api-fetch-client';
|
|
7
7
|
import { APP_VERSION_HEADER, type BulkOperationPayload, type BulkOperationResponse } from '@vertesia/common';
|
|
8
|
+
import { warnUnknownOptions } from '../unknown-options.js';
|
|
8
9
|
import { AgentsApi } from './AgentsApi.js';
|
|
9
10
|
import { CollectionsApi } from './CollectionsApi.js';
|
|
10
11
|
import { CostApi } from './CostApi.js';
|
|
@@ -39,6 +40,18 @@ export interface ZenoClientProps {
|
|
|
39
40
|
fetch?: FETCH_FN | Promise<FETCH_FN>;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Exhaustive in both directions — see the same table in `../client.ts`. */
|
|
44
|
+
const KNOWN_STORE_OPTIONS: Record<keyof Required<ZenoClientProps>, true> = {
|
|
45
|
+
serverUrl: true,
|
|
46
|
+
tokenServerUrl: true,
|
|
47
|
+
apikey: true,
|
|
48
|
+
onRequest: true,
|
|
49
|
+
onResponse: true,
|
|
50
|
+
retryPolicy: true,
|
|
51
|
+
timeout: true,
|
|
52
|
+
fetch: true,
|
|
53
|
+
};
|
|
54
|
+
|
|
42
55
|
function ensureDefined(serverUrl: string | undefined) {
|
|
43
56
|
if (!serverUrl) {
|
|
44
57
|
throw new Error('zeno client serverUrl is required');
|
|
@@ -48,6 +61,7 @@ function ensureDefined(serverUrl: string | undefined) {
|
|
|
48
61
|
|
|
49
62
|
export class ZenoClient extends AbstractFetchClient<ZenoClient> {
|
|
50
63
|
constructor(opts: ZenoClientProps = {}) {
|
|
64
|
+
warnUnknownOptions('ZenoClient', opts, KNOWN_STORE_OPTIONS);
|
|
51
65
|
super(ensureDefined(opts.serverUrl), opts.fetch);
|
|
52
66
|
if (opts.apikey) {
|
|
53
67
|
this.withApiKey(opts.apikey);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client constructors take a plain options object, so an option name that does not exist is not a
|
|
3
|
+
* runtime error — it is silently dropped. TypeScript's excess-property check catches it for an
|
|
4
|
+
* object literal, but not for a spread, an `any`, or plain JavaScript, and the resulting client
|
|
5
|
+
* looks healthy right up to the first request. A credential passed under the wrong name produces a
|
|
6
|
+
* client with no `Authorization` header, so every call fails with
|
|
7
|
+
* `401 Unauthorized: Authorization token is required` and nothing points at the constructor.
|
|
8
|
+
*
|
|
9
|
+
* These helpers make that case say so, once per unknown option name.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Options that never existed, mapped to what the caller almost certainly meant. */
|
|
13
|
+
const OPTION_HINTS: Record<string, string> = {
|
|
14
|
+
token: 'pass the credential as `apikey`, or build the client with `VertesiaClient.fromAuthToken(token)`',
|
|
15
|
+
accessToken: 'pass the credential as `apikey`, or build the client with `VertesiaClient.fromAuthToken(token)`',
|
|
16
|
+
authToken: 'pass the credential as `apikey`, or build the client with `VertesiaClient.fromAuthToken(token)`',
|
|
17
|
+
apiKey: 'the option is spelled `apikey`, all lowercase',
|
|
18
|
+
api_key: 'the option is spelled `apikey`, all lowercase',
|
|
19
|
+
appVersion: 'pin the app version with the `withAppVersion(version)` method after construction',
|
|
20
|
+
apiVersion: 'pin the API version with the `withApiVersion(version)` method after construction',
|
|
21
|
+
projectId: 'the project is determined by the credential; this option was never read',
|
|
22
|
+
baseUrl: 'use `serverUrl` for studio and `storeUrl` for the store',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const warned = new Set<string>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Warn once per unknown option name. Deliberately a warning and not a throw: passing a harmless
|
|
29
|
+
* extra key is common (spreading a wider config object) and has never failed, so throwing would
|
|
30
|
+
* break working callers to report a mistake they may not have made.
|
|
31
|
+
*
|
|
32
|
+
* @param clientName the constructor name, used to prefix the message
|
|
33
|
+
* @param opts the options object as given by the caller
|
|
34
|
+
* @param known the options the constructor actually reads
|
|
35
|
+
*/
|
|
36
|
+
export function warnUnknownOptions(clientName: string, opts: object, known: Record<string, true>): void {
|
|
37
|
+
// `Object.hasOwn`, not `in`: `in` walks the prototype chain, so options named `toString`,
|
|
38
|
+
// `constructor` or `valueOf` would look like known options and go unreported.
|
|
39
|
+
const unknown = Object.keys(opts).filter((key) => !Object.hasOwn(known, key) && !warned.has(key));
|
|
40
|
+
if (unknown.length === 0) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const key of unknown) {
|
|
44
|
+
warned.add(key);
|
|
45
|
+
}
|
|
46
|
+
const details = unknown.map((key) => (Object.hasOwn(OPTION_HINTS, key) ? `${key} (${OPTION_HINTS[key]})` : key));
|
|
47
|
+
console.warn(
|
|
48
|
+
`[${clientName}] Ignoring unknown constructor option(s): ${details.join('; ')}. ` +
|
|
49
|
+
'Unknown options have no effect.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test-only: forget which option names have already been reported. */
|
|
54
|
+
export function resetUnknownOptionWarnings(): void {
|
|
55
|
+
warned.clear();
|
|
56
|
+
}
|