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.
- package/README.md +3 -3
- package/dist/AssistantCloud.js +2 -2
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +5 -5
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +35 -12
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
- package/dist/AssistantCloudAuthTokens.js +3 -1
- package/dist/AssistantCloudAuthTokens.js.map +1 -1
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudFiles.js +16 -4
- package/dist/AssistantCloudFiles.js.map +1 -1
- package/dist/AssistantCloudRuns.d.ts +3 -13
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js +10 -2
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +3 -2
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/AssistantCloudThreads.js +3 -2
- package/dist/AssistantCloudThreads.js.map +1 -1
- package/dist/generateThreadTitle.d.ts +15 -0
- package/dist/generateThreadTitle.d.ts.map +1 -0
- package/dist/generateThreadTitle.js +25 -0
- package/dist/generateThreadTitle.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/runTelemetry.d.ts +54 -0
- package/dist/runTelemetry.d.ts.map +1 -0
- package/dist/runTelemetry.js +79 -0
- package/dist/runTelemetry.js.map +1 -0
- package/package.json +5 -5
- package/src/AssistantCloud.ts +1 -1
- package/src/AssistantCloudAPI.ts +9 -8
- package/src/AssistantCloudAuthStrategy.ts +49 -12
- package/src/AssistantCloudAuthTokens.test.ts +30 -0
- package/src/AssistantCloudAuthTokens.ts +7 -1
- package/src/AssistantCloudFiles.test.ts +71 -0
- package/src/AssistantCloudFiles.ts +56 -10
- package/src/AssistantCloudRuns.ts +37 -15
- package/src/AssistantCloudThreadMessages.test.ts +20 -0
- package/src/AssistantCloudThreadMessages.ts +10 -3
- package/src/AssistantCloudThreads.test.ts +17 -0
- package/src/AssistantCloudThreads.ts +6 -1
- package/src/generateThreadTitle.test.ts +71 -0
- package/src/generateThreadTitle.ts +38 -0
- package/src/index.ts +10 -0
- package/src/runTelemetry.test.ts +130 -0
- package/src/runTelemetry.ts +140 -0
- package/src/tests/AssistantCloud.test.ts +39 -0
- package/src/tests/AssistantCloudAPI.test.ts +25 -0
- package/src/tests/AssistantCloudAuthStrategy.test.ts +278 -5
- package/src/tests/AssistantCloudRuns.test.ts +115 -0
|
@@ -12,6 +12,26 @@ const createCloudThreadMessages = () => {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
describe("AssistantCloudThreadMessages responses", () => {
|
|
15
|
+
it("validates created message IDs", async () => {
|
|
16
|
+
const { messages, makeRequest } = createCloudThreadMessages();
|
|
17
|
+
const body = {
|
|
18
|
+
parent_id: null,
|
|
19
|
+
format: "aui/v0",
|
|
20
|
+
content: {},
|
|
21
|
+
};
|
|
22
|
+
makeRequest.mockResolvedValueOnce({ message_id: "message-1" });
|
|
23
|
+
|
|
24
|
+
await expect(messages.create("thread-1", body)).resolves.toEqual({
|
|
25
|
+
message_id: "message-1",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
makeRequest.mockResolvedValueOnce({});
|
|
29
|
+
|
|
30
|
+
await expect(messages.create("thread-1", body)).rejects.toThrow(
|
|
31
|
+
'Invalid Assistant Cloud response for "message_id": expected a string',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
15
35
|
it("decodes canonical message responses without changing content", async () => {
|
|
16
36
|
const { messages, makeRequest } = createCloudThreadMessages();
|
|
17
37
|
makeRequest.mockResolvedValue({
|
|
@@ -89,10 +89,17 @@ export class AssistantCloudThreadMessages {
|
|
|
89
89
|
threadId: string,
|
|
90
90
|
body: AssistantCloudThreadMessageCreateBody,
|
|
91
91
|
): Promise<AssistantCloudMessageCreateResponse> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const response = readCloudRecord(
|
|
93
|
+
await this.cloud.makeRequest(
|
|
94
|
+
`/threads/${encodeURIComponent(threadId)}/messages`,
|
|
95
|
+
{ method: "POST", body },
|
|
96
|
+
),
|
|
97
|
+
"thread message create response",
|
|
95
98
|
);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
message_id: readCloudString(response.message_id, "message_id"),
|
|
102
|
+
};
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
public async update(
|
|
@@ -22,6 +22,23 @@ const threadResponse = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
describe("AssistantCloudThreads responses", () => {
|
|
25
|
+
it("validates created thread IDs", async () => {
|
|
26
|
+
const { threads, makeRequest } = createCloudThreads();
|
|
27
|
+
makeRequest.mockResolvedValueOnce({ thread_id: "thread-1" });
|
|
28
|
+
|
|
29
|
+
await expect(
|
|
30
|
+
threads.create({ last_message_at: new Date() }),
|
|
31
|
+
).resolves.toEqual({ thread_id: "thread-1" });
|
|
32
|
+
|
|
33
|
+
makeRequest.mockResolvedValueOnce({});
|
|
34
|
+
|
|
35
|
+
await expect(
|
|
36
|
+
threads.create({ last_message_at: new Date() }),
|
|
37
|
+
).rejects.toThrow(
|
|
38
|
+
'Invalid Assistant Cloud response for "thread_id": expected a string',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
25
42
|
it("forwards both archive filter values", async () => {
|
|
26
43
|
const { threads, makeRequest } = createCloudThreads();
|
|
27
44
|
makeRequest.mockResolvedValue({ threads: [] });
|
|
@@ -117,7 +117,12 @@ export class AssistantCloudThreads {
|
|
|
117
117
|
public async create(
|
|
118
118
|
body: AssistantCloudThreadsCreateBody,
|
|
119
119
|
): Promise<AssistantCloudThreadsCreateResponse> {
|
|
120
|
-
|
|
120
|
+
const response = readCloudRecord(
|
|
121
|
+
await this.cloud.makeRequest("/threads", { method: "POST", body }),
|
|
122
|
+
"thread create response",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return { thread_id: readCloudString(response.thread_id, "thread_id") };
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
public async update(
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { AssistantCloud } from "./AssistantCloud";
|
|
3
|
+
import { generateThreadTitle } from "./generateThreadTitle";
|
|
4
|
+
|
|
5
|
+
const titleStream = (...chunks: { type: string; textDelta?: string }[]) =>
|
|
6
|
+
new ReadableStream({
|
|
7
|
+
start(controller) {
|
|
8
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
9
|
+
controller.close();
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const createCloud = (stream: ReadableStream<unknown>) => {
|
|
14
|
+
const update = vi.fn().mockResolvedValue(undefined);
|
|
15
|
+
const run = vi.fn().mockResolvedValue(stream);
|
|
16
|
+
const cloud = {
|
|
17
|
+
threads: { update },
|
|
18
|
+
runs: { stream: run },
|
|
19
|
+
} as unknown as AssistantCloud;
|
|
20
|
+
return { cloud, update, run };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("generateThreadTitle", () => {
|
|
24
|
+
it("accumulates text deltas and updates the thread title", async () => {
|
|
25
|
+
const { cloud, run, update } = createCloud(
|
|
26
|
+
titleStream(
|
|
27
|
+
{ type: "text-delta", textDelta: "Weather " },
|
|
28
|
+
{ type: "text-delta", textDelta: "chat" },
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
await expect(
|
|
33
|
+
generateThreadTitle(cloud, {
|
|
34
|
+
threadId: "thread-1",
|
|
35
|
+
messages: [
|
|
36
|
+
{
|
|
37
|
+
role: "user",
|
|
38
|
+
content: [{ type: "text", text: "What is the weather today?" }],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
).resolves.toBe("Weather chat");
|
|
43
|
+
|
|
44
|
+
expect(run).toHaveBeenCalledExactlyOnceWith({
|
|
45
|
+
thread_id: "thread-1",
|
|
46
|
+
assistant_id: "system/thread_title",
|
|
47
|
+
messages: [
|
|
48
|
+
{
|
|
49
|
+
role: "user",
|
|
50
|
+
content: [{ type: "text", text: "What is the weather today?" }],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
expect(update).toHaveBeenCalledExactlyOnceWith("thread-1", {
|
|
55
|
+
title: "Weather chat",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null without updating when the stream has no text", async () => {
|
|
60
|
+
const { cloud, update } = createCloud(titleStream());
|
|
61
|
+
|
|
62
|
+
await expect(
|
|
63
|
+
generateThreadTitle(cloud, {
|
|
64
|
+
threadId: "thread-1",
|
|
65
|
+
messages: [],
|
|
66
|
+
}),
|
|
67
|
+
).resolves.toBeNull();
|
|
68
|
+
|
|
69
|
+
expect(update).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AssistantCloud } from "./AssistantCloud";
|
|
2
|
+
|
|
3
|
+
export async function generateThreadTitle(
|
|
4
|
+
cloud: AssistantCloud,
|
|
5
|
+
options: {
|
|
6
|
+
threadId: string;
|
|
7
|
+
messages: readonly {
|
|
8
|
+
role: string;
|
|
9
|
+
content: readonly { type: "text"; text: string }[];
|
|
10
|
+
}[];
|
|
11
|
+
},
|
|
12
|
+
): Promise<string | null> {
|
|
13
|
+
const stream = await cloud.runs.stream({
|
|
14
|
+
thread_id: options.threadId,
|
|
15
|
+
assistant_id: "system/thread_title",
|
|
16
|
+
messages: options.messages,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let title = "";
|
|
20
|
+
const reader = stream.getReader();
|
|
21
|
+
try {
|
|
22
|
+
while (true) {
|
|
23
|
+
const { done, value: chunk } = await reader.read();
|
|
24
|
+
if (done) break;
|
|
25
|
+
if (chunk.type === "text-delta") {
|
|
26
|
+
title += chunk.textDelta;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} finally {
|
|
30
|
+
reader.releaseLock();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (title) {
|
|
34
|
+
await cloud.threads.update(options.threadId, { title });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return title || null;
|
|
38
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,17 @@ export type { CloudMessage } from "./AssistantCloudThreadMessages";
|
|
|
2
2
|
export type { AssistantCloudTelemetryConfig } from "./AssistantCloudAPI";
|
|
3
3
|
export { CloudAPIError } from "./AssistantCloudAPI";
|
|
4
4
|
export { CloudResponseError } from "./cloudResponse";
|
|
5
|
+
export { generateThreadTitle } from "./generateThreadTitle";
|
|
5
6
|
export type { AssistantCloudRunReport } from "./AssistantCloudRuns";
|
|
7
|
+
export {
|
|
8
|
+
createRunTelemetryToolCall,
|
|
9
|
+
normalizeRunTelemetryUsage,
|
|
10
|
+
truncateRunTelemetryText,
|
|
11
|
+
type AssistantCloudRunReportToolCall,
|
|
12
|
+
type RunTelemetryToolCallInit,
|
|
13
|
+
type RunTelemetryUsage,
|
|
14
|
+
type RunTelemetryUsageInit,
|
|
15
|
+
} from "./runTelemetry";
|
|
6
16
|
export { AssistantCloud } from "./AssistantCloud";
|
|
7
17
|
export { CloudMessagePersistence } from "./CloudMessagePersistence";
|
|
8
18
|
export {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createRunTelemetryToolCall,
|
|
4
|
+
normalizeRunTelemetryUsage,
|
|
5
|
+
truncateRunTelemetryText,
|
|
6
|
+
} from "./runTelemetry";
|
|
7
|
+
|
|
8
|
+
const MAX = 50_000;
|
|
9
|
+
|
|
10
|
+
describe("truncateRunTelemetryText", () => {
|
|
11
|
+
it("passes text at or under the cap through unchanged", () => {
|
|
12
|
+
expect(truncateRunTelemetryText("hello")).toBe("hello");
|
|
13
|
+
const exact = "a".repeat(MAX);
|
|
14
|
+
expect(truncateRunTelemetryText(exact)).toBe(exact);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("clamps text over the cap", () => {
|
|
18
|
+
expect(truncateRunTelemetryText("a".repeat(MAX + 1))).toHaveLength(MAX);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("createRunTelemetryToolCall", () => {
|
|
23
|
+
it("serializes args and omits tool_source when the caller gives none", () => {
|
|
24
|
+
expect(
|
|
25
|
+
createRunTelemetryToolCall({
|
|
26
|
+
toolName: "calculator",
|
|
27
|
+
toolCallId: "call-1",
|
|
28
|
+
args: { a: 1 },
|
|
29
|
+
result: { sum: 1 },
|
|
30
|
+
}),
|
|
31
|
+
).toEqual({
|
|
32
|
+
tool_name: "calculator",
|
|
33
|
+
tool_call_id: "call-1",
|
|
34
|
+
tool_args: '{"a":1}',
|
|
35
|
+
tool_result: '{"sum":1}',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("clamps serialized args and results", () => {
|
|
40
|
+
const call = createRunTelemetryToolCall({
|
|
41
|
+
toolName: "t",
|
|
42
|
+
toolCallId: "call-1",
|
|
43
|
+
args: { blob: "a".repeat(MAX) },
|
|
44
|
+
result: { blob: "a".repeat(MAX) },
|
|
45
|
+
});
|
|
46
|
+
expect(call.tool_args).toHaveLength(MAX);
|
|
47
|
+
expect(call.tool_result).toHaveLength(MAX);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("clamps pre-serialized argsText to the cap", () => {
|
|
51
|
+
const argsText = "a".repeat(MAX + 10);
|
|
52
|
+
const call = createRunTelemetryToolCall({
|
|
53
|
+
toolName: "t",
|
|
54
|
+
toolCallId: "call-1",
|
|
55
|
+
argsText,
|
|
56
|
+
args: { ignored: true },
|
|
57
|
+
});
|
|
58
|
+
expect(call.tool_args).toBe(argsText.slice(0, MAX));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("omits fields whose value cannot be serialized", () => {
|
|
62
|
+
const circular: Record<string, unknown> = {};
|
|
63
|
+
circular.self = circular;
|
|
64
|
+
expect(
|
|
65
|
+
createRunTelemetryToolCall({
|
|
66
|
+
toolName: "t",
|
|
67
|
+
toolCallId: "call-1",
|
|
68
|
+
args: circular,
|
|
69
|
+
result: undefined,
|
|
70
|
+
}),
|
|
71
|
+
).toEqual({ tool_name: "t", tool_call_id: "call-1" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("summarizes base64 image and audio blocks in an mcp result", () => {
|
|
75
|
+
const call = createRunTelemetryToolCall({
|
|
76
|
+
toolName: "t",
|
|
77
|
+
toolCallId: "call-1",
|
|
78
|
+
toolSource: "mcp",
|
|
79
|
+
result: [
|
|
80
|
+
{ type: "text", text: "keep me" },
|
|
81
|
+
{ type: "image", data: "A".repeat(4096) },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
expect(call.tool_source).toBe("mcp");
|
|
85
|
+
expect(call.tool_result).toContain("keep me");
|
|
86
|
+
expect(call.tool_result).toContain("[image: 3.0KB]");
|
|
87
|
+
expect(call.tool_result).not.toContain("A".repeat(200));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("leaves a non-mcp result unsummarized", () => {
|
|
91
|
+
const result = [{ type: "image", data: "A".repeat(4096) }];
|
|
92
|
+
const call = createRunTelemetryToolCall({
|
|
93
|
+
toolName: "t",
|
|
94
|
+
toolCallId: "call-1",
|
|
95
|
+
toolSource: "frontend",
|
|
96
|
+
result,
|
|
97
|
+
});
|
|
98
|
+
expect(call.tool_source).toBe("frontend");
|
|
99
|
+
expect(call.tool_result).toBe(JSON.stringify(result));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("normalizeRunTelemetryUsage", () => {
|
|
104
|
+
it("prefers the current names over the legacy ones", () => {
|
|
105
|
+
expect(
|
|
106
|
+
normalizeRunTelemetryUsage({
|
|
107
|
+
inputTokens: 1,
|
|
108
|
+
outputTokens: 2,
|
|
109
|
+
promptTokens: 90,
|
|
110
|
+
completionTokens: 90,
|
|
111
|
+
}),
|
|
112
|
+
).toEqual({ inputTokens: 1, outputTokens: 2 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("falls back to the legacy prompt and completion names", () => {
|
|
116
|
+
expect(
|
|
117
|
+
normalizeRunTelemetryUsage({ promptTokens: 3, completionTokens: 4 }),
|
|
118
|
+
).toEqual({ inputTokens: 3, outputTokens: 4 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps a zero count and omits an absent one", () => {
|
|
122
|
+
expect(
|
|
123
|
+
normalizeRunTelemetryUsage({ inputTokens: 0, cachedInputTokens: 5 }),
|
|
124
|
+
).toEqual({ inputTokens: 0, cachedInputTokens: 5 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns undefined when no count is present", () => {
|
|
128
|
+
expect(normalizeRunTelemetryUsage({})).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { SamplingCallData } from "./instrumentMcpSampling";
|
|
2
|
+
|
|
3
|
+
const MAX_TELEMETRY_TEXT_LENGTH = 50_000;
|
|
4
|
+
|
|
5
|
+
const BASE64_PATTERN = /^[A-Za-z0-9+/]{100,}={0,2}$/;
|
|
6
|
+
|
|
7
|
+
export type AssistantCloudRunReportToolCall = {
|
|
8
|
+
tool_name: string;
|
|
9
|
+
tool_call_id: string;
|
|
10
|
+
tool_args?: string;
|
|
11
|
+
tool_result?: string;
|
|
12
|
+
tool_source?: "mcp" | "frontend" | "backend";
|
|
13
|
+
start_ms?: number;
|
|
14
|
+
end_ms?: number;
|
|
15
|
+
sampling_calls?: SamplingCallData[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Clamps a string to the size the runs endpoint accepts for a single span
|
|
20
|
+
* field.
|
|
21
|
+
*/
|
|
22
|
+
export function truncateRunTelemetryText(value: string): string {
|
|
23
|
+
if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;
|
|
24
|
+
return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeStringify(value: unknown): string | undefined {
|
|
28
|
+
if (value == null) return undefined;
|
|
29
|
+
try {
|
|
30
|
+
return truncateRunTelemetryText(JSON.stringify(value));
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function summarizeMcpResult(value: unknown): string | undefined {
|
|
37
|
+
if (value == null) return undefined;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
40
|
+
if (Array.isArray(parsed)) {
|
|
41
|
+
const summarized = parsed.map((item) => {
|
|
42
|
+
if (item && typeof item === "object" && item.type) {
|
|
43
|
+
if (
|
|
44
|
+
(item.type === "image" || item.type === "audio") &&
|
|
45
|
+
typeof item.data === "string" &&
|
|
46
|
+
BASE64_PATTERN.test(item.data.slice(0, 200))
|
|
47
|
+
) {
|
|
48
|
+
const sizeKB = ((item.data.length * 3) / 4 / 1024).toFixed(1);
|
|
49
|
+
return { ...item, data: `[${item.type}: ${sizeKB}KB]` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return item;
|
|
53
|
+
});
|
|
54
|
+
return truncateRunTelemetryText(JSON.stringify(summarized));
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// not JSON array, fall through
|
|
58
|
+
}
|
|
59
|
+
return safeStringify(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type RunTelemetryToolCallInit = {
|
|
63
|
+
toolName: string;
|
|
64
|
+
toolCallId: string;
|
|
65
|
+
args?: unknown;
|
|
66
|
+
/**
|
|
67
|
+
* Pre-serialized arguments, used in place of serializing `args`. Values over
|
|
68
|
+
* the span size are clamped before they are included in the report.
|
|
69
|
+
*/
|
|
70
|
+
argsText?: string | undefined;
|
|
71
|
+
result?: unknown;
|
|
72
|
+
toolSource?: "mcp" | "frontend" | "backend" | undefined;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Serializes one tool call into the shape the runs endpoint accepts. An `mcp`
|
|
77
|
+
* source has its result summarized, because MCP content blocks carry inline
|
|
78
|
+
* base64 image and audio payloads that would otherwise dominate the report.
|
|
79
|
+
*/
|
|
80
|
+
export function createRunTelemetryToolCall(
|
|
81
|
+
init: RunTelemetryToolCallInit,
|
|
82
|
+
): AssistantCloudRunReportToolCall {
|
|
83
|
+
const { toolName, toolCallId, args, argsText, result, toolSource } = init;
|
|
84
|
+
const call: AssistantCloudRunReportToolCall = {
|
|
85
|
+
tool_name: toolName,
|
|
86
|
+
tool_call_id: toolCallId,
|
|
87
|
+
};
|
|
88
|
+
const toolArgs =
|
|
89
|
+
argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);
|
|
90
|
+
if (toolArgs !== undefined) call.tool_args = toolArgs;
|
|
91
|
+
const toolResult =
|
|
92
|
+
toolSource === "mcp" ? summarizeMcpResult(result) : safeStringify(result);
|
|
93
|
+
if (toolResult !== undefined) call.tool_result = toolResult;
|
|
94
|
+
if (toolSource) call.tool_source = toolSource;
|
|
95
|
+
return call;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type RunTelemetryUsage = {
|
|
99
|
+
inputTokens?: number;
|
|
100
|
+
outputTokens?: number;
|
|
101
|
+
reasoningTokens?: number;
|
|
102
|
+
cachedInputTokens?: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type RunTelemetryUsageInit = RunTelemetryUsage & {
|
|
106
|
+
promptTokens?: number;
|
|
107
|
+
completionTokens?: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolves the token counts a provider reports under either the current or the
|
|
112
|
+
* legacy prompt/completion names. Returns undefined when no count is present,
|
|
113
|
+
* so callers can tell an empty usage object from a zeroed one.
|
|
114
|
+
*/
|
|
115
|
+
export function normalizeRunTelemetryUsage(
|
|
116
|
+
usage: RunTelemetryUsageInit,
|
|
117
|
+
): RunTelemetryUsage | undefined {
|
|
118
|
+
const inputTokens = usage.inputTokens ?? usage.promptTokens;
|
|
119
|
+
const outputTokens = usage.outputTokens ?? usage.completionTokens;
|
|
120
|
+
|
|
121
|
+
if (
|
|
122
|
+
inputTokens == null &&
|
|
123
|
+
outputTokens == null &&
|
|
124
|
+
usage.reasoningTokens == null &&
|
|
125
|
+
usage.cachedInputTokens == null
|
|
126
|
+
) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
...(inputTokens != null ? { inputTokens } : undefined),
|
|
132
|
+
...(outputTokens != null ? { outputTokens } : undefined),
|
|
133
|
+
...(usage.reasoningTokens != null
|
|
134
|
+
? { reasoningTokens: usage.reasoningTokens }
|
|
135
|
+
: undefined),
|
|
136
|
+
...(usage.cachedInputTokens != null
|
|
137
|
+
? { cachedInputTokens: usage.cachedInputTokens }
|
|
138
|
+
: undefined),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AssistantCloud } from "../AssistantCloud";
|
|
3
|
+
import type { AssistantCloudTelemetryConfig } from "../AssistantCloudAPI";
|
|
4
|
+
|
|
5
|
+
const createCloud = (
|
|
6
|
+
telemetry?: ConstructorParameters<typeof AssistantCloud>[0]["telemetry"],
|
|
7
|
+
) =>
|
|
8
|
+
new AssistantCloud({
|
|
9
|
+
apiKey: "test-key",
|
|
10
|
+
userId: "user-id",
|
|
11
|
+
workspaceId: "workspace-id",
|
|
12
|
+
...(telemetry !== undefined ? { telemetry } : {}),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("AssistantCloud telemetry config", () => {
|
|
16
|
+
it("defaults to enabled", () => {
|
|
17
|
+
expect(createCloud().telemetry.enabled).toBe(true);
|
|
18
|
+
expect(createCloud(true).telemetry.enabled).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("disables when configured off", () => {
|
|
22
|
+
expect(createCloud(false).telemetry.enabled).toBe(false);
|
|
23
|
+
expect(createCloud({ enabled: false }).telemetry.enabled).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("stays enabled when the config object carries an undefined enabled", () => {
|
|
27
|
+
const beforeReport: NonNullable<
|
|
28
|
+
AssistantCloudTelemetryConfig["beforeReport"]
|
|
29
|
+
> = (report) => report;
|
|
30
|
+
// JS consumers (and TS apps without exactOptionalPropertyTypes) can pass
|
|
31
|
+
// an explicitly-undefined enabled, e.g. { enabled: cfg.enabled }.
|
|
32
|
+
const telemetry = createCloud({
|
|
33
|
+
enabled: undefined,
|
|
34
|
+
beforeReport,
|
|
35
|
+
} as unknown as AssistantCloudTelemetryConfig).telemetry;
|
|
36
|
+
expect(telemetry.enabled).toBe(true);
|
|
37
|
+
expect(telemetry.beforeReport).toBe(beforeReport);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -169,6 +169,31 @@ describe("AssistantCloudAPI", () => {
|
|
|
169
169
|
expect(error.status).toBe(400);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
it("falls back to the response text when the JSON error body has no message", async () => {
|
|
173
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
174
|
+
ok: false,
|
|
175
|
+
status: 429,
|
|
176
|
+
headers: new Headers(),
|
|
177
|
+
text: vi
|
|
178
|
+
.fn()
|
|
179
|
+
.mockResolvedValue(JSON.stringify({ error: "rate limited" })),
|
|
180
|
+
});
|
|
181
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
182
|
+
|
|
183
|
+
const api = new AssistantCloudAPI({
|
|
184
|
+
apiKey: "test-key",
|
|
185
|
+
userId: "u-1",
|
|
186
|
+
workspaceId: "w-1",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const error = await api.makeRawRequest("/threads").catch((e) => e);
|
|
190
|
+
expect(error).toBeInstanceOf(CloudAPIError);
|
|
191
|
+
expect(error.message).toBe(
|
|
192
|
+
'Request failed with status 429, {"error":"rate limited"}',
|
|
193
|
+
);
|
|
194
|
+
expect(error.status).toBe(429);
|
|
195
|
+
});
|
|
196
|
+
|
|
172
197
|
it("throws generic error with status for non-JSON error responses", async () => {
|
|
173
198
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
174
199
|
ok: false,
|