assistant-cloud 0.1.43 → 0.2.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/README.md +40 -0
- package/dist/AssistantCloud.d.ts +4 -0
- package/dist/AssistantCloud.d.ts.map +1 -1
- package/dist/AssistantCloud.js +11 -5
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts +17 -1
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +20 -9
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts +4 -1
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +11 -1
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudEvents.d.ts +32 -0
- package/dist/AssistantCloudEvents.d.ts.map +1 -0
- package/dist/AssistantCloudEvents.js +115 -0
- package/dist/AssistantCloudEvents.js.map +1 -0
- package/dist/AssistantCloudFiles.d.ts +12 -1
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudFiles.js +13 -1
- package/dist/AssistantCloudFiles.js.map +1 -1
- package/dist/AssistantCloudRuns.d.ts +11 -0
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudScores.d.ts +27 -0
- package/dist/AssistantCloudScores.d.ts.map +1 -0
- package/dist/AssistantCloudScores.js +30 -0
- package/dist/AssistantCloudScores.js.map +1 -0
- package/dist/AssistantCloudThreadMessages.d.ts +11 -1
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +12 -1
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts +8 -0
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/AssistantCloudThreads.js +9 -1
- package/dist/AssistantCloudThreads.js.map +1 -1
- package/dist/CloudMessagePersistence.d.ts +4 -0
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +38 -11
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/cloudResponse.d.ts +4 -1
- package/dist/cloudResponse.d.ts.map +1 -1
- package/dist/cloudResponse.js +14 -1
- package/dist/cloudResponse.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -2
- package/dist/runTelemetry.d.ts +70 -7
- package/dist/runTelemetry.d.ts.map +1 -1
- package/dist/runTelemetry.js +144 -1
- package/dist/runTelemetry.js.map +1 -1
- package/dist/telemetry/index.d.ts +32 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +62 -0
- package/dist/telemetry/index.js.map +1 -0
- package/package.json +28 -4
- package/src/AssistantCloud.ts +20 -8
- package/src/AssistantCloudAPI.ts +42 -10
- package/src/AssistantCloudAuthStrategy.ts +13 -0
- package/src/AssistantCloudEvents.test.ts +124 -0
- package/src/AssistantCloudEvents.ts +185 -0
- package/src/AssistantCloudFiles.test.ts +59 -0
- package/src/AssistantCloudFiles.ts +39 -0
- package/src/AssistantCloudRuns.ts +12 -1
- package/src/AssistantCloudScores.test.ts +113 -0
- package/src/AssistantCloudScores.ts +61 -0
- package/src/AssistantCloudThreadMessages.test.ts +51 -0
- package/src/AssistantCloudThreadMessages.ts +33 -0
- package/src/AssistantCloudThreads.test.ts +21 -0
- package/src/AssistantCloudThreads.ts +21 -0
- package/src/CloudMessagePersistence.ts +48 -16
- package/src/cloudResponse.test.ts +34 -1
- package/src/cloudResponse.ts +30 -0
- package/src/index.ts +24 -1
- package/src/runTelemetry.test.ts +325 -0
- package/src/runTelemetry.ts +257 -6
- package/src/telemetry/index.test.ts +252 -0
- package/src/telemetry/index.ts +98 -0
- package/src/tests/AssistantCloud.test.ts +22 -0
- package/src/tests/AssistantCloudAPI.test.ts +44 -4
- package/src/tests/AssistantCloudAuthStrategy.test.ts +71 -2
- package/src/tests/CloudMessagePersistence.test.ts +115 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ROOT_CONTEXT,
|
|
3
|
+
context,
|
|
4
|
+
trace,
|
|
5
|
+
type Context,
|
|
6
|
+
type ContextManager,
|
|
7
|
+
} from "@opentelemetry/api";
|
|
8
|
+
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
assistantCloudTraceExportOptions,
|
|
12
|
+
assistantCloudTraceMetadata,
|
|
13
|
+
createAssistantCloudSpanProcessor,
|
|
14
|
+
isAssistantCloudSpan,
|
|
15
|
+
withAssistantCloudTraceMetadata,
|
|
16
|
+
} from "./index";
|
|
17
|
+
|
|
18
|
+
const TRACE_ID = "0123456789abcdef0123456789abcdef";
|
|
19
|
+
const SPAN_ID = "0123456789abcdef";
|
|
20
|
+
|
|
21
|
+
class TestContextManager implements ContextManager {
|
|
22
|
+
private activeContext: Context = ROOT_CONTEXT;
|
|
23
|
+
|
|
24
|
+
active(): Context {
|
|
25
|
+
return this.activeContext;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
|
|
29
|
+
nextContext: Context,
|
|
30
|
+
fn: F,
|
|
31
|
+
thisArg?: ThisParameterType<F>,
|
|
32
|
+
...args: A
|
|
33
|
+
): ReturnType<F> {
|
|
34
|
+
const previousContext = this.activeContext;
|
|
35
|
+
this.activeContext = nextContext;
|
|
36
|
+
try {
|
|
37
|
+
return fn.call(thisArg, ...args);
|
|
38
|
+
} finally {
|
|
39
|
+
this.activeContext = previousContext;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
bind<T>(_context: Context, target: T): T {
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
enable(): this {
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
disable(): this {
|
|
52
|
+
this.activeContext = ROOT_CONTEXT;
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function span(
|
|
58
|
+
name: string,
|
|
59
|
+
attributes: Record<string, unknown> = {},
|
|
60
|
+
): ReadableSpan {
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
attributes,
|
|
64
|
+
spanContext: () => ({
|
|
65
|
+
traceId: TRACE_ID,
|
|
66
|
+
spanId: SPAN_ID,
|
|
67
|
+
traceFlags: 1,
|
|
68
|
+
}),
|
|
69
|
+
resource: { asyncAttributesPending: false },
|
|
70
|
+
} as ReadableSpan;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function recordingExporter() {
|
|
74
|
+
const spans: ReadableSpan[] = [];
|
|
75
|
+
const exporter = {
|
|
76
|
+
export: vi.fn(
|
|
77
|
+
(
|
|
78
|
+
nextSpans: ReadableSpan[],
|
|
79
|
+
resultCallback: Parameters<SpanExporter["export"]>[1],
|
|
80
|
+
) => {
|
|
81
|
+
spans.push(...nextSpans);
|
|
82
|
+
resultCallback({ code: 0 });
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
86
|
+
} satisfies SpanExporter;
|
|
87
|
+
|
|
88
|
+
return { exporter, spans };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeAll(() => {
|
|
92
|
+
context.disable();
|
|
93
|
+
context.setGlobalContextManager(new TestContextManager());
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(() => {
|
|
97
|
+
context.disable();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("assistantCloudTraceExportOptions", () => {
|
|
101
|
+
it("uses the default receiver and authorization header", () => {
|
|
102
|
+
expect(assistantCloudTraceExportOptions({ apiKey: "key" })).toEqual({
|
|
103
|
+
url: "https://backend.assistant-api.com/v1/traces",
|
|
104
|
+
headers: { Authorization: "Bearer key" },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("uses a custom receiver without a trailing slash", () => {
|
|
109
|
+
expect(
|
|
110
|
+
assistantCloudTraceExportOptions({
|
|
111
|
+
apiKey: "key",
|
|
112
|
+
baseUrl: "https://cloud.example.com",
|
|
113
|
+
}),
|
|
114
|
+
).toEqual({
|
|
115
|
+
url: "https://cloud.example.com/v1/traces",
|
|
116
|
+
headers: { Authorization: "Bearer key" },
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("removes trailing slashes and preserves extra headers", () => {
|
|
121
|
+
expect(
|
|
122
|
+
assistantCloudTraceExportOptions({
|
|
123
|
+
apiKey: "key",
|
|
124
|
+
baseUrl: "https://cloud.example.com/",
|
|
125
|
+
headers: { "X-Workspace": "workspace_1" },
|
|
126
|
+
}),
|
|
127
|
+
).toEqual({
|
|
128
|
+
url: "https://cloud.example.com/v1/traces",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: "Bearer key",
|
|
131
|
+
"X-Workspace": "workspace_1",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects an empty API key", () => {
|
|
137
|
+
expect(() => assistantCloudTraceExportOptions({ apiKey: "" })).toThrow(
|
|
138
|
+
"An Assistant Cloud API key is required",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("isAssistantCloudSpan", () => {
|
|
144
|
+
it("accepts AI span name prefixes", () => {
|
|
145
|
+
expect(isAssistantCloudSpan(span("gen_ai.client"))).toBe(true);
|
|
146
|
+
expect(isAssistantCloudSpan(span("ai.streamText"))).toBe(true);
|
|
147
|
+
expect(isAssistantCloudSpan(span("llm.generate"))).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("accepts the GenAI operation attribute", () => {
|
|
151
|
+
expect(
|
|
152
|
+
isAssistantCloudSpan(
|
|
153
|
+
span("generate", { "gen_ai.operation.name": "ai.generateText" }),
|
|
154
|
+
),
|
|
155
|
+
).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects ordinary spans", () => {
|
|
159
|
+
expect(isAssistantCloudSpan(span("GET /api/chat"))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("createAssistantCloudSpanProcessor", () => {
|
|
164
|
+
it("exports matching spans and drops other spans", async () => {
|
|
165
|
+
const { exporter, spans } = recordingExporter();
|
|
166
|
+
const processor = createAssistantCloudSpanProcessor(exporter);
|
|
167
|
+
|
|
168
|
+
processor.onEnd(span("gen_ai.client"));
|
|
169
|
+
processor.onEnd(span("GET /api/chat"));
|
|
170
|
+
await processor.forceFlush();
|
|
171
|
+
|
|
172
|
+
expect(spans.map((candidate) => candidate.name)).toEqual(["gen_ai.client"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("uses a custom filter when one is provided", async () => {
|
|
176
|
+
const { exporter, spans } = recordingExporter();
|
|
177
|
+
const processor = createAssistantCloudSpanProcessor(exporter, {
|
|
178
|
+
filter: (candidate) => candidate.name === "custom",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
processor.onEnd(span("gen_ai.client"));
|
|
182
|
+
processor.onEnd(span("custom"));
|
|
183
|
+
await processor.forceFlush();
|
|
184
|
+
|
|
185
|
+
expect(spans.map((candidate) => candidate.name)).toEqual(["custom"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("flushes and shuts down the wrapped processor", async () => {
|
|
189
|
+
const { exporter } = recordingExporter();
|
|
190
|
+
const processor = createAssistantCloudSpanProcessor(exporter);
|
|
191
|
+
|
|
192
|
+
await processor.forceFlush();
|
|
193
|
+
await processor.shutdown();
|
|
194
|
+
|
|
195
|
+
expect(exporter.shutdown).toHaveBeenCalledTimes(1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("assistantCloudTraceMetadata", () => {
|
|
200
|
+
it("returns the active trace ID", () => {
|
|
201
|
+
const result = context.with(
|
|
202
|
+
trace.setSpanContext(context.active(), {
|
|
203
|
+
traceId: TRACE_ID,
|
|
204
|
+
spanId: SPAN_ID,
|
|
205
|
+
traceFlags: 1,
|
|
206
|
+
}),
|
|
207
|
+
assistantCloudTraceMetadata,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(result).toEqual({ traceId: TRACE_ID });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns an empty object without an active span", () => {
|
|
214
|
+
expect(assistantCloudTraceMetadata()).toEqual({});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("withAssistantCloudTraceMetadata", () => {
|
|
219
|
+
it("merges the active trace ID into start metadata", () => {
|
|
220
|
+
const messageMetadata = withAssistantCloudTraceMetadata(({ part }) => ({
|
|
221
|
+
type: part.type,
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
const result = context.with(
|
|
225
|
+
trace.setSpanContext(context.active(), {
|
|
226
|
+
traceId: TRACE_ID,
|
|
227
|
+
spanId: SPAN_ID,
|
|
228
|
+
traceFlags: 1,
|
|
229
|
+
}),
|
|
230
|
+
() => messageMetadata({ part: { type: "start" } }),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(result).toEqual({ type: "start", traceId: TRACE_ID });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("passes through start metadata when no span is active", () => {
|
|
237
|
+
const value = { started: true };
|
|
238
|
+
const messageMetadata = withAssistantCloudTraceMetadata(() => value);
|
|
239
|
+
|
|
240
|
+
expect(messageMetadata({ part: { type: "start" } })).toBe(value);
|
|
241
|
+
expect(withAssistantCloudTraceMetadata()({ part: { type: "start" } })).toBe(
|
|
242
|
+
undefined,
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("passes through metadata for other parts", () => {
|
|
247
|
+
const value = { usage: { totalTokens: 42 } };
|
|
248
|
+
const messageMetadata = withAssistantCloudTraceMetadata(() => value);
|
|
249
|
+
|
|
250
|
+
expect(messageMetadata({ part: { type: "finish" } })).toBe(value);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { trace } from "@opentelemetry/api";
|
|
2
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
3
|
+
import {
|
|
4
|
+
BatchSpanProcessor,
|
|
5
|
+
type ReadableSpan,
|
|
6
|
+
type SpanExporter,
|
|
7
|
+
type SpanProcessor,
|
|
8
|
+
} from "@opentelemetry/sdk-trace-base";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BACKEND_BASE_URL = "https://backend.assistant-api.com";
|
|
11
|
+
const AI_SPAN_PREFIXES = ["gen_ai.", "ai.", "llm."];
|
|
12
|
+
|
|
13
|
+
export type AssistantCloudTraceExportOptions = {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AssistantCloudSpanProcessorOptions = {
|
|
20
|
+
filter?: (span: ReadableSpan) => boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function assistantCloudTraceExportOptions({
|
|
24
|
+
apiKey,
|
|
25
|
+
baseUrl = DEFAULT_BACKEND_BASE_URL,
|
|
26
|
+
headers,
|
|
27
|
+
}: AssistantCloudTraceExportOptions): {
|
|
28
|
+
url: string;
|
|
29
|
+
headers: Record<string, string>;
|
|
30
|
+
} {
|
|
31
|
+
if (!apiKey) throw new Error("An Assistant Cloud API key is required");
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
url: `${trimTrailingSlashes(baseUrl)}/v1/traces`,
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${apiKey}`,
|
|
37
|
+
...headers,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function trimTrailingSlashes(url: string): string {
|
|
43
|
+
let end = url.length;
|
|
44
|
+
while (end > 0 && url[end - 1] === "/") end--;
|
|
45
|
+
return url.slice(0, end);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createAssistantCloudTraceExporter(
|
|
49
|
+
options: AssistantCloudTraceExportOptions,
|
|
50
|
+
): SpanExporter {
|
|
51
|
+
return new OTLPTraceExporter(assistantCloudTraceExportOptions(options));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isAssistantCloudSpan(span: ReadableSpan): boolean {
|
|
55
|
+
return (
|
|
56
|
+
typeof span.attributes["gen_ai.operation.name"] === "string" ||
|
|
57
|
+
AI_SPAN_PREFIXES.some((prefix) => span.name.startsWith(prefix))
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createAssistantCloudSpanProcessor(
|
|
62
|
+
exporter: SpanExporter,
|
|
63
|
+
options: AssistantCloudSpanProcessorOptions = {},
|
|
64
|
+
): SpanProcessor {
|
|
65
|
+
const processor = new BatchSpanProcessor(exporter);
|
|
66
|
+
const filter = options.filter ?? isAssistantCloudSpan;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
onStart: (span, parentContext) => processor.onStart(span, parentContext),
|
|
70
|
+
onEnd: (span) => {
|
|
71
|
+
if (filter(span)) processor.onEnd(span);
|
|
72
|
+
},
|
|
73
|
+
forceFlush: () => processor.forceFlush(),
|
|
74
|
+
shutdown: () => processor.shutdown(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assistantCloudTraceMetadata(): { traceId?: string } {
|
|
79
|
+
const spanContext = trace.getActiveSpan()?.spanContext();
|
|
80
|
+
return spanContext && trace.isSpanContextValid(spanContext)
|
|
81
|
+
? { traceId: spanContext.traceId }
|
|
82
|
+
: {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function withAssistantCloudTraceMetadata<
|
|
86
|
+
Part extends { type: string } = { type: string },
|
|
87
|
+
>(
|
|
88
|
+
messageMetadata?: (options: {
|
|
89
|
+
part: Part;
|
|
90
|
+
}) => Record<string, unknown> | undefined,
|
|
91
|
+
): (options: { part: Part }) => Record<string, unknown> | undefined {
|
|
92
|
+
return (options) => {
|
|
93
|
+
const metadata = messageMetadata?.(options);
|
|
94
|
+
if (options.part.type !== "start") return metadata;
|
|
95
|
+
const trace = assistantCloudTraceMetadata();
|
|
96
|
+
return trace.traceId === undefined ? metadata : { ...metadata, ...trace };
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -23,6 +23,13 @@ describe("AssistantCloud telemetry config", () => {
|
|
|
23
23
|
expect(createCloud({ enabled: false }).telemetry.enabled).toBe(false);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it("can disable engagement events without disabling run reports", () => {
|
|
27
|
+
expect(createCloud({ events: false }).telemetry).toEqual({
|
|
28
|
+
enabled: true,
|
|
29
|
+
events: false,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
26
33
|
it("stays enabled when the config object carries an undefined enabled", () => {
|
|
27
34
|
const beforeReport: NonNullable<
|
|
28
35
|
AssistantCloudTelemetryConfig["beforeReport"]
|
|
@@ -36,4 +43,19 @@ describe("AssistantCloud telemetry config", () => {
|
|
|
36
43
|
expect(telemetry.enabled).toBe(true);
|
|
37
44
|
expect(telemetry.beforeReport).toBe(beforeReport);
|
|
38
45
|
});
|
|
46
|
+
|
|
47
|
+
it("preserves configured run report dimensions", () => {
|
|
48
|
+
expect(
|
|
49
|
+
createCloud({
|
|
50
|
+
release: "web-2026.09.08",
|
|
51
|
+
environment: "production",
|
|
52
|
+
tags: ["region:sg", "tier:paid"],
|
|
53
|
+
}).telemetry,
|
|
54
|
+
).toEqual({
|
|
55
|
+
enabled: true,
|
|
56
|
+
release: "web-2026.09.08",
|
|
57
|
+
environment: "production",
|
|
58
|
+
tags: ["region:sg", "tier:paid"],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
39
61
|
});
|
|
@@ -143,7 +143,7 @@ describe("AssistantCloudAPI", () => {
|
|
|
143
143
|
);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it("
|
|
146
|
+
it("preserves the current error shape for JSON error responses without a code", async () => {
|
|
147
147
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
148
148
|
ok: false,
|
|
149
149
|
status: 400,
|
|
@@ -167,16 +167,54 @@ describe("AssistantCloudAPI", () => {
|
|
|
167
167
|
expect(error.name).toBe("CloudAPIError");
|
|
168
168
|
expect(error.message).toBe("invalid request payload");
|
|
169
169
|
expect(error.status).toBe(400);
|
|
170
|
+
expect(error.code).toBeUndefined();
|
|
171
|
+
expect(error.details).toBeUndefined();
|
|
170
172
|
});
|
|
171
173
|
|
|
172
|
-
it("
|
|
174
|
+
it("exposes the plan-limit error code and details", async () => {
|
|
175
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
176
|
+
ok: false,
|
|
177
|
+
status: 402,
|
|
178
|
+
headers: new Headers(),
|
|
179
|
+
text: vi.fn().mockResolvedValue(
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
error: "plan_limit_reached",
|
|
182
|
+
plan: "free",
|
|
183
|
+
period_end: "2026-10-01T00:00:00.000Z",
|
|
184
|
+
cap: 100,
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
});
|
|
188
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
189
|
+
|
|
190
|
+
const api = new AssistantCloudAPI({
|
|
191
|
+
apiKey: "test-key",
|
|
192
|
+
userId: "u-1",
|
|
193
|
+
workspaceId: "w-1",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const error = await api.makeRawRequest("/threads").catch((e) => e);
|
|
197
|
+
expect(error).toBeInstanceOf(CloudAPIError);
|
|
198
|
+
expect(error.message).toBe(
|
|
199
|
+
'Request failed with status 402, {"error":"plan_limit_reached","plan":"free","period_end":"2026-10-01T00:00:00.000Z","cap":100}',
|
|
200
|
+
);
|
|
201
|
+
expect(error.status).toBe(402);
|
|
202
|
+
expect(error.code).toBe("plan_limit_reached");
|
|
203
|
+
expect(error.details).toEqual({
|
|
204
|
+
plan: "free",
|
|
205
|
+
period_end: "2026-10-01T00:00:00.000Z",
|
|
206
|
+
cap: 100,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("exposes rate-limit error codes with empty details", async () => {
|
|
173
211
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
174
212
|
ok: false,
|
|
175
213
|
status: 429,
|
|
176
214
|
headers: new Headers(),
|
|
177
215
|
text: vi
|
|
178
216
|
.fn()
|
|
179
|
-
.mockResolvedValue(JSON.stringify({ error: "
|
|
217
|
+
.mockResolvedValue(JSON.stringify({ error: "rate_limited" })),
|
|
180
218
|
});
|
|
181
219
|
vi.stubGlobal("fetch", fetchMock);
|
|
182
220
|
|
|
@@ -189,9 +227,11 @@ describe("AssistantCloudAPI", () => {
|
|
|
189
227
|
const error = await api.makeRawRequest("/threads").catch((e) => e);
|
|
190
228
|
expect(error).toBeInstanceOf(CloudAPIError);
|
|
191
229
|
expect(error.message).toBe(
|
|
192
|
-
'Request failed with status 429, {"error":"
|
|
230
|
+
'Request failed with status 429, {"error":"rate_limited"}',
|
|
193
231
|
);
|
|
194
232
|
expect(error.status).toBe(429);
|
|
233
|
+
expect(error.code).toBe("rate_limited");
|
|
234
|
+
expect(error.details).toEqual({});
|
|
195
235
|
});
|
|
196
236
|
|
|
197
237
|
it("throws generic error with status for non-JSON error responses", async () => {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
AssistantCloudAnonymousAuthStrategy,
|
|
4
5
|
AssistantCloudJWTAuthStrategy,
|
|
6
|
+
readAnonymousRefreshToken,
|
|
5
7
|
} from "../AssistantCloudAuthStrategy";
|
|
6
8
|
import { CloudResponseError } from "../cloudResponse";
|
|
7
9
|
|
|
@@ -56,6 +58,73 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
56
58
|
}
|
|
57
59
|
});
|
|
58
60
|
|
|
61
|
+
it("reads the stored anonymous refresh token", () => {
|
|
62
|
+
const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
|
|
63
|
+
installLocalStorage({
|
|
64
|
+
getItem: (key) => values.get(key) ?? null,
|
|
65
|
+
setItem: (key, value) => {
|
|
66
|
+
values.set(key, value);
|
|
67
|
+
},
|
|
68
|
+
removeItem: (key) => {
|
|
69
|
+
values.delete(key);
|
|
70
|
+
},
|
|
71
|
+
} as Storage);
|
|
72
|
+
|
|
73
|
+
expect(readAnonymousRefreshToken(baseUrl)).toBe(refreshToken.token);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("reads the token stored for a base url given with a trailing slash", () => {
|
|
77
|
+
// The two-step cast below erases the contextual Storage typing the plain
|
|
78
|
+
// `as Storage` sites get, so this parameter needs its own annotation.
|
|
79
|
+
installLocalStorage({
|
|
80
|
+
getItem: (key: string) =>
|
|
81
|
+
key === refreshTokenKey ? JSON.stringify(refreshToken) : null,
|
|
82
|
+
setItem: vi.fn(),
|
|
83
|
+
removeItem: vi.fn(),
|
|
84
|
+
} as unknown as Storage);
|
|
85
|
+
|
|
86
|
+
expect(readAnonymousRefreshToken(`${baseUrl}/`)).toBe(refreshToken.token);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null when no anonymous refresh token is stored", () => {
|
|
90
|
+
installLocalStorage({
|
|
91
|
+
getItem: () => null,
|
|
92
|
+
setItem: vi.fn(),
|
|
93
|
+
removeItem: vi.fn(),
|
|
94
|
+
} as unknown as Storage);
|
|
95
|
+
|
|
96
|
+
expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each([30_000, 0, -1])(
|
|
100
|
+
"returns null when the stored anonymous refresh token expires in %i ms",
|
|
101
|
+
(expiresIn) => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
const now = Date.UTC(2026, 8, 4, 12, 0, 0);
|
|
104
|
+
vi.setSystemTime(now);
|
|
105
|
+
const values = new Map([
|
|
106
|
+
[
|
|
107
|
+
refreshTokenKey,
|
|
108
|
+
JSON.stringify({
|
|
109
|
+
token: refreshToken.token,
|
|
110
|
+
expires_at: new Date(now + expiresIn).toISOString(),
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
]);
|
|
114
|
+
installLocalStorage({
|
|
115
|
+
getItem: (key) => values.get(key) ?? null,
|
|
116
|
+
setItem: (key, value) => {
|
|
117
|
+
values.set(key, value);
|
|
118
|
+
},
|
|
119
|
+
removeItem: (key) => {
|
|
120
|
+
values.delete(key);
|
|
121
|
+
},
|
|
122
|
+
} as Storage);
|
|
123
|
+
|
|
124
|
+
expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
59
128
|
it.each([
|
|
60
129
|
"2099-01-01",
|
|
61
130
|
"2099-01-01T00:00:00Z",
|
|
@@ -269,7 +338,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
269
338
|
{ once: true },
|
|
270
339
|
);
|
|
271
340
|
}),
|
|
272
|
-
} as Response),
|
|
341
|
+
} as unknown as Response),
|
|
273
342
|
)
|
|
274
343
|
.mockResolvedValueOnce({
|
|
275
344
|
ok: true,
|
|
@@ -598,7 +667,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
598
667
|
const removeItem = vi.fn(() => {
|
|
599
668
|
throw new DOMException("blocked", "SecurityError");
|
|
600
669
|
});
|
|
601
|
-
installLocalStorage({ getItem, setItem, removeItem } as Storage);
|
|
670
|
+
installLocalStorage({ getItem, setItem, removeItem } as unknown as Storage);
|
|
602
671
|
mockAnonymousTokenFetch();
|
|
603
672
|
|
|
604
673
|
await expect(
|
|
@@ -14,6 +14,18 @@ function createMockCloud() {
|
|
|
14
14
|
} as unknown as AssistantCloud;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function createCloudMessages(count: number) {
|
|
18
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
19
|
+
id: `message-${index + 1}`,
|
|
20
|
+
parent_id: null,
|
|
21
|
+
height: index,
|
|
22
|
+
created_at: "2025-01-01T00:00:00Z" as unknown as Date,
|
|
23
|
+
updated_at: "2025-01-01T00:00:00Z" as unknown as Date,
|
|
24
|
+
format: "aui/v0",
|
|
25
|
+
content: {},
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
describe("CloudMessagePersistence", () => {
|
|
18
30
|
let cloud: AssistantCloud;
|
|
19
31
|
let persistence: CloudMessagePersistence;
|
|
@@ -35,8 +47,29 @@ describe("CloudMessagePersistence", () => {
|
|
|
35
47
|
|
|
36
48
|
expect(persistence.isPersisted("local-1")).toBe(true);
|
|
37
49
|
expect(await persistence.getRemoteId("local-1")).toBe("remote-1");
|
|
50
|
+
expect(persistence.getResolvedRemoteId("local-1")).toBe("remote-1");
|
|
38
51
|
});
|
|
39
52
|
|
|
53
|
+
it.each(["__proto__", "constructor", "toString"])(
|
|
54
|
+
"supports prototype-named local ID %s",
|
|
55
|
+
async (messageId) => {
|
|
56
|
+
vi.mocked(cloud.threads.messages.create).mockResolvedValue({
|
|
57
|
+
message_id: `remote-${messageId}`,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(persistence.isPersisted(messageId)).toBe(false);
|
|
61
|
+
|
|
62
|
+
await persistence.append("thread-1", messageId, null, "aui/v0", {
|
|
63
|
+
text: "hello",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(persistence.isPersisted(messageId)).toBe(true);
|
|
67
|
+
expect(await persistence.getRemoteId(messageId)).toBe(
|
|
68
|
+
`remote-${messageId}`,
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
40
73
|
it("uses the current client without losing ID mappings", async () => {
|
|
41
74
|
const firstCloud = createMockCloud();
|
|
42
75
|
const secondCloud = createMockCloud();
|
|
@@ -221,6 +254,88 @@ describe("CloudMessagePersistence", () => {
|
|
|
221
254
|
expect(persistence.isPersisted("msg-1")).toBe(true);
|
|
222
255
|
});
|
|
223
256
|
|
|
257
|
+
it("maps prototype-named loaded messages", async () => {
|
|
258
|
+
vi.mocked(cloud.threads.messages.list).mockResolvedValue({
|
|
259
|
+
messages: [
|
|
260
|
+
{
|
|
261
|
+
id: "__proto__",
|
|
262
|
+
parent_id: null,
|
|
263
|
+
height: 0,
|
|
264
|
+
created_at: "2025-01-01T00:00:00Z" as unknown as Date,
|
|
265
|
+
updated_at: "2025-01-01T00:00:00Z" as unknown as Date,
|
|
266
|
+
format: "aui/v0",
|
|
267
|
+
content: { text: "loaded" },
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await persistence.load("thread-1");
|
|
273
|
+
|
|
274
|
+
expect(persistence.isPersisted("__proto__")).toBe(true);
|
|
275
|
+
expect(await persistence.getRemoteId("__proto__")).toBe("__proto__");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("follows the message cursor across pages in server order", async () => {
|
|
279
|
+
const rows = createCloudMessages(450);
|
|
280
|
+
vi.mocked(cloud.threads.messages.list).mockImplementation(
|
|
281
|
+
async (_threadId, query) => {
|
|
282
|
+
const start = query?.after
|
|
283
|
+
? rows.findIndex((row) => row.id === query.after) + 1
|
|
284
|
+
: 0;
|
|
285
|
+
return { messages: rows.slice(start, start + 200) };
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const messages = await persistence.load("thread-1", "aui/v0");
|
|
290
|
+
|
|
291
|
+
expect(messages.map((message) => message.id)).toEqual(
|
|
292
|
+
rows.map((row) => row.id),
|
|
293
|
+
);
|
|
294
|
+
expect(cloud.threads.messages.list).toHaveBeenCalledTimes(3);
|
|
295
|
+
expect(cloud.threads.messages.list).toHaveBeenNthCalledWith(1, "thread-1", {
|
|
296
|
+
format: "aui/v0",
|
|
297
|
+
limit: 200,
|
|
298
|
+
});
|
|
299
|
+
expect(cloud.threads.messages.list).toHaveBeenNthCalledWith(3, "thread-1", {
|
|
300
|
+
format: "aui/v0",
|
|
301
|
+
limit: 200,
|
|
302
|
+
after: "message-400",
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("stops without duplicates when a later cursor stops resolving", async () => {
|
|
307
|
+
const rows = createCloudMessages(450);
|
|
308
|
+
vi.mocked(cloud.threads.messages.list).mockImplementation(
|
|
309
|
+
async (_threadId, query) => {
|
|
310
|
+
// The third request replays page one, the way the endpoint answers a
|
|
311
|
+
// cursor whose message no longer resolves.
|
|
312
|
+
const start =
|
|
313
|
+
query?.after && query.after !== "message-400"
|
|
314
|
+
? rows.findIndex((row) => row.id === query.after) + 1
|
|
315
|
+
: 0;
|
|
316
|
+
return { messages: rows.slice(start, start + 200) };
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const messages = await persistence.load("thread-1", "aui/v0");
|
|
321
|
+
|
|
322
|
+
expect(messages.map((message) => message.id)).toEqual(
|
|
323
|
+
rows.slice(0, 400).map((row) => row.id),
|
|
324
|
+
);
|
|
325
|
+
expect(cloud.threads.messages.list).toHaveBeenCalledTimes(3);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("stops when a page does not advance the cursor", async () => {
|
|
329
|
+
vi.mocked(cloud.threads.messages.list).mockResolvedValue({
|
|
330
|
+
messages: createCloudMessages(200),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const messages = await persistence.load("thread-1", "aui/v0");
|
|
334
|
+
|
|
335
|
+
expect(messages).toHaveLength(200);
|
|
336
|
+
expect(cloud.threads.messages.list).toHaveBeenCalledTimes(2);
|
|
337
|
+
});
|
|
338
|
+
|
|
224
339
|
it("updates an already-persisted message", async () => {
|
|
225
340
|
vi.mocked(cloud.threads.messages.create).mockResolvedValue({
|
|
226
341
|
message_id: "remote-1",
|