assistant-cloud 0.2.0 → 0.2.2
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 +48 -6
- package/dist/AssistantCloud.d.ts +3 -3
- package/dist/AssistantCloud.d.ts.map +1 -1
- package/dist/AssistantCloud.js +2 -0
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts +14 -6
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +17 -1
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts +6 -7
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +4 -2
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudAuthTokens.d.ts +1 -2
- package/dist/AssistantCloudAuthTokens.d.ts.map +1 -1
- package/dist/AssistantCloudEvents.d.ts +3 -4
- package/dist/AssistantCloudEvents.d.ts.map +1 -1
- package/dist/AssistantCloudEvents.js +5 -1
- package/dist/AssistantCloudEvents.js.map +1 -1
- package/dist/AssistantCloudFiles.d.ts +2 -3
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudProjectThreadMessages.d.ts +1 -2
- package/dist/AssistantCloudProjectThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudProjectThreads.d.ts +1 -2
- package/dist/AssistantCloudProjectThreads.d.ts.map +1 -1
- package/dist/AssistantCloudProjects.d.ts +1 -2
- package/dist/AssistantCloudProjects.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.d.ts +3 -3
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js +2 -1
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudScores.d.ts +3 -4
- package/dist/AssistantCloudScores.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.d.ts +7 -6
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +7 -2
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts +3 -4
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/CloudEngagementReporter.d.ts +73 -0
- package/dist/CloudEngagementReporter.d.ts.map +1 -0
- package/dist/CloudEngagementReporter.js +132 -0
- package/dist/CloudEngagementReporter.js.map +1 -0
- package/dist/CloudMessagePersistence.d.ts +9 -11
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +18 -11
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/CloudRunReporter.d.ts +19 -0
- package/dist/CloudRunReporter.d.ts.map +1 -0
- package/dist/CloudRunReporter.js +47 -0
- package/dist/CloudRunReporter.js.map +1 -0
- package/dist/FormattedCloudPersistence.d.ts +5 -11
- package/dist/FormattedCloudPersistence.d.ts.map +1 -1
- package/dist/FormattedCloudPersistence.js +3 -8
- package/dist/FormattedCloudPersistence.js.map +1 -1
- package/dist/ai-sdk/index.d.ts +32 -0
- package/dist/ai-sdk/index.d.ts.map +1 -0
- package/dist/ai-sdk/index.js +147 -0
- package/dist/ai-sdk/index.js.map +1 -0
- package/dist/cloudResponse.d.ts +12 -13
- package/dist/cloudResponse.d.ts.map +1 -1
- package/dist/generateThreadTitle.d.ts +1 -2
- package/dist/generateThreadTitle.d.ts.map +1 -1
- package/dist/generateThreadTitle.js +1 -0
- package/dist/generateThreadTitle.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/instrumentMcpSampling.d.ts +6 -7
- package/dist/instrumentMcpSampling.d.ts.map +1 -1
- package/dist/instrumentMcpSampling.js +12 -2
- package/dist/instrumentMcpSampling.js.map +1 -1
- package/dist/runTelemetry.d.ts +30 -15
- package/dist/runTelemetry.d.ts.map +1 -1
- package/dist/runTelemetry.js.map +1 -1
- package/dist/telemetry/index.d.ts +8 -9
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +24 -10
- package/src/AssistantCloud.ts +3 -0
- package/src/AssistantCloudAPI.ts +31 -1
- package/src/AssistantCloudAuthStrategy.ts +4 -2
- package/src/AssistantCloudEvents.test.ts +68 -2
- package/src/AssistantCloudEvents.ts +6 -0
- package/src/AssistantCloudRuns.ts +1 -0
- package/src/AssistantCloudThreadMessages.test.ts +53 -2
- package/src/AssistantCloudThreadMessages.ts +13 -1
- package/src/CloudEngagementReporter.ts +231 -0
- package/src/CloudMessagePersistence.ts +24 -13
- package/src/CloudRunReporter.ts +50 -0
- package/src/FormattedCloudPersistence.ts +3 -8
- package/src/ai-sdk/index.test.ts +258 -0
- package/src/ai-sdk/index.ts +222 -0
- package/src/generateThreadTitle.test.ts +32 -0
- package/src/generateThreadTitle.ts +1 -0
- package/src/index.ts +11 -1
- package/src/instrumentMcpSampling.test.ts +102 -0
- package/src/instrumentMcpSampling.ts +16 -2
- package/src/runTelemetry.ts +17 -0
- package/src/tests/AssistantCloud.test.ts +33 -1
- package/src/tests/AssistantCloudAPI.test.ts +51 -0
- package/src/tests/AssistantCloudAuthStrategy.test.ts +22 -0
- package/src/tests/AssistantCloudProjects.test.ts +1 -5
- package/src/tests/CloudEngagementReporter.test.ts +184 -0
- package/src/tests/CloudMessagePersistence.test.ts +235 -0
- package/src/tests/CloudRunReporter.test.ts +135 -0
- package/src/version.ts +4 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
aiSDKV6FormatAdapter,
|
|
5
|
+
extractAISDKRunTelemetry,
|
|
6
|
+
type AISDKMessageLike,
|
|
7
|
+
} from "./index";
|
|
8
|
+
|
|
9
|
+
const assistant = (
|
|
10
|
+
parts: UIMessage["parts"],
|
|
11
|
+
metadata?: Record<string, unknown>,
|
|
12
|
+
id = "a1",
|
|
13
|
+
): UIMessage =>
|
|
14
|
+
({
|
|
15
|
+
id,
|
|
16
|
+
role: "assistant",
|
|
17
|
+
parts,
|
|
18
|
+
...(metadata ? { metadata } : {}),
|
|
19
|
+
}) as UIMessage;
|
|
20
|
+
|
|
21
|
+
describe("aiSDKV6FormatAdapter", () => {
|
|
22
|
+
it("stores a message without its id and restores it from the row", () => {
|
|
23
|
+
const message = assistant([{ type: "text", text: "hi" }], { a: 1 });
|
|
24
|
+
const content = aiSDKV6FormatAdapter.encode({ parentId: null, message });
|
|
25
|
+
expect(content).toEqual({
|
|
26
|
+
role: "assistant",
|
|
27
|
+
parts: [{ type: "text", text: "hi" }],
|
|
28
|
+
metadata: { a: 1 },
|
|
29
|
+
});
|
|
30
|
+
expect(
|
|
31
|
+
aiSDKV6FormatAdapter.decode({
|
|
32
|
+
id: "a1",
|
|
33
|
+
parent_id: "u1",
|
|
34
|
+
format: "ai-sdk/v6",
|
|
35
|
+
content,
|
|
36
|
+
}),
|
|
37
|
+
).toEqual({ parentId: "u1", message });
|
|
38
|
+
expect(aiSDKV6FormatAdapter.getId(message)).toBe("a1");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("extractAISDKRunTelemetry", () => {
|
|
43
|
+
it("returns null without an assistant message", () => {
|
|
44
|
+
expect(
|
|
45
|
+
extractAISDKRunTelemetry([
|
|
46
|
+
{ role: "user", parts: [{ type: "text", text: "q" }] },
|
|
47
|
+
]),
|
|
48
|
+
).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reads tool calls, steps and usage from one streamed message", () => {
|
|
52
|
+
const message = assistant(
|
|
53
|
+
[
|
|
54
|
+
{ type: "step-start" },
|
|
55
|
+
{
|
|
56
|
+
type: "tool-weather",
|
|
57
|
+
toolCallId: "call_1",
|
|
58
|
+
state: "output-available",
|
|
59
|
+
input: { city: "Oslo" },
|
|
60
|
+
output: { temp: 3 },
|
|
61
|
+
} as UIMessage["parts"][number],
|
|
62
|
+
{ type: "step-start" },
|
|
63
|
+
{
|
|
64
|
+
type: "dynamic-tool",
|
|
65
|
+
toolName: "search",
|
|
66
|
+
toolCallId: "call_2",
|
|
67
|
+
state: "output-available",
|
|
68
|
+
input: { q: "x" },
|
|
69
|
+
output: [{ type: "text", text: "found" }],
|
|
70
|
+
} as UIMessage["parts"][number],
|
|
71
|
+
{ type: "step-start" },
|
|
72
|
+
{ type: "text", text: "Cold." },
|
|
73
|
+
],
|
|
74
|
+
{
|
|
75
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
76
|
+
steps: [
|
|
77
|
+
{ usage: { promptTokens: 4, completionTokens: 1 } },
|
|
78
|
+
{ usage: { inputTokens: 6, outputTokens: 4 } },
|
|
79
|
+
{},
|
|
80
|
+
],
|
|
81
|
+
samplingCalls: { call_2: [{ duration_ms: 20 }] },
|
|
82
|
+
modelId: "gpt-5.6-luna",
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(extractAISDKRunTelemetry([message])).toEqual({
|
|
87
|
+
assistantMessageId: "a1",
|
|
88
|
+
status: "completed",
|
|
89
|
+
toolCalls: [
|
|
90
|
+
{
|
|
91
|
+
tool_name: "weather",
|
|
92
|
+
tool_call_id: "call_1",
|
|
93
|
+
tool_args: '{"city":"Oslo"}',
|
|
94
|
+
tool_result: '{"temp":3}',
|
|
95
|
+
tool_source: "frontend",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
tool_name: "search",
|
|
99
|
+
tool_call_id: "call_2",
|
|
100
|
+
tool_args: '{"q":"x"}',
|
|
101
|
+
tool_result: '[{"type":"text","text":"found"}]',
|
|
102
|
+
tool_source: "mcp",
|
|
103
|
+
sampling_calls: [{ duration_ms: 20 }],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
steps: [
|
|
107
|
+
{
|
|
108
|
+
usage: { promptTokens: 4, completionTokens: 1 },
|
|
109
|
+
toolCalls: [expect.objectContaining({ tool_call_id: "call_1" })],
|
|
110
|
+
finishReason: "tool-calls",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
usage: { inputTokens: 6, outputTokens: 4 },
|
|
114
|
+
toolCalls: [expect.objectContaining({ tool_call_id: "call_2" })],
|
|
115
|
+
finishReason: "tool-calls",
|
|
116
|
+
},
|
|
117
|
+
{},
|
|
118
|
+
],
|
|
119
|
+
totalSteps: 3,
|
|
120
|
+
outputText: "Cold.",
|
|
121
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
122
|
+
modelId: "gpt-5.6-luna",
|
|
123
|
+
metadata: message.metadata,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("sums usage over stored rows without ids and reads legacy tool fields", () => {
|
|
128
|
+
const rows: AISDKMessageLike[] = [
|
|
129
|
+
{
|
|
130
|
+
role: "assistant",
|
|
131
|
+
parts: [
|
|
132
|
+
{ type: "step-start" },
|
|
133
|
+
{
|
|
134
|
+
type: "tool-lookup",
|
|
135
|
+
toolCallId: "call_1",
|
|
136
|
+
state: "output-available",
|
|
137
|
+
args: { id: 1 },
|
|
138
|
+
result: "ok",
|
|
139
|
+
} as unknown as UIMessage["parts"][number],
|
|
140
|
+
],
|
|
141
|
+
metadata: { steps: [{ usage: { inputTokens: 2, outputTokens: 1 } }] },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
role: "assistant",
|
|
145
|
+
parts: [{ type: "step-start" }, { type: "text", text: "done" }],
|
|
146
|
+
metadata: {
|
|
147
|
+
steps: [{ usage: { inputTokens: 3, reasoningTokens: 7 } }],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
const result = extractAISDKRunTelemetry(rows);
|
|
152
|
+
expect(result?.assistantMessageId).toBeUndefined();
|
|
153
|
+
expect(result?.status).toBe("completed");
|
|
154
|
+
expect(result?.totalSteps).toBe(2);
|
|
155
|
+
expect(result?.usage).toEqual({
|
|
156
|
+
inputTokens: 5,
|
|
157
|
+
outputTokens: 1,
|
|
158
|
+
reasoningTokens: 7,
|
|
159
|
+
});
|
|
160
|
+
expect(result?.toolCalls).toEqual([
|
|
161
|
+
{
|
|
162
|
+
tool_name: "lookup",
|
|
163
|
+
tool_call_id: "call_1",
|
|
164
|
+
tool_args: '{"id":1}',
|
|
165
|
+
tool_result: '"ok"',
|
|
166
|
+
tool_source: "frontend",
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("attaches sampling calls from the row that made the tool call", () => {
|
|
172
|
+
const rows: AISDKMessageLike[] = [
|
|
173
|
+
{
|
|
174
|
+
role: "assistant",
|
|
175
|
+
parts: [
|
|
176
|
+
{
|
|
177
|
+
type: "dynamic-tool",
|
|
178
|
+
toolName: "search",
|
|
179
|
+
toolCallId: "call_1",
|
|
180
|
+
state: "output-available",
|
|
181
|
+
input: {},
|
|
182
|
+
output: "x",
|
|
183
|
+
} as UIMessage["parts"][number],
|
|
184
|
+
],
|
|
185
|
+
metadata: { samplingCalls: { call_1: [{ duration_ms: 5 }] } },
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
role: "assistant",
|
|
189
|
+
parts: [{ type: "text", text: "done" }],
|
|
190
|
+
metadata: { samplingCalls: {} },
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
expect(extractAISDKRunTelemetry(rows)?.toolCalls).toEqual([
|
|
194
|
+
expect.objectContaining({
|
|
195
|
+
tool_call_id: "call_1",
|
|
196
|
+
tool_source: "mcp",
|
|
197
|
+
sampling_calls: [{ duration_ms: 5 }],
|
|
198
|
+
}),
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("reads a tool only run as completed with its step closed by the calls", () => {
|
|
203
|
+
const call = (toolCallId: string, state: string, output?: unknown) =>
|
|
204
|
+
({
|
|
205
|
+
type: "tool-search",
|
|
206
|
+
toolCallId,
|
|
207
|
+
state,
|
|
208
|
+
input: {},
|
|
209
|
+
...(output !== undefined ? { output } : {}),
|
|
210
|
+
}) as UIMessage["parts"][number];
|
|
211
|
+
|
|
212
|
+
const answered = extractAISDKRunTelemetry([
|
|
213
|
+
assistant([{ type: "step-start" }, call("a", "output-available", [])]),
|
|
214
|
+
]);
|
|
215
|
+
expect(answered?.status).toBe("completed");
|
|
216
|
+
expect(answered?.steps?.[0]?.finishReason).toBe("tool-calls");
|
|
217
|
+
expect(answered?.outputText).toBeUndefined();
|
|
218
|
+
|
|
219
|
+
const pending = extractAISDKRunTelemetry([
|
|
220
|
+
assistant([{ type: "step-start" }, call("b", "input-available")]),
|
|
221
|
+
]);
|
|
222
|
+
expect(pending?.status).toBe("completed");
|
|
223
|
+
expect(pending?.steps?.[0]?.finishReason).toBe("tool-calls");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("reads incomplete for a run that produced nothing", () => {
|
|
227
|
+
const empty = extractAISDKRunTelemetry([
|
|
228
|
+
assistant([{ type: "step-start" }]),
|
|
229
|
+
]);
|
|
230
|
+
expect(empty?.status).toBe("incomplete");
|
|
231
|
+
expect(empty?.totalSteps).toBe(1);
|
|
232
|
+
expect(empty?.steps?.[0]?.finishReason).toBeUndefined();
|
|
233
|
+
expect(empty?.outputText).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("reads no steps without step markers", () => {
|
|
237
|
+
const result = extractAISDKRunTelemetry([
|
|
238
|
+
assistant([{ type: "text", text: "hi" }]),
|
|
239
|
+
]);
|
|
240
|
+
expect(result?.steps).toBeUndefined();
|
|
241
|
+
expect(result?.totalSteps).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("skips a stored part without a type", () => {
|
|
245
|
+
const result = extractAISDKRunTelemetry([
|
|
246
|
+
{
|
|
247
|
+
role: "assistant",
|
|
248
|
+
parts: [
|
|
249
|
+
null,
|
|
250
|
+
{ toolCallId: "a" },
|
|
251
|
+
{ type: "text", text: "hi" },
|
|
252
|
+
] as never,
|
|
253
|
+
},
|
|
254
|
+
]);
|
|
255
|
+
expect(result?.outputText).toBe("hi");
|
|
256
|
+
expect(result?.toolCalls).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import type { MessageFormatAdapter } from "../FormattedCloudPersistence";
|
|
3
|
+
import type { SamplingCallData } from "../instrumentMcpSampling";
|
|
4
|
+
import {
|
|
5
|
+
type AssistantCloudRunReportToolCall,
|
|
6
|
+
createRunTelemetryToolCall,
|
|
7
|
+
extractRunTelemetryModelId,
|
|
8
|
+
normalizeRunTelemetryUsage,
|
|
9
|
+
type RunMessageTelemetry,
|
|
10
|
+
type RunReportStepInit,
|
|
11
|
+
type RunTelemetryUsage,
|
|
12
|
+
type RunTelemetryUsageInit,
|
|
13
|
+
truncateRunTelemetryText,
|
|
14
|
+
} from "../runTelemetry";
|
|
15
|
+
|
|
16
|
+
export type AISDKStorageFormat = Omit<UIMessage, "id">;
|
|
17
|
+
|
|
18
|
+
/** The stored form of an AI SDK message: the message without its id. */
|
|
19
|
+
export const aiSDKV6FormatAdapter: MessageFormatAdapter<
|
|
20
|
+
UIMessage,
|
|
21
|
+
AISDKStorageFormat
|
|
22
|
+
> = {
|
|
23
|
+
format: "ai-sdk/v6",
|
|
24
|
+
encode: ({ message: { id: _id, ...message } }) => message,
|
|
25
|
+
decode: (stored) => ({
|
|
26
|
+
parentId: stored.parent_id,
|
|
27
|
+
message: { id: stored.id, ...stored.content } as UIMessage,
|
|
28
|
+
}),
|
|
29
|
+
getId: (message) => message.id,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A message as an AI SDK integration holds it, or as the cloud stored it under
|
|
34
|
+
* the ai-sdk/v6 format, which drops the id. Parts are typed by their `type`
|
|
35
|
+
* alone, so this shape and the telemetry read from it need nothing from `ai`.
|
|
36
|
+
*/
|
|
37
|
+
export type AISDKMessageLike = {
|
|
38
|
+
id?: string | undefined;
|
|
39
|
+
role: string;
|
|
40
|
+
parts: readonly { type: string; [key: string]: unknown }[];
|
|
41
|
+
metadata?: unknown;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Part = Record<string, unknown> & { type: string };
|
|
45
|
+
|
|
46
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
47
|
+
return value !== null && typeof value === "object";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isPart(value: unknown): value is Part {
|
|
51
|
+
return isRecord(value) && typeof value.type === "string";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The AI SDK's own tool part rules, kept here so the entry loads without the
|
|
56
|
+
* `ai` runtime: a static tool part is `tool-<name>`, a dynamic one is
|
|
57
|
+
* `dynamic-tool` with its name in `toolName`.
|
|
58
|
+
*/
|
|
59
|
+
function toolCallOf(part: Part): AssistantCloudRunReportToolCall | undefined {
|
|
60
|
+
if (typeof part.toolCallId !== "string") return undefined;
|
|
61
|
+
const isStatic = part.type.startsWith("tool-");
|
|
62
|
+
if (!isStatic && part.type !== "dynamic-tool") return undefined;
|
|
63
|
+
const toolName = isStatic
|
|
64
|
+
? part.type.slice("tool-".length)
|
|
65
|
+
: typeof part.toolName === "string"
|
|
66
|
+
? part.toolName
|
|
67
|
+
: undefined;
|
|
68
|
+
if (!toolName) return undefined;
|
|
69
|
+
return createRunTelemetryToolCall({
|
|
70
|
+
toolName,
|
|
71
|
+
toolCallId: part.toolCallId,
|
|
72
|
+
args: part.input ?? part.args,
|
|
73
|
+
result: part.output ?? part.result,
|
|
74
|
+
toolSource: isStatic ? "frontend" : "mcp",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function attachSamplingCalls(
|
|
79
|
+
toolCalls: readonly AssistantCloudRunReportToolCall[],
|
|
80
|
+
metadata: Record<string, unknown> | undefined,
|
|
81
|
+
): void {
|
|
82
|
+
const samplingCalls = isRecord(metadata?.samplingCalls)
|
|
83
|
+
? (metadata.samplingCalls as Record<string, SamplingCallData[]>)
|
|
84
|
+
: undefined;
|
|
85
|
+
if (!samplingCalls) return;
|
|
86
|
+
for (const toolCall of toolCalls) {
|
|
87
|
+
const calls = samplingCalls[toolCall.tool_call_id];
|
|
88
|
+
if (Array.isArray(calls) && calls.length > 0) {
|
|
89
|
+
toolCall.sampling_calls = calls;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stepUsages(
|
|
95
|
+
metadata: Record<string, unknown> | undefined,
|
|
96
|
+
): (RunTelemetryUsageInit | undefined)[] {
|
|
97
|
+
const steps = metadata?.steps;
|
|
98
|
+
if (!Array.isArray(steps)) return [];
|
|
99
|
+
return steps.map((step) =>
|
|
100
|
+
isRecord(step) && isRecord(step.usage)
|
|
101
|
+
? (step.usage as RunTelemetryUsageInit)
|
|
102
|
+
: undefined,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sumUsage(usages: readonly RunTelemetryUsage[]): RunTelemetryUsage {
|
|
107
|
+
const total: RunTelemetryUsage = {};
|
|
108
|
+
for (const usage of usages) {
|
|
109
|
+
if (usage.inputTokens != null) {
|
|
110
|
+
total.inputTokens = (total.inputTokens ?? 0) + usage.inputTokens;
|
|
111
|
+
}
|
|
112
|
+
if (usage.outputTokens != null) {
|
|
113
|
+
total.outputTokens = (total.outputTokens ?? 0) + usage.outputTokens;
|
|
114
|
+
}
|
|
115
|
+
if (usage.reasoningTokens != null) {
|
|
116
|
+
total.reasoningTokens =
|
|
117
|
+
(total.reasoningTokens ?? 0) + usage.reasoningTokens;
|
|
118
|
+
}
|
|
119
|
+
if (usage.cachedInputTokens != null) {
|
|
120
|
+
total.cachedInputTokens =
|
|
121
|
+
(total.cachedInputTokens ?? 0) + usage.cachedInputTokens;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return total;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The usage a message reports: `metadata.usage` when the integration copied
|
|
129
|
+
* the run total there, else the sum over `metadata.steps[].usage`.
|
|
130
|
+
*/
|
|
131
|
+
function messageUsage(
|
|
132
|
+
metadata: Record<string, unknown> | undefined,
|
|
133
|
+
): RunTelemetryUsage | undefined {
|
|
134
|
+
const total = isRecord(metadata?.usage)
|
|
135
|
+
? normalizeRunTelemetryUsage(metadata.usage as RunTelemetryUsageInit)
|
|
136
|
+
: undefined;
|
|
137
|
+
if (total) return total;
|
|
138
|
+
const perStep = stepUsages(metadata).flatMap((usage) => {
|
|
139
|
+
const normalized = usage ? normalizeRunTelemetryUsage(usage) : undefined;
|
|
140
|
+
return normalized ? [normalized] : [];
|
|
141
|
+
});
|
|
142
|
+
return perStep.length > 0 ? sumUsage(perStep) : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Reads the run report fields out of the assistant messages of one run. A run
|
|
147
|
+
* the AI SDK streamed as one message is one element; a run the cloud stored as
|
|
148
|
+
* several assistant rows is aggregated, step by step, in order. Returns null
|
|
149
|
+
* when no assistant message is present. Status reads completed when the run
|
|
150
|
+
* produced text or tool calls, as a live finish with reason `tool-calls` does;
|
|
151
|
+
* an integration that observed the finish event overrides it.
|
|
152
|
+
*/
|
|
153
|
+
export function extractAISDKRunTelemetry(
|
|
154
|
+
messages: readonly AISDKMessageLike[],
|
|
155
|
+
): RunMessageTelemetry | null {
|
|
156
|
+
const textParts: string[] = [];
|
|
157
|
+
const toolCalls: AssistantCloudRunReportToolCall[] = [];
|
|
158
|
+
const steps: RunReportStepInit[] = [];
|
|
159
|
+
const usages: RunTelemetryUsage[] = [];
|
|
160
|
+
let assistant: AISDKMessageLike | undefined;
|
|
161
|
+
|
|
162
|
+
for (const message of messages) {
|
|
163
|
+
if (message.role !== "assistant") continue;
|
|
164
|
+
assistant = message;
|
|
165
|
+
const metadata = isRecord(message.metadata) ? message.metadata : undefined;
|
|
166
|
+
const usagePerStep = stepUsages(metadata);
|
|
167
|
+
const messageToolCalls: AssistantCloudRunReportToolCall[] = [];
|
|
168
|
+
let step: RunReportStepInit | undefined;
|
|
169
|
+
let stepIndex = -1;
|
|
170
|
+
|
|
171
|
+
for (const part of message.parts) {
|
|
172
|
+
if (!isPart(part)) continue;
|
|
173
|
+
if (part.type === "step-start") {
|
|
174
|
+
stepIndex += 1;
|
|
175
|
+
const usage = usagePerStep[stepIndex];
|
|
176
|
+
step = usage ? { usage } : {};
|
|
177
|
+
steps.push(step);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (part.type === "text" && typeof part.text === "string" && part.text) {
|
|
181
|
+
textParts.push(part.text);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const toolCall = toolCallOf(part);
|
|
185
|
+
if (!toolCall) continue;
|
|
186
|
+
toolCalls.push(toolCall);
|
|
187
|
+
messageToolCalls.push(toolCall);
|
|
188
|
+
if (step) {
|
|
189
|
+
step.toolCalls = [...(step.toolCalls ?? []), toolCall];
|
|
190
|
+
step.finishReason = "tool-calls";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
attachSamplingCalls(messageToolCalls, metadata);
|
|
195
|
+
const usage = messageUsage(metadata);
|
|
196
|
+
if (usage) usages.push(usage);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!assistant) return null;
|
|
200
|
+
|
|
201
|
+
const metadata = isRecord(assistant.metadata)
|
|
202
|
+
? assistant.metadata
|
|
203
|
+
: undefined;
|
|
204
|
+
|
|
205
|
+
const usage = usages.length > 0 ? sumUsage(usages) : undefined;
|
|
206
|
+
const modelId = extractRunTelemetryModelId(metadata);
|
|
207
|
+
const completed = textParts.length > 0 || toolCalls.length > 0;
|
|
208
|
+
return {
|
|
209
|
+
...(assistant.id !== undefined
|
|
210
|
+
? { assistantMessageId: assistant.id }
|
|
211
|
+
: undefined),
|
|
212
|
+
status: completed ? "completed" : "incomplete",
|
|
213
|
+
...(toolCalls.length > 0 ? { toolCalls } : undefined),
|
|
214
|
+
...(steps.length > 0 ? { steps, totalSteps: steps.length } : undefined),
|
|
215
|
+
...(textParts.length > 0
|
|
216
|
+
? { outputText: truncateRunTelemetryText(textParts.join("")) }
|
|
217
|
+
: undefined),
|
|
218
|
+
...(usage ? { usage } : undefined),
|
|
219
|
+
...(modelId ? { modelId } : undefined),
|
|
220
|
+
...(metadata ? { metadata } : undefined),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -68,4 +68,36 @@ describe("generateThreadTitle", () => {
|
|
|
68
68
|
|
|
69
69
|
expect(update).not.toHaveBeenCalled();
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
it("returns null without updating for a whitespace-only title", async () => {
|
|
73
|
+
const { cloud, update } = createCloud(
|
|
74
|
+
titleStream({ type: "text-delta", textDelta: " \n\t" }),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
generateThreadTitle(cloud, {
|
|
79
|
+
threadId: "thread-1",
|
|
80
|
+
messages: [],
|
|
81
|
+
}),
|
|
82
|
+
).resolves.toBeNull();
|
|
83
|
+
|
|
84
|
+
expect(update).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("trims surrounding whitespace from a generated title", async () => {
|
|
88
|
+
const { cloud, update } = createCloud(
|
|
89
|
+
titleStream({ type: "text-delta", textDelta: " Weather chat \n" }),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
generateThreadTitle(cloud, {
|
|
94
|
+
threadId: "thread-1",
|
|
95
|
+
messages: [],
|
|
96
|
+
}),
|
|
97
|
+
).resolves.toBe("Weather chat");
|
|
98
|
+
|
|
99
|
+
expect(update).toHaveBeenCalledExactlyOnceWith("thread-1", {
|
|
100
|
+
title: "Weather chat",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
71
103
|
});
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,10 @@ export type {
|
|
|
3
3
|
AssistantCloudThreadMessageFeedbackBody,
|
|
4
4
|
AssistantCloudThreadMessageFeedbackResponse,
|
|
5
5
|
} from "./AssistantCloudThreadMessages";
|
|
6
|
-
export type {
|
|
6
|
+
export type {
|
|
7
|
+
AssistantCloudTelemetryConfig,
|
|
8
|
+
SdkIdentity,
|
|
9
|
+
} from "./AssistantCloudAPI";
|
|
7
10
|
export {
|
|
8
11
|
AssistantCloudEvents,
|
|
9
12
|
type AssistantCloudEvent,
|
|
@@ -28,6 +31,7 @@ export {
|
|
|
28
31
|
normalizeRunTelemetryUsage,
|
|
29
32
|
truncateRunTelemetryText,
|
|
30
33
|
type AssistantCloudRunReportToolCall,
|
|
34
|
+
type RunMessageTelemetry,
|
|
31
35
|
type RunReportInit,
|
|
32
36
|
type RunReportOutcome,
|
|
33
37
|
type RunReportStepInit,
|
|
@@ -36,6 +40,12 @@ export {
|
|
|
36
40
|
type RunTelemetryUsageInit,
|
|
37
41
|
} from "./runTelemetry";
|
|
38
42
|
export { AssistantCloud } from "./AssistantCloud";
|
|
43
|
+
export { CloudRunReporter, type CloudRunReportInit } from "./CloudRunReporter";
|
|
44
|
+
export {
|
|
45
|
+
CloudEngagementReporter,
|
|
46
|
+
type EngagementEventIds,
|
|
47
|
+
type EngagementIdResolver,
|
|
48
|
+
} from "./CloudEngagementReporter";
|
|
39
49
|
export { readAnonymousRefreshToken } from "./AssistantCloudAuthStrategy";
|
|
40
50
|
export { CloudMessagePersistence } from "./CloudMessagePersistence";
|
|
41
51
|
export {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
wrapSamplingHandler,
|
|
4
|
+
createSamplingCollector,
|
|
5
|
+
type McpSamplingRequest,
|
|
6
|
+
} from "./instrumentMcpSampling";
|
|
7
|
+
|
|
8
|
+
const request: McpSamplingRequest = {
|
|
9
|
+
method: "sampling/createMessage",
|
|
10
|
+
params: { messages: [], maxTokens: 10 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("wrapSamplingHandler", () => {
|
|
14
|
+
it("returns the response and records sampling metrics", async () => {
|
|
15
|
+
const collector = createSamplingCollector();
|
|
16
|
+
const response = {
|
|
17
|
+
model: "test-model",
|
|
18
|
+
content: { type: "text", text: "hello" },
|
|
19
|
+
usage: { inputTokens: 2, outputTokens: 3 },
|
|
20
|
+
};
|
|
21
|
+
const handler = vi.fn(async () => response);
|
|
22
|
+
await expect(
|
|
23
|
+
wrapSamplingHandler(handler, collector.collect)(request),
|
|
24
|
+
).resolves.toBe(response);
|
|
25
|
+
expect(handler).toHaveBeenCalledWith(request);
|
|
26
|
+
expect(collector.getCalls()).toEqual([
|
|
27
|
+
expect.objectContaining({
|
|
28
|
+
model_id: "test-model",
|
|
29
|
+
input_tokens: 2,
|
|
30
|
+
output_tokens: 3,
|
|
31
|
+
duration_ms: expect.any(Number),
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each([false, true])(
|
|
37
|
+
"preserves successful responses when the observer fails asynchronously=%s",
|
|
38
|
+
async (asynchronous) => {
|
|
39
|
+
const error = new Error("metrics unavailable");
|
|
40
|
+
const observer = asynchronous
|
|
41
|
+
? async () => {
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
: () => {
|
|
45
|
+
throw error;
|
|
46
|
+
};
|
|
47
|
+
const response = { content: "successful response" };
|
|
48
|
+
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
49
|
+
try {
|
|
50
|
+
await expect(
|
|
51
|
+
wrapSamplingHandler(async () => response, observer)(request),
|
|
52
|
+
).resolves.toBe(response);
|
|
53
|
+
expect(log).toHaveBeenCalledWith(
|
|
54
|
+
"[assistant-cloud] onSamplingCall callback threw an error",
|
|
55
|
+
error,
|
|
56
|
+
);
|
|
57
|
+
} finally {
|
|
58
|
+
log.mockRestore();
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
it("does not await an asynchronous observer", async () => {
|
|
64
|
+
const response = { content: "successful response" };
|
|
65
|
+
await expect(
|
|
66
|
+
wrapSamplingHandler(
|
|
67
|
+
async () => response,
|
|
68
|
+
() => new Promise<void>(() => {}),
|
|
69
|
+
)(request),
|
|
70
|
+
).resolves.toBe(response);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("propagates model errors without calling the observer", async () => {
|
|
74
|
+
const error = new Error("model failed");
|
|
75
|
+
const observer = vi.fn();
|
|
76
|
+
await expect(
|
|
77
|
+
wrapSamplingHandler(async () => {
|
|
78
|
+
throw error;
|
|
79
|
+
}, observer)(request),
|
|
80
|
+
).rejects.toBe(error);
|
|
81
|
+
expect(observer).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("preserves successful responses even if error reporting throws", async () => {
|
|
85
|
+
const response = { content: "successful response" };
|
|
86
|
+
const log = vi.spyOn(console, "error").mockImplementation(() => {
|
|
87
|
+
throw new Error("logging failed");
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
await expect(
|
|
91
|
+
wrapSamplingHandler(
|
|
92
|
+
async () => response,
|
|
93
|
+
() => {
|
|
94
|
+
throw new Error("metrics failed");
|
|
95
|
+
},
|
|
96
|
+
)(request),
|
|
97
|
+
).resolves.toBe(response);
|
|
98
|
+
} finally {
|
|
99
|
+
log.mockRestore();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -43,6 +43,15 @@ export type McpSamplingResponse = {
|
|
|
43
43
|
[key: string]: unknown;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
const reportSamplingError = (error: unknown): void => {
|
|
47
|
+
try {
|
|
48
|
+
console.error(
|
|
49
|
+
"[assistant-cloud] onSamplingCall callback threw an error",
|
|
50
|
+
error,
|
|
51
|
+
);
|
|
52
|
+
} catch {}
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
/**
|
|
47
56
|
* Wraps an MCP sampling handler to intercept and measure sampling calls.
|
|
48
57
|
*
|
|
@@ -80,7 +89,7 @@ export function wrapSamplingHandler(
|
|
|
80
89
|
const reasoningTokens = response.usage?.reasoningTokens;
|
|
81
90
|
const cachedInputTokens = response.usage?.cachedInputTokens;
|
|
82
91
|
|
|
83
|
-
|
|
92
|
+
const data: SamplingCallData = {
|
|
84
93
|
...(modelId ? { model_id: modelId } : undefined),
|
|
85
94
|
...(inputTokens != null ? { input_tokens: inputTokens } : undefined),
|
|
86
95
|
...(outputTokens != null ? { output_tokens: outputTokens } : undefined),
|
|
@@ -91,7 +100,12 @@ export function wrapSamplingHandler(
|
|
|
91
100
|
? { cached_input_tokens: cachedInputTokens }
|
|
92
101
|
: undefined),
|
|
93
102
|
duration_ms: durationMs,
|
|
94
|
-
}
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
void Promise.resolve(onSamplingCall(data)).catch(reportSamplingError);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
reportSamplingError(error);
|
|
108
|
+
}
|
|
95
109
|
|
|
96
110
|
return response;
|
|
97
111
|
};
|