Skip to content

Commit 3ac7729

Browse files
lsr911claudesteipete
authored
fix(mistral): use truncateUtf16Safe for error text truncation (#102539)
* fix(mistral): use truncateUtf16Safe for error text truncation Replace naive .slice(0, maxChars) with truncateUtf16Safe() in truncateErrorText() to prevent surrogate pair splitting in Mistral provider error messages shown to users. Co-Authored-By: Claude <[email protected]> * fix(ai): keep Mistral errors UTF-16 safe --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 32510a3 commit 3ac7729

2 files changed

Lines changed: 20 additions & 2 deletions

File tree

packages/ai/src/providers/mistral.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-bound
77
const mistralMockState = vi.hoisted(() => ({
88
configs: [] as unknown[],
99
payloads: [] as unknown[],
10+
streamError: new Error("stop before network") as unknown,
1011
}));
1112

1213
vi.mock("@mistralai/mistralai", async () => {
@@ -27,7 +28,7 @@ vi.mock("@mistralai/mistralai", async () => {
2728
chat = {
2829
stream: vi.fn(async (payload: unknown) => {
2930
mistralMockState.payloads.push(payload);
30-
throw new Error("stop before network");
31+
throw mistralMockState.streamError;
3132
}),
3233
};
3334
},
@@ -77,6 +78,7 @@ describe("Mistral provider", () => {
7778
beforeEach(() => {
7879
mistralMockState.configs = [];
7980
mistralMockState.payloads = [];
81+
mistralMockState.streamError = new Error("stop before network");
8082
});
8183

8284
afterEach(() => {
@@ -95,6 +97,20 @@ describe("Mistral provider", () => {
9597
expect((mistralMockState.payloads[0] as { stop?: unknown }).stop).toEqual(["STOP"]);
9698
});
9799

100+
it("keeps truncated Mistral error bodies UTF-16 safe with an exact omitted count", async () => {
101+
const prefix = "a".repeat(3_999);
102+
mistralMockState.streamError = Object.assign(new Error("invalid request"), {
103+
statusCode: 400,
104+
body: `${prefix}😀tail`,
105+
});
106+
107+
const result = await streamMistral(makeMistralModel(), context, {
108+
apiKey: "sk-mistral-provider",
109+
}).result();
110+
111+
expect(result.errorMessage).toBe(`Mistral API error (400): ${prefix}... [truncated 6 chars]`);
112+
});
113+
98114
it("routes the Mistral HTTPClient through the host guarded fetch", async () => {
99115
const hostFetch = vi.fn<typeof fetch>(async () => new Response("guarded"));
100116
configureAiTransportHost({ buildModelFetch: () => hostFetch });

packages/ai/src/providers/mistral.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ContentChunk,
88
FunctionTool,
99
} from "@mistralai/mistralai/models/components";
10+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
1011
import { getEnvApiKey } from "../env-api-keys.js";
1112
import { getAiTransportHost } from "../host.js";
1213
import { calculateCost, clampThinkingLevel } from "../model-utils.js";
@@ -295,7 +296,8 @@ function truncateErrorText(text: string, maxChars: number): string {
295296
if (text.length <= maxChars) {
296297
return text;
297298
}
298-
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
299+
const truncated = truncateUtf16Safe(text, maxChars);
300+
return `${truncated}... [truncated ${text.length - truncated.length} chars]`;
299301
}
300302

301303
function safeJsonStringify(value: unknown): string {

0 commit comments

Comments
 (0)