assistant-cloud 0.1.40 → 0.1.42

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 (57) hide show
  1. package/README.md +3 -3
  2. package/dist/AssistantCloud.js +2 -2
  3. package/dist/AssistantCloud.js.map +1 -1
  4. package/dist/AssistantCloudAPI.d.ts.map +1 -1
  5. package/dist/AssistantCloudAPI.js +5 -5
  6. package/dist/AssistantCloudAPI.js.map +1 -1
  7. package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
  8. package/dist/AssistantCloudAuthStrategy.js +35 -12
  9. package/dist/AssistantCloudAuthStrategy.js.map +1 -1
  10. package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
  11. package/dist/AssistantCloudAuthTokens.js +3 -1
  12. package/dist/AssistantCloudAuthTokens.js.map +1 -1
  13. package/dist/AssistantCloudFiles.d.ts.map +1 -1
  14. package/dist/AssistantCloudFiles.js +16 -4
  15. package/dist/AssistantCloudFiles.js.map +1 -1
  16. package/dist/AssistantCloudRuns.d.ts +3 -13
  17. package/dist/AssistantCloudRuns.d.ts.map +1 -1
  18. package/dist/AssistantCloudRuns.js +10 -2
  19. package/dist/AssistantCloudRuns.js.map +1 -1
  20. package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
  21. package/dist/AssistantCloudThreadMessages.js +3 -2
  22. package/dist/AssistantCloudThreadMessages.js.map +1 -1
  23. package/dist/AssistantCloudThreads.d.ts.map +1 -1
  24. package/dist/AssistantCloudThreads.js +3 -2
  25. package/dist/AssistantCloudThreads.js.map +1 -1
  26. package/dist/generateThreadTitle.d.ts +15 -0
  27. package/dist/generateThreadTitle.d.ts.map +1 -0
  28. package/dist/generateThreadTitle.js +25 -0
  29. package/dist/generateThreadTitle.js.map +1 -0
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +3 -1
  32. package/dist/runTelemetry.d.ts +54 -0
  33. package/dist/runTelemetry.d.ts.map +1 -0
  34. package/dist/runTelemetry.js +79 -0
  35. package/dist/runTelemetry.js.map +1 -0
  36. package/package.json +5 -5
  37. package/src/AssistantCloud.ts +1 -1
  38. package/src/AssistantCloudAPI.ts +9 -8
  39. package/src/AssistantCloudAuthStrategy.ts +49 -12
  40. package/src/AssistantCloudAuthTokens.test.ts +30 -0
  41. package/src/AssistantCloudAuthTokens.ts +7 -1
  42. package/src/AssistantCloudFiles.test.ts +71 -0
  43. package/src/AssistantCloudFiles.ts +56 -10
  44. package/src/AssistantCloudRuns.ts +37 -15
  45. package/src/AssistantCloudThreadMessages.test.ts +20 -0
  46. package/src/AssistantCloudThreadMessages.ts +10 -3
  47. package/src/AssistantCloudThreads.test.ts +17 -0
  48. package/src/AssistantCloudThreads.ts +6 -1
  49. package/src/generateThreadTitle.test.ts +71 -0
  50. package/src/generateThreadTitle.ts +38 -0
  51. package/src/index.ts +10 -0
  52. package/src/runTelemetry.test.ts +130 -0
  53. package/src/runTelemetry.ts +140 -0
  54. package/src/tests/AssistantCloud.test.ts +39 -0
  55. package/src/tests/AssistantCloudAPI.test.ts +25 -0
  56. package/src/tests/AssistantCloudAuthStrategy.test.ts +278 -5
  57. package/src/tests/AssistantCloudRuns.test.ts +115 -0
@@ -11,6 +11,7 @@ const refreshToken = {
11
11
  token: "r1",
12
12
  expires_at: "2099-01-01",
13
13
  };
14
+ const refreshTokenKey = `aui:refresh_token:${baseUrl}`;
14
15
 
15
16
  let originalLocalStorageDescriptor: PropertyDescriptor | undefined;
16
17
 
@@ -79,9 +80,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
79
80
  await expect(strategy.getAuthHeaders()).resolves.toEqual({
80
81
  Authorization: `Bearer ${accessToken}`,
81
82
  });
82
- expect(values.get("aui:refresh_token")).toBe(
83
- JSON.stringify(nextRefreshToken),
84
- );
83
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(nextRefreshToken));
85
84
  expect(fetchMock).toHaveBeenCalledWith(
86
85
  `${baseUrl}/v1/auth/tokens/anonymous`,
87
86
  { method: "POST" },
@@ -107,6 +106,183 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
107
106
  expect(fetchMock).toHaveBeenCalledTimes(1);
108
107
  });
109
108
 
109
+ it("scopes anonymous refresh tokens by backend", async () => {
110
+ const secondBaseUrl = "https://other.example.com";
111
+ const values = new Map<string, string>();
112
+ installLocalStorage({
113
+ getItem: (key) => values.get(key) ?? null,
114
+ setItem: (key, value) => {
115
+ values.set(key, value);
116
+ },
117
+ removeItem: (key) => {
118
+ values.delete(key);
119
+ },
120
+ } as Storage);
121
+ const secondRefreshToken = {
122
+ token: "r2",
123
+ expires_at: "2099-01-01",
124
+ };
125
+ const fetchMock = vi
126
+ .fn()
127
+ .mockResolvedValueOnce({
128
+ ok: true,
129
+ json: vi.fn().mockResolvedValue({
130
+ access_token: accessToken,
131
+ refresh_token: refreshToken,
132
+ }),
133
+ })
134
+ .mockResolvedValueOnce({
135
+ ok: true,
136
+ json: vi.fn().mockResolvedValue({
137
+ access_token: accessToken,
138
+ refresh_token: secondRefreshToken,
139
+ }),
140
+ });
141
+ vi.stubGlobal("fetch", fetchMock);
142
+
143
+ await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
144
+ await new AssistantCloudAnonymousAuthStrategy(
145
+ secondBaseUrl,
146
+ ).getAuthHeaders();
147
+
148
+ expect(fetchMock).toHaveBeenNthCalledWith(
149
+ 1,
150
+ `${baseUrl}/v1/auth/tokens/anonymous`,
151
+ { method: "POST" },
152
+ );
153
+ expect(fetchMock).toHaveBeenNthCalledWith(
154
+ 2,
155
+ `${secondBaseUrl}/v1/auth/tokens/anonymous`,
156
+ { method: "POST" },
157
+ );
158
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
159
+ expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
160
+ JSON.stringify(secondRefreshToken),
161
+ );
162
+ });
163
+
164
+ it("migrates the legacy refresh token to the first backend", async () => {
165
+ const secondBaseUrl = "https://other.example.com";
166
+ const secondRefreshToken = {
167
+ token: "r2",
168
+ expires_at: "2099-01-01",
169
+ };
170
+ const values = new Map([
171
+ ["aui:refresh_token", JSON.stringify(refreshToken)],
172
+ ]);
173
+ installLocalStorage({
174
+ getItem: (key) => values.get(key) ?? null,
175
+ setItem: (key, value) => {
176
+ values.set(key, value);
177
+ },
178
+ removeItem: (key) => {
179
+ values.delete(key);
180
+ },
181
+ } as Storage);
182
+ const fetchMock = vi
183
+ .fn()
184
+ .mockResolvedValueOnce({
185
+ ok: true,
186
+ json: vi.fn().mockResolvedValue({ access_token: accessToken }),
187
+ })
188
+ .mockResolvedValueOnce({
189
+ ok: true,
190
+ json: vi.fn().mockResolvedValue({
191
+ access_token: accessToken,
192
+ refresh_token: secondRefreshToken,
193
+ }),
194
+ });
195
+ vi.stubGlobal("fetch", fetchMock);
196
+
197
+ await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
198
+ await new AssistantCloudAnonymousAuthStrategy(
199
+ secondBaseUrl,
200
+ ).getAuthHeaders();
201
+
202
+ expect(fetchMock).toHaveBeenNthCalledWith(
203
+ 1,
204
+ `${baseUrl}/v1/auth/tokens/refresh`,
205
+ {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json" },
208
+ body: JSON.stringify({ refresh_token: refreshToken.token }),
209
+ },
210
+ );
211
+ expect(fetchMock).toHaveBeenNthCalledWith(
212
+ 2,
213
+ `${secondBaseUrl}/v1/auth/tokens/anonymous`,
214
+ { method: "POST" },
215
+ );
216
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
217
+ expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
218
+ JSON.stringify(secondRefreshToken),
219
+ );
220
+ expect(values.has("aui:refresh_token")).toBe(false);
221
+ });
222
+
223
+ it("retires the legacy refresh token when a scoped token exists", async () => {
224
+ const secondBaseUrl = "https://other.example.com";
225
+ const scopedRefreshToken = {
226
+ token: "scoped-r1",
227
+ expires_at: "2099-01-01",
228
+ };
229
+ const secondRefreshToken = {
230
+ token: "r2",
231
+ expires_at: "2099-01-01",
232
+ };
233
+ const values = new Map([
234
+ ["aui:refresh_token", JSON.stringify(refreshToken)],
235
+ [refreshTokenKey, JSON.stringify(scopedRefreshToken)],
236
+ ]);
237
+ installLocalStorage({
238
+ getItem: (key) => values.get(key) ?? null,
239
+ setItem: (key, value) => {
240
+ values.set(key, value);
241
+ },
242
+ removeItem: (key) => {
243
+ values.delete(key);
244
+ },
245
+ } as Storage);
246
+ const fetchMock = vi
247
+ .fn()
248
+ .mockResolvedValueOnce({
249
+ ok: true,
250
+ json: vi.fn().mockResolvedValue({ access_token: accessToken }),
251
+ })
252
+ .mockResolvedValueOnce({
253
+ ok: true,
254
+ json: vi.fn().mockResolvedValue({
255
+ access_token: accessToken,
256
+ refresh_token: secondRefreshToken,
257
+ }),
258
+ });
259
+ vi.stubGlobal("fetch", fetchMock);
260
+
261
+ await new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders();
262
+ await new AssistantCloudAnonymousAuthStrategy(
263
+ secondBaseUrl,
264
+ ).getAuthHeaders();
265
+
266
+ expect(fetchMock).toHaveBeenNthCalledWith(
267
+ 1,
268
+ `${baseUrl}/v1/auth/tokens/refresh`,
269
+ {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/json" },
272
+ body: JSON.stringify({ refresh_token: scopedRefreshToken.token }),
273
+ },
274
+ );
275
+ expect(fetchMock).toHaveBeenNthCalledWith(
276
+ 2,
277
+ `${secondBaseUrl}/v1/auth/tokens/anonymous`,
278
+ { method: "POST" },
279
+ );
280
+ expect(values.has("aui:refresh_token")).toBe(false);
281
+ expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
282
+ JSON.stringify(secondRefreshToken),
283
+ );
284
+ });
285
+
110
286
  it("returns an anonymous access token without localStorage", async () => {
111
287
  delete (globalThis as { localStorage?: Storage }).localStorage;
112
288
  mockAnonymousTokenFetch();
@@ -160,7 +336,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
160
336
  ).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
161
337
  expect(getItem).toHaveBeenCalledTimes(2);
162
338
  expect(setItem).toHaveBeenCalledTimes(2);
163
- expect(removeItem).toHaveBeenCalledTimes(1);
339
+ expect(removeItem).toHaveBeenCalledTimes(2);
164
340
  });
165
341
 
166
342
  it("treats corrupted refresh token JSON as absent", async () => {
@@ -181,7 +357,8 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
181
357
  await expect(strategy.getAuthHeaders()).resolves.toEqual({
182
358
  Authorization: `Bearer ${accessToken}`,
183
359
  });
184
- expect(values.get("aui:refresh_token")).toBe(JSON.stringify(refreshToken));
360
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
361
+ expect(values.has("aui:refresh_token")).toBe(false);
185
362
  });
186
363
 
187
364
  it("rejects malformed anonymous token responses without persisting them", async () => {
@@ -298,6 +475,102 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
298
475
  expect(setItem).not.toHaveBeenCalled();
299
476
  });
300
477
 
478
+ it.each([429, 500, 503])(
479
+ "preserves the anonymous identity after transient status %i",
480
+ async (status) => {
481
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
482
+ installLocalStorage({
483
+ getItem: (key) => values.get(key) ?? null,
484
+ setItem: (key, value) => {
485
+ values.set(key, value);
486
+ },
487
+ removeItem: (key) => {
488
+ values.delete(key);
489
+ },
490
+ } as Storage);
491
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status });
492
+ vi.stubGlobal("fetch", fetchMock);
493
+
494
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
495
+
496
+ await expect(strategy.getAuthHeaders()).rejects.toThrow(
497
+ `Assistant Cloud token refresh failed with status ${status}`,
498
+ );
499
+ expect(fetchMock).toHaveBeenCalledTimes(1);
500
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
501
+ },
502
+ );
503
+
504
+ it.each([401, 403])(
505
+ "replaces an anonymous identity after refresh is rejected with %i",
506
+ async (status) => {
507
+ const replacementRefreshToken = {
508
+ token: "r2",
509
+ expires_at: "2099-02-01",
510
+ };
511
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
512
+ installLocalStorage({
513
+ getItem: (key) => values.get(key) ?? null,
514
+ setItem: (key, value) => {
515
+ values.set(key, value);
516
+ },
517
+ removeItem: (key) => {
518
+ values.delete(key);
519
+ },
520
+ } as Storage);
521
+ const fetchMock = vi
522
+ .fn()
523
+ .mockResolvedValueOnce({ ok: false, status })
524
+ .mockResolvedValueOnce({
525
+ ok: true,
526
+ json: vi.fn().mockResolvedValue({
527
+ access_token: accessToken,
528
+ refresh_token: replacementRefreshToken,
529
+ }),
530
+ });
531
+ vi.stubGlobal("fetch", fetchMock);
532
+
533
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
534
+
535
+ await expect(strategy.getAuthHeaders()).resolves.toEqual({
536
+ Authorization: `Bearer ${accessToken}`,
537
+ });
538
+ expect(fetchMock).toHaveBeenNthCalledWith(
539
+ 2,
540
+ `${baseUrl}/v1/auth/tokens/anonymous`,
541
+ { method: "POST" },
542
+ );
543
+ expect(values.get(refreshTokenKey)).toBe(
544
+ JSON.stringify(replacementRefreshToken),
545
+ );
546
+ },
547
+ );
548
+
549
+ it("preserves a rejected refresh token until its replacement succeeds", async () => {
550
+ const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
551
+ installLocalStorage({
552
+ getItem: (key) => values.get(key) ?? null,
553
+ setItem: (key, value) => {
554
+ values.set(key, value);
555
+ },
556
+ removeItem: (key) => {
557
+ values.delete(key);
558
+ },
559
+ } as Storage);
560
+ vi.stubGlobal(
561
+ "fetch",
562
+ vi
563
+ .fn()
564
+ .mockResolvedValueOnce({ ok: false, status: 403 })
565
+ .mockResolvedValueOnce({ ok: false, status: 503 }),
566
+ );
567
+
568
+ const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
569
+
570
+ await expect(strategy.getAuthHeaders()).resolves.toBe(false);
571
+ expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
572
+ });
573
+
301
574
  it("contextualizes invalid JSON token responses", async () => {
302
575
  delete (globalThis as { localStorage?: Storage }).localStorage;
303
576
  vi.stubGlobal(
@@ -0,0 +1,115 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { AssistantCloudAPI } from "../AssistantCloudAPI";
3
+ import { AssistantCloud } from "../AssistantCloud";
4
+ import { AssistantCloudRuns } from "../AssistantCloudRuns";
5
+ import { CloudResponseError } from "../cloudResponse";
6
+
7
+ const createCloud = () =>
8
+ new AssistantCloud({
9
+ apiKey: "test-key",
10
+ userId: "user-id",
11
+ workspaceId: "workspace-id",
12
+ });
13
+
14
+ const streamBody = {
15
+ thread_id: "thread-id",
16
+ assistant_id: "system/thread_title" as const,
17
+ messages: [],
18
+ };
19
+
20
+ describe("AssistantCloudRuns", () => {
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ it("decodes text/plain run streams", async () => {
26
+ vi.stubGlobal(
27
+ "fetch",
28
+ vi.fn().mockResolvedValue(
29
+ new Response("Generated title", {
30
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
31
+ }),
32
+ ),
33
+ );
34
+ const stream = await createCloud().runs.stream(streamBody);
35
+ let text = "";
36
+
37
+ await stream.pipeTo(
38
+ new WritableStream({
39
+ write(chunk) {
40
+ if (chunk.type === "text-delta") text += chunk.textDelta;
41
+ },
42
+ }),
43
+ );
44
+
45
+ expect(text).toBe("Generated title");
46
+ });
47
+
48
+ it("rejects successful run responses without a body", async () => {
49
+ vi.stubGlobal(
50
+ "fetch",
51
+ vi.fn().mockResolvedValue(new Response(null, { status: 204 })),
52
+ );
53
+
54
+ await expect(createCloud().runs.stream(streamBody)).rejects.toThrow(
55
+ new CloudResponseError(
56
+ 'Invalid Assistant Cloud response for "run stream": expected a response body',
57
+ ),
58
+ );
59
+ });
60
+
61
+ it("rejects and cancels run responses with the wrong content type", async () => {
62
+ const cancel = vi.fn();
63
+ const body = new ReadableStream({ cancel });
64
+ vi.stubGlobal(
65
+ "fetch",
66
+ vi.fn().mockResolvedValue(
67
+ new Response(body, {
68
+ headers: { "Content-Type": "text/html; charset=utf-8" },
69
+ }),
70
+ ),
71
+ );
72
+
73
+ await expect(createCloud().runs.stream(streamBody)).rejects.toThrow(
74
+ new CloudResponseError(
75
+ 'Invalid Assistant Cloud response for "run stream": expected a "text/plain" content type, received "text/html; charset=utf-8"',
76
+ ),
77
+ );
78
+ expect(cancel).toHaveBeenCalledOnce();
79
+ });
80
+
81
+ it("rejects and cancels run responses without a content type", async () => {
82
+ const cancel = vi.fn();
83
+ const body = new ReadableStream({ cancel });
84
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body)));
85
+
86
+ await expect(createCloud().runs.stream(streamBody)).rejects.toThrow(
87
+ new CloudResponseError(
88
+ 'Invalid Assistant Cloud response for "run stream": expected a "text/plain" content type, received no Content-Type header',
89
+ ),
90
+ );
91
+ expect(cancel).toHaveBeenCalledOnce();
92
+ });
93
+ });
94
+
95
+ const createCloudRuns = () => {
96
+ const makeRequest = vi.fn();
97
+ const api = { makeRequest } as unknown as AssistantCloudAPI;
98
+ return { runs: new AssistantCloudRuns(api), makeRequest };
99
+ };
100
+
101
+ describe("AssistantCloudRuns responses", () => {
102
+ it("validates reported run IDs", async () => {
103
+ const { runs, makeRequest } = createCloudRuns();
104
+ const body = { thread_id: "thread-1", status: "completed" } as const;
105
+ makeRequest.mockResolvedValueOnce({ run_id: "run-1" });
106
+
107
+ await expect(runs.report(body)).resolves.toEqual({ run_id: "run-1" });
108
+
109
+ makeRequest.mockResolvedValueOnce({});
110
+
111
+ await expect(runs.report(body)).rejects.toThrow(
112
+ 'Invalid Assistant Cloud response for "run_id": expected a string',
113
+ );
114
+ });
115
+ });