fix(openai-completions): guard string assistant content in transform + tool-history (#93681)

When an assistant message's `content` is a raw string at runtime (JSONL
transcript replay passes it through even though the type declares an array),
the OpenAI-compatible completions path crashes:

- `transformMessages` called `assistantMsg.content.flatMap(...)` ->
  `TypeError: ... .flatMap is not a function` (first crash, always hit).
- Two `hasToolHistory` helpers (`openai-transport-stream.ts` and
  `openai-completions.ts`) called `content.some(...)` -> `TypeError: ...
  .some is not a function` (siblings, surface once the flatMap crash is fixed).

Normalize a string assistant content to an equivalent single text block
before transforming (matching the string->text handling already used in
anthropic-payload-policy.ts), and `Array.isArray`-guard both `hasToolHistory`
helpers so a string assistant simply does not count toward tool history.

Verified end-to-end through the real `buildOpenAICompletionsParams` and
`streamOpenAICompletions` entry points: before the fix a string-content
assistant followed by a toolResult throws TypeError; after the fix params are
produced correctly (string preserved as text, tool history detected). Normal
array content is unaffected.
This commit is contained in:
Alix-007
2026-06-17 01:32:46 +08:00
committed by GitHub
parent c1df7aa08b
commit e77fa3aeba
3 changed files with 17 additions and 3 deletions

View File

@@ -2524,7 +2524,11 @@ function hasToolHistory(messages: Context["messages"]): boolean {
return messages.some(
(message) =>
message.role === "toolResult" ||
(message.role === "assistant" && message.content.some((block) => block.type === "toolCall")),
// Assistant content can be a raw string from transcript replay; a string
// never carries tool calls, so it should not count toward tool history.
(message.role === "assistant" &&
Array.isArray(message.content) &&
message.content.some((block) => block.type === "toolCall")),
);
}

View File

@@ -63,7 +63,9 @@ function hasToolHistory(messages: Message[]): boolean {
return true;
}
if (msg.role === "assistant") {
if (msg.content.some((block) => block.type === "toolCall")) {
// Assistant content can be a raw string from transcript replay; a string
// never carries tool calls, so it should not count toward tool history.
if (Array.isArray(msg.content) && msg.content.some((block) => block.type === "toolCall")) {
return true;
}
}

View File

@@ -117,7 +117,15 @@ export function transformMessages<TApi extends Api>(
assistantMsg.api === model.api &&
assistantMsg.model === model.id);
const transformedContent = assistantMsg.content.flatMap((block) => {
// Assistant content is typed as a block array, but transcript replay can
// hand us a raw string (JSONL passthrough). Normalize it to an equivalent
// single text block before transforming, matching the string->text block
// handling already used in anthropic-payload-policy.ts.
const contentBlocks = Array.isArray(assistantMsg.content)
? assistantMsg.content
: [{ type: "text" as const, text: assistantMsg.content as unknown as string }];
const transformedContent = contentBlocks.flatMap((block) => {
if (block.type === "thinking") {
if (modelBoundThinkingReplayMode === "drop") {
return [];