assistant-cloud 0.2.0 → 0.2.1
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 +26 -0
- package/dist/AssistantCloud.d.ts +2 -1
- 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 +11 -2
- 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.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +4 -2
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudEvents.d.ts.map +1 -1
- package/dist/AssistantCloudEvents.js +4 -0
- package/dist/AssistantCloudEvents.js.map +1 -1
- package/dist/AssistantCloudRuns.d.ts +1 -0
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js +2 -1
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/CloudEngagementReporter.d.ts +74 -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 +8 -9
- 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 +20 -0
- package/dist/CloudRunReporter.d.ts.map +1 -0
- package/dist/CloudRunReporter.js +38 -0
- package/dist/CloudRunReporter.js.map +1 -0
- package/dist/FormattedCloudPersistence.d.ts +3 -8
- 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 +33 -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/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.map +1 -1
- package/dist/instrumentMcpSampling.js +12 -2
- package/dist/instrumentMcpSampling.js.map +1 -1
- package/dist/runTelemetry.d.ts +17 -1
- package/dist/runTelemetry.d.ts.map +1 -1
- package/dist/runTelemetry.js.map +1 -1
- package/dist/version.d.ts +5 -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 +19 -6
- package/src/AssistantCloud.ts +3 -0
- package/src/AssistantCloudAPI.ts +31 -1
- package/src/AssistantCloudAuthStrategy.ts +4 -2
- package/src/AssistantCloudEvents.test.ts +27 -2
- package/src/AssistantCloudEvents.ts +5 -0
- package/src/AssistantCloudRuns.ts +1 -0
- package/src/CloudEngagementReporter.ts +231 -0
- package/src/CloudMessagePersistence.ts +24 -13
- package/src/CloudRunReporter.ts +38 -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 +100 -0
- package/src/version.ts +4 -0
|
@@ -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
|
};
|
package/src/runTelemetry.ts
CHANGED
|
@@ -225,6 +225,23 @@ export type RunReportStepInit = {
|
|
|
225
225
|
finishReason?: string | undefined;
|
|
226
226
|
};
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The run report fields read from the messages of one run, in whichever
|
|
230
|
+
* format they were stored: the status the messages imply, the tool calls, the
|
|
231
|
+
* steps, the text, the usage and the model.
|
|
232
|
+
*/
|
|
233
|
+
export type RunMessageTelemetry = {
|
|
234
|
+
assistantMessageId?: string;
|
|
235
|
+
status: "completed" | "incomplete";
|
|
236
|
+
toolCalls?: AssistantCloudRunReportToolCall[];
|
|
237
|
+
steps?: RunReportStepInit[];
|
|
238
|
+
totalSteps?: number;
|
|
239
|
+
outputText?: string;
|
|
240
|
+
usage?: RunTelemetryUsage;
|
|
241
|
+
modelId?: string;
|
|
242
|
+
metadata?: Record<string, unknown>;
|
|
243
|
+
};
|
|
244
|
+
|
|
228
245
|
export type RunReportInit = {
|
|
229
246
|
threadId: string;
|
|
230
247
|
status: AssistantCloudRunReport["status"];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { AssistantCloud } from "../AssistantCloud";
|
|
3
3
|
import type { AssistantCloudTelemetryConfig } from "../AssistantCloudAPI";
|
|
4
4
|
|
|
@@ -13,6 +13,10 @@ const createCloud = (
|
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
describe("AssistantCloud telemetry config", () => {
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
|
|
16
20
|
it("defaults to enabled", () => {
|
|
17
21
|
expect(createCloud().telemetry.enabled).toBe(true);
|
|
18
22
|
expect(createCloud(true).telemetry.enabled).toBe(true);
|
|
@@ -58,4 +62,32 @@ describe("AssistantCloud telemetry config", () => {
|
|
|
58
62
|
tags: ["region:sg", "tier:paid"],
|
|
59
63
|
});
|
|
60
64
|
});
|
|
65
|
+
|
|
66
|
+
it("forwards registered SDK identities to requests and stream options", async () => {
|
|
67
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
68
|
+
ok: true,
|
|
69
|
+
headers: new Headers(),
|
|
70
|
+
text: vi.fn().mockResolvedValue(JSON.stringify({ threads: [] })),
|
|
71
|
+
});
|
|
72
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
73
|
+
|
|
74
|
+
const cloud = createCloud();
|
|
75
|
+
cloud.registerSdk({ name: "@assistant-ui/core", version: "0.3.18" });
|
|
76
|
+
|
|
77
|
+
await cloud.threads.list();
|
|
78
|
+
|
|
79
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
80
|
+
expect(init.headers).toMatchObject({
|
|
81
|
+
"Aui-Sdk": expect.stringMatching(
|
|
82
|
+
/^assistant-cloud\/.* @assistant-ui\/core\/0\.3\.18$/,
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
await expect(
|
|
86
|
+
cloud.runs.__internal_getAssistantOptions("assistant-id").headers(),
|
|
87
|
+
).resolves.toMatchObject({
|
|
88
|
+
"Aui-Sdk": expect.stringMatching(
|
|
89
|
+
/^assistant-cloud\/.* @assistant-ui\/core\/0\.3\.18$/,
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
61
93
|
});
|
|
@@ -45,6 +45,7 @@ describe("AssistantCloudAPI", () => {
|
|
|
45
45
|
Authorization: "Bearer test-key",
|
|
46
46
|
"Aui-User-Id": "u-1",
|
|
47
47
|
"Aui-Workspace-Id": "w-1",
|
|
48
|
+
"Aui-Sdk": expect.stringMatching(/^assistant-cloud\//),
|
|
48
49
|
"Content-Type": "application/json",
|
|
49
50
|
"X-Test": "1",
|
|
50
51
|
});
|
|
@@ -53,6 +54,56 @@ describe("AssistantCloudAPI", () => {
|
|
|
53
54
|
expect(init.body).toBe(JSON.stringify({ hello: "world" }));
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
it("ignores identities that cannot travel in a header", () => {
|
|
58
|
+
const api = new AssistantCloudAPI({
|
|
59
|
+
apiKey: "test-key",
|
|
60
|
+
userId: "u-1",
|
|
61
|
+
workspaceId: "w-1",
|
|
62
|
+
});
|
|
63
|
+
api.registerSdk({ name: "bad name", version: "1.0.0" });
|
|
64
|
+
api.registerSdk({ name: "@scope/pkg", version: "1.0.0 ok" });
|
|
65
|
+
api.registerSdk({ name: "@scope/pkg\ttab", version: "1.0.0" });
|
|
66
|
+
api.registerSdk({ name: "@scope/ok", version: " 1.0.0 " });
|
|
67
|
+
|
|
68
|
+
expect(api.sdkHeader().split(" ")).toEqual([
|
|
69
|
+
expect.stringMatching(/^assistant-cloud\//),
|
|
70
|
+
"@scope/ok/1.0.0",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("sends each registered SDK identity once in registration order", async () => {
|
|
75
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
76
|
+
ok: true,
|
|
77
|
+
headers: new Headers(),
|
|
78
|
+
json: vi.fn().mockResolvedValue({}),
|
|
79
|
+
});
|
|
80
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
81
|
+
|
|
82
|
+
const api = new AssistantCloudAPI({
|
|
83
|
+
apiKey: "test-key",
|
|
84
|
+
userId: "u-1",
|
|
85
|
+
workspaceId: "w-1",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
api.registerSdk({ name: " @assistant-ui/core ", version: " 0.3.18 " });
|
|
89
|
+
api.registerSdk({ name: "@assistant-ui/core", version: "0.3.18" });
|
|
90
|
+
api.registerSdk({ name: "@assistant-ui/ai-sdk", version: "0.0.5" });
|
|
91
|
+
api.registerSdk({ name: " ", version: "0.0.5" });
|
|
92
|
+
api.registerSdk({ name: "@assistant-ui/react-langgraph", version: " " });
|
|
93
|
+
|
|
94
|
+
await api.makeRawRequest("/threads", {
|
|
95
|
+
headers: { "Aui-Sdk": "overridden" },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
99
|
+
expect(init.headers).toMatchObject({ "Aui-Sdk": api.sdkHeader() });
|
|
100
|
+
expect(api.sdkHeader().split(" ")).toEqual([
|
|
101
|
+
expect.stringMatching(/^assistant-cloud\//),
|
|
102
|
+
"@assistant-ui/core/0.3.18",
|
|
103
|
+
"@assistant-ui/ai-sdk/0.0.5",
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
56
107
|
it("uses custom baseUrl when provided with apiKey config", async () => {
|
|
57
108
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
58
109
|
ok: true,
|
|
@@ -950,4 +950,26 @@ describe("AssistantCloudJWTAuthStrategy", () => {
|
|
|
950
950
|
});
|
|
951
951
|
expect(authToken).toHaveBeenCalledTimes(2);
|
|
952
952
|
});
|
|
953
|
+
|
|
954
|
+
it("does not cache a malformed rotated token", async () => {
|
|
955
|
+
const authToken = vi
|
|
956
|
+
.fn<() => Promise<string | null>>()
|
|
957
|
+
.mockResolvedValue(accessToken);
|
|
958
|
+
const strategy = new AssistantCloudJWTAuthStrategy(authToken);
|
|
959
|
+
|
|
960
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
961
|
+
Authorization: `Bearer ${accessToken}`,
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
expect(() =>
|
|
965
|
+
strategy.readAuthHeaders(
|
|
966
|
+
new Headers({ Authorization: "Bearer malformed" }),
|
|
967
|
+
),
|
|
968
|
+
).toThrow("Unable to determine the token expiry");
|
|
969
|
+
|
|
970
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
971
|
+
Authorization: `Bearer ${accessToken}`,
|
|
972
|
+
});
|
|
973
|
+
expect(authToken).toHaveBeenCalledTimes(1);
|
|
974
|
+
});
|
|
953
975
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach,
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
2
|
import { AssistantCloudProjects } from "../AssistantCloudProjects";
|
|
3
3
|
import type { AssistantCloudAPI } from "../AssistantCloudAPI";
|
|
4
4
|
|
|
@@ -19,10 +19,6 @@ describe("AssistantCloudProjects", () => {
|
|
|
19
19
|
projects = new AssistantCloudProjects(mockApi);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
vi.clearAllMocks();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
22
|
it("lists project threads with the query", async () => {
|
|
27
23
|
vi.mocked(mockApi.makeRequest).mockResolvedValue({ threads: [] });
|
|
28
24
|
|