@vellumai/assistant 0.11.4-dev.202608192308.82015f7 → 0.11.4-dev.202608200019.f77fe6a

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608192308.82015f7",
3
+ "version": "0.11.4-dev.202608200019.f77fe6a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -145,7 +145,7 @@
145
145
  "scope": "client",
146
146
  "key": "fork-from-message",
147
147
  "label": "Fork from Message",
148
- "description": "Show the 'Fork from here' option in message overflow menus",
148
+ "description": "Show the fork affordance: 'Fork from here' on messages and 'Fork Conversation' in the thread menu. Staff-only: the client also requires the viewer to be Vellum staff, so enabling this for a non-staff account still shows nothing.",
149
149
  "defaultEnabled": false
150
150
  },
151
151
  {
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Tests that an unclassified 4xx provider rejection logs the upstream
3
+ * response body (bounded) plus the offending request content-part coordinates,
4
+ * so the failing message can be identified from the daemon log alone.
5
+ */
6
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
7
+
8
+ import type { AssistantEvent } from "../../api/index.js";
9
+ import { ProviderError } from "../../util/errors.js";
10
+ import * as loggerModule from "../../util/logger.js";
11
+ import type {
12
+ EventHandlerDeps,
13
+ EventHandlerState,
14
+ } from "../conversation-agent-loop-handlers.js";
15
+ import {
16
+ buildProviderRejectionLogFields,
17
+ MAX_LOGGED_UPSTREAM_BODY_CHARS,
18
+ } from "../provider-rejection-log-fields.js";
19
+
20
+ const logRecords: Record<string, unknown>[] = [];
21
+
22
+ mock.module("../../util/logger.js", () => ({
23
+ ...loggerModule,
24
+ getLogger: () => ({
25
+ error: (record: Record<string, unknown>) => {
26
+ logRecords.push(record);
27
+ },
28
+ warn: () => {},
29
+ info: () => {},
30
+ debug: () => {},
31
+ trace: () => {},
32
+ }),
33
+ }));
34
+
35
+ // The handlers module binds its logger at import time, so it is loaded
36
+ // dynamically once the logger stub is installed above.
37
+ const { createEventHandlerState, dispatchAgentEvent } =
38
+ await import("../conversation-agent-loop-handlers.js");
39
+
40
+ function createDeps(): EventHandlerDeps {
41
+ return {
42
+ ctx: {
43
+ conversationId: "conv-provider-rejection",
44
+ provider: { name: "openai" },
45
+ streamThinking: false,
46
+ emitActivityState: () => {},
47
+ markWorkspaceTopLevelDirty: () => {},
48
+ currentTurnSurfaces: [],
49
+ } as unknown as EventHandlerDeps["ctx"],
50
+ onEvent: (_msg: AssistantEvent) => {},
51
+ reqId: "req-provider-rejection",
52
+ isFirstMessage: false,
53
+ shouldGenerateTitle: false,
54
+ rlog: new Proxy({} as Record<string, unknown>, {
55
+ get: () => () => {},
56
+ }) as unknown as EventHandlerDeps["rlog"],
57
+ turnChannelContext: {
58
+ userMessageChannel: "vellum",
59
+ assistantMessageChannel: "vellum",
60
+ } as EventHandlerDeps["turnChannelContext"],
61
+ turnInterfaceContext: {
62
+ userMessageInterface: "macos",
63
+ assistantMessageInterface: "macos",
64
+ } as EventHandlerDeps["turnInterfaceContext"],
65
+ } as EventHandlerDeps;
66
+ }
67
+
68
+ describe("buildProviderRejectionLogFields", () => {
69
+ test("carries the verbatim upstream body", () => {
70
+ // GIVEN a provider rejection that captured the upstream 400 body
71
+ const rawBody = JSON.stringify({
72
+ error: {
73
+ message: "Invalid body: failed to parse JSON value from the request.",
74
+ type: "invalid_request_error",
75
+ code: "invalid_body",
76
+ },
77
+ });
78
+ const error = new ProviderError(
79
+ "API error (400): Invalid body",
80
+ "openai",
81
+ 400,
82
+ {
83
+ rawBody,
84
+ apiErrorCode: "invalid_body",
85
+ apiErrorType: "invalid_request_error",
86
+ requestId: "req_abc123",
87
+ },
88
+ );
89
+
90
+ // WHEN diagnostic log fields are built for it
91
+ const fields = buildProviderRejectionLogFields(error);
92
+
93
+ // THEN the body and the upstream metadata ride the record
94
+ expect(fields.upstreamErrorBody).toBe(rawBody);
95
+ expect(fields.upstreamErrorBodyTruncated).toBeUndefined();
96
+ expect(fields.apiErrorCode).toBe("invalid_body");
97
+ expect(fields.apiErrorType).toBe("invalid_request_error");
98
+ expect(fields.requestId).toBe("req_abc123");
99
+ });
100
+
101
+ test("bounds the body to 4 KB and flags the truncation", () => {
102
+ // GIVEN an upstream body larger than the logged ceiling
103
+ const rawBody = "x".repeat(MAX_LOGGED_UPSTREAM_BODY_CHARS + 500);
104
+ const error = new ProviderError("API error (400): huge", "openai", 400, {
105
+ rawBody,
106
+ });
107
+
108
+ // WHEN diagnostic log fields are built for it
109
+ const fields = buildProviderRejectionLogFields(error);
110
+
111
+ // THEN the logged body stops at the ceiling
112
+ expect(fields.upstreamErrorBody).toHaveLength(
113
+ MAX_LOGGED_UPSTREAM_BODY_CHARS,
114
+ );
115
+ // AND the record says the body was cut
116
+ expect(fields.upstreamErrorBodyTruncated).toBe(true);
117
+ });
118
+
119
+ test("parses the offending content-part coordinates for oversized strings", () => {
120
+ // GIVEN an oversized-content-part rejection naming the request position
121
+ const error = new ProviderError(
122
+ "API error (400): Invalid 'input[191].content[1].text': string too long. Expected a string with maximum length 10485760, but got a string with length 11436754 instead.",
123
+ "openai",
124
+ 400,
125
+ { apiErrorCode: "string_above_max_length" },
126
+ );
127
+
128
+ // WHEN diagnostic log fields are built for it
129
+ const fields = buildProviderRejectionLogFields(error);
130
+
131
+ // THEN the message and content indices are logged for a follow-up query
132
+ expect(fields.offendingMessageIndex).toBe(191);
133
+ expect(fields.offendingContentIndex).toBe(1);
134
+ });
135
+
136
+ test("parses coordinates out of the Chat Completions `messages[N]` shape", () => {
137
+ // GIVEN a rejection using the Chat Completions request shape
138
+ const error = new ProviderError(
139
+ "API error (400): Invalid 'messages[7].content[0].text': string too long.",
140
+ "openai",
141
+ 400,
142
+ { apiErrorCode: "string_above_max_length" },
143
+ );
144
+
145
+ // WHEN diagnostic log fields are built for it
146
+ const fields = buildProviderRejectionLogFields(error);
147
+
148
+ // THEN the same coordinates are extracted
149
+ expect(fields.offendingMessageIndex).toBe(7);
150
+ expect(fields.offendingContentIndex).toBe(0);
151
+ });
152
+
153
+ test("leaves coordinates absent for codes that carry no content-part pointer", () => {
154
+ // GIVEN a rejection whose code is outside the narrow tap
155
+ const error = new ProviderError(
156
+ "API error (400): Invalid 'input[3].content[0].text': something else.",
157
+ "openai",
158
+ 400,
159
+ { apiErrorCode: "model_not_found" },
160
+ );
161
+
162
+ // WHEN diagnostic log fields are built for it
163
+ const fields = buildProviderRejectionLogFields(error);
164
+
165
+ // THEN no coordinates are inferred from a coincidental match
166
+ expect(fields.offendingMessageIndex).toBeUndefined();
167
+ expect(fields.offendingContentIndex).toBeUndefined();
168
+ });
169
+
170
+ test("scrubs secrets out of the upstream body", () => {
171
+ // GIVEN an upstream body that echoed back a credential
172
+ const projectKey = `sk-proj-${"A".repeat(48)}`;
173
+ const error = new ProviderError("API error (400): bad key", "openai", 400, {
174
+ rawBody: JSON.stringify({
175
+ error: { message: `Incorrect API key provided: ${projectKey}` },
176
+ headers: { authorization: "Bearer some-token-value" },
177
+ }),
178
+ });
179
+
180
+ // WHEN diagnostic log fields are built for it
181
+ const fields = buildProviderRejectionLogFields(error);
182
+
183
+ // THEN the credential never reaches the log record
184
+ expect(fields.upstreamErrorBody).not.toContain(projectKey);
185
+ expect(fields.upstreamErrorBody).not.toContain("some-token-value");
186
+ expect(fields.upstreamErrorBody).toContain("[REDACTED]");
187
+ });
188
+
189
+ test("reports a null body for errors that are not provider errors", () => {
190
+ // GIVEN a non-provider error
191
+ const error = new Error("socket hang up");
192
+
193
+ // WHEN diagnostic log fields are built for it
194
+ const fields = buildProviderRejectionLogFields(error);
195
+
196
+ // THEN the body is explicitly null rather than missing
197
+ expect(fields.upstreamErrorBody).toBeNull();
198
+ });
199
+ });
200
+
201
+ describe("unclassified 4xx log line", () => {
202
+ let state: EventHandlerState;
203
+
204
+ beforeEach(() => {
205
+ logRecords.length = 0;
206
+ state = createEventHandlerState();
207
+ });
208
+
209
+ test("includes the upstream response body", async () => {
210
+ // GIVEN an unclassified 4xx provider rejection carrying the upstream body
211
+ const rawBody = JSON.stringify({
212
+ error: {
213
+ message:
214
+ "Invalid body: failed to parse JSON value from the request. Common errors include trailing commas.",
215
+ type: "invalid_request_error",
216
+ code: "invalid_body",
217
+ },
218
+ });
219
+ const error = new ProviderError(
220
+ "API error (400): Invalid body: failed to parse JSON value from the request.",
221
+ "openai",
222
+ 400,
223
+ { rawBody, apiErrorCode: "invalid_body" },
224
+ );
225
+
226
+ // WHEN the agent loop dispatches the error event
227
+ await dispatchAgentEvent(state, createDeps(), { type: "error", error });
228
+
229
+ // THEN the log record carries the body alongside the existing fields
230
+ const record = logRecords.find((r) => r.upstreamErrorBody !== undefined);
231
+ expect(record).toBeDefined();
232
+ expect(record?.upstreamErrorBody).toBe(rawBody);
233
+ expect(record?.conversationId).toBe("conv-provider-rejection");
234
+ expect(record?.statusCode).toBe(400);
235
+ expect(record?.provider).toBe("openai");
236
+ });
237
+
238
+ test("scrubs secrets out of the error message it logs", async () => {
239
+ // GIVEN a rejection whose normalized message echoes a credential
240
+ const error = new ProviderError(
241
+ "API error (400): rejected request with Authorization: Bearer some-token-value",
242
+ "openai",
243
+ 400,
244
+ { rawBody: "{}" },
245
+ );
246
+
247
+ // WHEN the agent loop dispatches the error event
248
+ await dispatchAgentEvent(state, createDeps(), { type: "error", error });
249
+
250
+ // THEN the credential is redacted out of the logged message
251
+ const record = logRecords.find((r) => r.upstreamErrorBody !== undefined);
252
+ expect(record?.errorMessage).not.toContain("some-token-value");
253
+ expect(record?.errorMessage).toContain("Bearer [REDACTED]");
254
+ });
255
+
256
+ test("bounds a huge upstream body to 4 KB", async () => {
257
+ // GIVEN an unclassified 4xx rejection whose body exceeds the ceiling
258
+ const error = new ProviderError("API error (400): huge", "openai", 400, {
259
+ rawBody: "y".repeat(MAX_LOGGED_UPSTREAM_BODY_CHARS * 3),
260
+ });
261
+
262
+ // WHEN the agent loop dispatches the error event
263
+ await dispatchAgentEvent(state, createDeps(), { type: "error", error });
264
+
265
+ // THEN the logged body is capped
266
+ const record = logRecords.find((r) => r.upstreamErrorBody !== undefined);
267
+ expect(record?.upstreamErrorBody).toHaveLength(
268
+ MAX_LOGGED_UPSTREAM_BODY_CHARS,
269
+ );
270
+ expect(record?.upstreamErrorBodyTruncated).toBe(true);
271
+ });
272
+ });
@@ -89,6 +89,7 @@ import {
89
89
  } from "../usage/pricing.js";
90
90
  import { ProviderError } from "../util/errors.js";
91
91
  import { faviconUrlForDomain } from "../util/favicon.js";
92
+ import { redactLogString } from "../util/log-redact.js";
92
93
  import { getLogger } from "../util/logger.js";
93
94
  import { withSqliteRetry } from "../util/sqlite-retry.js";
94
95
  import type { DirectiveRequest } from "./assistant-attachments.js";
@@ -143,6 +144,7 @@ import type {
143
144
  WebSearchResultItem,
144
145
  } from "./message-types/web-activity.js";
145
146
  import { referenceMediaBlocksForPersist } from "./persist-media-references.js";
147
+ import { buildProviderRejectionLogFields } from "./provider-rejection-log-fields.js";
146
148
  import { turnOrRestingTrust } from "./trust-context-types.js";
147
149
  import type { TurnLatencyTracker } from "./turn-latency-tracker.js";
148
150
 
@@ -2708,7 +2710,8 @@ function handleError(
2708
2710
  event.error instanceof ProviderError
2709
2711
  ? event.error.provider
2710
2712
  : undefined,
2711
- errorMessage: event.error.message,
2713
+ errorMessage: redactLogString(event.error.message),
2714
+ ...buildProviderRejectionLogFields(event.error),
2712
2715
  },
2713
2716
  "Provider rejected request with unclassified 4xx error",
2714
2717
  );
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Log-field builder for provider rejections that carry no actionable
3
+ * classification of their own (unclassified 4xx).
4
+ *
5
+ * The extracted fields on `ProviderError` (`apiErrorCode`, `message`) drop the
6
+ * detail an operator needs to find the offending request: which request field,
7
+ * which message, which byte pattern. The verbatim upstream body carries it, so
8
+ * it rides the log record, bounded so a large provider error page cannot flood
9
+ * the log stream.
10
+ */
11
+
12
+ import { ProviderError } from "../util/errors.js";
13
+ import { redactLogString } from "../util/log-redact.js";
14
+
15
+ /**
16
+ * Ceiling for the logged upstream body. Bodies are already capped at 16 KiB
17
+ * when captured (`providers/openai/api-error-normalization.ts`); this second,
18
+ * tighter bound is what a log record carries.
19
+ */
20
+ export const MAX_LOGGED_UPSTREAM_BODY_CHARS = 4096;
21
+
22
+ /**
23
+ * Upstream error codes whose message names the offending request content part,
24
+ * e.g. `Invalid 'input[191].content[1].text': string too long`. Kept narrow:
25
+ * codes outside this set have no such pointer, so parsing their message would
26
+ * only produce coincidental matches.
27
+ */
28
+ const CONTENT_PART_POINTER_CODES = new Set([
29
+ "string_above_max_length",
30
+ "invalid_body",
31
+ ]);
32
+
33
+ /**
34
+ * Positional pointer into the request payload. `input[N]` is the Responses API
35
+ * shape, `messages[N]` the Chat Completions shape; both index the request's
36
+ * message array in send order, so `N` is an offset into the conversation
37
+ * history that produced the request.
38
+ */
39
+ const CONTENT_PART_POINTER = /\b(?:input|messages)\[(\d+)\]\.content\[(\d+)\]/i;
40
+
41
+ export interface ProviderRejectionLogFields {
42
+ /** Verbatim upstream non-2xx body, truncated to the logged ceiling. */
43
+ upstreamErrorBody: string | null;
44
+ /** True when the body was cut to fit the ceiling. */
45
+ upstreamErrorBodyTruncated?: boolean;
46
+ apiErrorCode?: string;
47
+ apiErrorType?: string;
48
+ apiErrorParam?: string;
49
+ requestId?: string;
50
+ /** Index of the offending message within the request's message array. */
51
+ offendingMessageIndex?: number;
52
+ /** Index of the offending content part within that message. */
53
+ offendingContentIndex?: number;
54
+ }
55
+
56
+ /**
57
+ * Extract the offending `input[N].content[M]` coordinates from an upstream
58
+ * error message, for the codes that carry such a pointer.
59
+ */
60
+ function parseContentPartPointer(
61
+ error: ProviderError,
62
+ ): Pick<
63
+ ProviderRejectionLogFields,
64
+ "offendingMessageIndex" | "offendingContentIndex"
65
+ > {
66
+ const code = error.apiErrorCode?.toLowerCase();
67
+ if (!code || !CONTENT_PART_POINTER_CODES.has(code)) {
68
+ return {};
69
+ }
70
+ const match = CONTENT_PART_POINTER.exec(
71
+ `${error.message} ${error.rawBody ?? ""}`,
72
+ );
73
+ if (!match) {
74
+ return {};
75
+ }
76
+ const messageIndex = Number(match[1]);
77
+ const contentIndex = Number(match[2]);
78
+ if (
79
+ !Number.isSafeInteger(messageIndex) ||
80
+ !Number.isSafeInteger(contentIndex)
81
+ ) {
82
+ return {};
83
+ }
84
+ return {
85
+ offendingMessageIndex: messageIndex,
86
+ offendingContentIndex: contentIndex,
87
+ };
88
+ }
89
+
90
+ /** Diagnostic fields describing what the provider actually rejected. */
91
+ export function buildProviderRejectionLogFields(
92
+ error: Error,
93
+ ): ProviderRejectionLogFields {
94
+ if (!(error instanceof ProviderError)) {
95
+ return { upstreamErrorBody: null };
96
+ }
97
+
98
+ // The body is arbitrary upstream text under a custom field name, so it is
99
+ // outside the reach of the pino `err`/`req`/`res` serializers and is scrubbed
100
+ // here before it reaches the record.
101
+ const rawBody =
102
+ error.rawBody === undefined ? undefined : redactLogString(error.rawBody);
103
+ const truncated =
104
+ rawBody !== undefined && rawBody.length > MAX_LOGGED_UPSTREAM_BODY_CHARS;
105
+
106
+ return {
107
+ upstreamErrorBody:
108
+ rawBody === undefined
109
+ ? null
110
+ : rawBody.slice(0, MAX_LOGGED_UPSTREAM_BODY_CHARS),
111
+ ...(truncated ? { upstreamErrorBodyTruncated: true } : {}),
112
+ ...(error.apiErrorCode !== undefined
113
+ ? { apiErrorCode: error.apiErrorCode }
114
+ : {}),
115
+ ...(error.apiErrorType !== undefined
116
+ ? { apiErrorType: error.apiErrorType }
117
+ : {}),
118
+ ...(error.apiErrorParam !== undefined
119
+ ? { apiErrorParam: error.apiErrorParam }
120
+ : {}),
121
+ ...(error.requestId !== undefined ? { requestId: error.requestId } : {}),
122
+ ...parseContentPartPointer(error),
123
+ };
124
+ }