Просмотр исходного кода

fix(ai): harden Responses item replay

Aiden Cline 1 неделя назад
Родитель
Сommit
50f76827bf

+ 44 - 48
packages/ai/src/protocols/open-responses.ts

@@ -286,10 +286,9 @@ export interface ParserState {
   readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
   readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
   readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
-  readonly store: boolean | undefined
 }
 
-type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
+type ReasoningSummaryStatus = "active" | "can-conclude"
 
 interface ReasoningStreamItem {
   readonly encryptedContent: string | null | undefined
@@ -328,10 +327,21 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
   })
 
 const responseItemID = (prefix: string, id: string) => {
-  const value = id.replace(/^[^_]+_/, "").replace(/[^a-zA-Z0-9_-]/g, "_")
-  return `${prefix}_${value || "item"}`.slice(0, 64)
+  const value = id.startsWith("call_") ? id.slice(5) : id
+  const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "item"
+  const direct = `${prefix}_${sanitized}`
+  if (value === sanitized && direct.length <= 64) return direct
+  const hash = Array.from(id).reduce(
+    (hash, character) => BigInt.asUintN(64, (hash ^ BigInt(character.codePointAt(0) ?? 0)) * 1099511628211n),
+    14695981039346656037n,
+  )
+  const suffix = hash.toString(36)
+  return `${prefix}_${sanitized.slice(0, 62 - prefix.length - suffix.length)}_${suffix}`
 }
 
+const validResponseItemID = (value: unknown): value is string =>
+  typeof value === "string" && value.length <= 64 && /^[a-zA-Z0-9]+_.+$/.test(value)
+
 const responseItemMetadata = (part: { readonly providerMetadata?: ProviderMetadata }, providerMetadataKey: string) => {
   const metadata = part.providerMetadata?.[providerMetadataKey]
   return ProviderShared.isRecord(metadata) ? metadata : undefined
@@ -341,7 +351,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
   const metadata = responseItemMetadata(part, providerMetadataKey)
   return {
     type: "function_call",
-    id: typeof metadata?.itemId === "string" ? metadata.itemId : responseItemID("fc", part.id),
+    id: validResponseItemID(metadata?.itemId) ? metadata.itemId : responseItemID("fc", part.id),
     call_id: part.id,
     name: part.name,
     arguments: ProviderShared.encodeJson(part.input),
@@ -351,15 +361,14 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
 
 const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
   const metadata = part.providerMetadata?.[providerMetadataKey]
-  if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
-    return undefined
+  if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
   const encryptedContent =
     typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
       ? metadata.reasoningEncryptedContent
       : undefined
   return {
     type: "reasoning",
-    id: metadata.itemId,
+    id: validResponseItemID(metadata.itemId) ? metadata.itemId : responseItemID("rs", metadata.itemId),
     summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
     encrypted_content: encryptedContent,
   }
@@ -372,14 +381,26 @@ const hostedToolItem = (part: ToolResultPart, providerMetadataKey: string): Open
     typeof metadata.responseItem.id === "string" &&
     typeof metadata.responseItem.type === "string"
   )
-    return { ...metadata.responseItem, type: metadata.responseItem.type, id: metadata.responseItem.id }
+    return {
+      ...metadata.responseItem,
+      type: metadata.responseItem.type,
+      id: validResponseItemID(metadata.responseItem.id)
+        ? metadata.responseItem.id
+        : responseItemID("item", metadata.responseItem.id),
+    }
   if (
     part.result.type === "json" &&
     ProviderShared.isRecord(part.result.value) &&
     typeof part.result.value.id === "string" &&
     typeof part.result.value.type === "string"
   )
-    return { ...part.result.value, type: part.result.value.type, id: part.result.value.id }
+    return {
+      ...part.result.value,
+      type: part.result.value.type,
+      id: validResponseItemID(part.result.value.id)
+        ? part.result.value.id
+        : responseItemID("item", part.result.value.id),
+    }
   return undefined
 }
 
@@ -472,6 +493,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
       const content: TextPart[] = []
       const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
       const hostedToolItems = new Set<string>()
+      let textItemIndex = 0
       const flushText = () => {
         if (content.length === 0) return
         const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
@@ -486,17 +508,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
           [],
         )
         input.push(
-          ...groups.map((group, index) => {
+          ...groups.map((group) => {
+            const index = textItemIndex++
             const first = group.parts[0]
             const metadata = first ? responseItemMetadata(first, providerMetadataKey) : undefined
-            const id =
-              typeof metadata?.itemId === "string"
-                ? metadata.itemId
-                : message.id === undefined
-                  ? undefined
-                  : index === 0
-                    ? message.id
-                    : responseItemID("msg", `${message.id}_${index}`)
+            const id = validResponseItemID(metadata?.itemId)
+              ? metadata.itemId
+              : message.id === undefined && typeof metadata?.itemId !== "string"
+                ? undefined
+                : index === 0 && validResponseItemID(message.id)
+                  ? message.id
+                  : responseItemID("msg", `${message.id ?? metadata?.itemId}_${index}`)
             return {
               type: "message" as const,
               ...(id === undefined ? {} : { id }),
@@ -810,23 +832,11 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
   }
 
   const events: LLMEvent[] = []
-  const closed = Object.entries(item.summaryParts)
-    .filter((entry) => entry[1] === "can-conclude")
-    .reduce(
-      (lifecycle, entry) =>
-        Lifecycle.reasoningEnd(
-          lifecycle,
-          events,
-          `${event.item_id}:${entry[0]}`,
-          providerMetadata(state, { itemId: event.item_id }),
-        ),
-      state.lifecycle,
-    )
   return [
     {
       ...state,
       lifecycle: Lifecycle.reasoningStart(
-        closed,
+        state.lifecycle,
         events,
         `${event.item_id}:${event.summary_index}`,
         providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
@@ -836,11 +846,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
         [event.item_id]: {
           ...item,
           summaryParts: {
-            ...Object.fromEntries(
-              Object.entries(item.summaryParts).map((entry) =>
-                entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
-              ),
-            ),
+            ...item.summaryParts,
             [event.summary_index]: "active",
           },
         },
@@ -858,22 +864,13 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
   return [
     {
       ...state,
-      lifecycle:
-        state.store !== false
-          ? Lifecycle.reasoningEnd(
-              state.lifecycle,
-              events,
-              `${event.item_id}:${event.summary_index}`,
-              providerMetadata(state, { itemId: event.item_id }),
-            )
-          : state.lifecycle,
       reasoningItems: {
         ...state.reasoningItems,
         [event.item_id]: {
           ...item,
           summaryParts: {
             ...item.summaryParts,
-            [event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
+            [event.summary_index]: "can-conclude",
           },
         },
       },
@@ -1089,7 +1086,6 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
   messagePhase: (value) => messagePhase(value, extension),
   messagePhases: {},
   reasoningItems: {},
-  store: OpenResponsesOptions.resolve(request).store,
 })
 
 const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {

+ 12 - 2
packages/ai/src/protocols/utils/lifecycle.ts

@@ -59,7 +59,9 @@ export const reasoningEnd = (
 ): State => {
   if (!state.reasoning.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.reasoningEnd({ id, providerMetadata: providerMetadata ?? stepped.reasoning.get(id) }))
+  events.push(
+    LLMEvent.reasoningEnd({ id, providerMetadata: mergeMetadata(stepped.reasoning.get(id), providerMetadata) }),
+  )
   const reasoning = new Map(stepped.reasoning)
   reasoning.delete(id)
   return { ...stepped, reasoning }
@@ -68,12 +70,20 @@ export const reasoningEnd = (
 export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
   if (!state.text.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.textEnd({ id, providerMetadata: providerMetadata ?? stepped.text.get(id) }))
+  events.push(LLMEvent.textEnd({ id, providerMetadata: mergeMetadata(stepped.text.get(id), providerMetadata) }))
   const text = new Map(stepped.text)
   text.delete(id)
   return { ...stepped, text }
 }
 
+const mergeMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => {
+  if (left === undefined) return right
+  if (right === undefined) return left
+  return Object.fromEntries(
+    Array.from(new Set([...Object.keys(left), ...Object.keys(right)]), (key) => [key, { ...left[key], ...right[key] }]),
+  )
+}
+
 const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
   for (const [id, providerMetadata] of state.reasoning) events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
   for (const [id, providerMetadata] of state.text) events.push(LLMEvent.textEnd({ id, providerMetadata }))

+ 69 - 23
packages/ai/test/provider/openai-responses.test.ts

@@ -1173,33 +1173,34 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(response.reasoning).toBe("FirstSecond")
-      expect(response.events).toMatchObject([
-        { type: "step-start", index: 0 },
+      expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
         {
           type: "reasoning-start",
           id: "rs_1:0",
           providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
         },
-        { type: "reasoning-delta", id: "rs_1:0", text: "First" },
-        { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
+        { type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
         {
           type: "reasoning-start",
           id: "rs_1:1",
           providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
         },
-        { type: "reasoning-delta", id: "rs_1:1", text: "Second" },
+        { type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
+        {
+          type: "reasoning-end",
+          id: "rs_1:0",
+          providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
+        },
         {
           type: "reasoning-end",
           id: "rs_1:1",
           providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
         },
-        { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
-        { type: "finish", reason: { normalized: "stop", raw: undefined } },
       ])
     }),
   )
 
-  it.effect("closes reasoning summary parts when storage is not disabled", () =>
+  it.effect("preserves complete reasoning metadata when storage is enabled", () =>
     Effect.gen(function* () {
       const response = yield* LLMClient.generate(
         LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
@@ -1219,7 +1220,7 @@ describe("OpenAI Responses route", () => {
               { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
               {
                 type: "response.output_item.done",
-                item: { type: "reasoning", id: "rs_1", encrypted_content: null },
+                item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
               },
               { type: "response.completed", response: { id: "resp_1" } },
             ),
@@ -1228,8 +1229,16 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
-        { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
-        { type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
+        {
+          type: "reasoning-end",
+          id: "rs_1:0",
+          providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
+        },
+        {
+          type: "reasoning-end",
+          id: "rs_1:1",
+          providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
+        },
       ])
     }),
   )
@@ -1355,6 +1364,37 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
+  it.effect("replaces invalid item ids without creating collisions", () =>
+    Effect.gen(function* () {
+      const prepared = yield* compileRequest(
+        LLM.request({
+          model,
+          messages: [
+            Message.make({
+              id: "msg_text",
+              role: "assistant",
+              content: [
+                { type: "text", text: "Ready.", providerMetadata: { openai: { itemId: "" } } },
+                {
+                  type: "reasoning",
+                  text: "Think.",
+                  providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "encrypted" } },
+                },
+                ToolCallPart.make({ id: "call_a/b", name: "one", input: {} }),
+                ToolCallPart.make({ id: "call_a?b", name: "two", input: {} }),
+              ],
+            }),
+          ],
+        }),
+      )
+      const ids = prepared.body.input.flatMap((item) => ("id" in item && typeof item.id === "string" ? [item.id] : []))
+
+      expect(ids).toHaveLength(4)
+      expect(new Set(ids).size).toBe(ids.length)
+      expect(ids.every((id) => /^[a-zA-Z0-9]+_.+$/.test(id) && id.length <= 64)).toBe(true)
+    }),
+  )
+
   it.effect("preserves assistant content order around reasoning items", () =>
     Effect.gen(function* () {
       const prepared = yield* compileRequest(
@@ -1362,20 +1402,24 @@ describe("OpenAI Responses route", () => {
           id: "req_reasoning_order",
           model,
           messages: [
-            Message.assistant([
-              { type: "text", text: "Before." },
-              {
-                type: "reasoning",
-                text: "Checked order.",
-                providerMetadata: {
-                  openai: {
-                    itemId: "rs_1",
-                    reasoningEncryptedContent: "encrypted-state",
+            Message.make({
+              id: "msg_assistant",
+              role: "assistant",
+              content: [
+                { type: "text", text: "Before." },
+                {
+                  type: "reasoning",
+                  text: "Checked order.",
+                  providerMetadata: {
+                    openai: {
+                      itemId: "rs_1",
+                      reasoningEncryptedContent: "encrypted-state",
+                    },
                   },
                 },
-              },
-              { type: "text", text: "After." },
-            ]),
+                { type: "text", text: "After." },
+              ],
+            }),
           ],
           providerOptions: { openai: { store: false } },
         }),
@@ -1384,6 +1428,7 @@ describe("OpenAI Responses route", () => {
       expect(prepared.body.input).toEqual([
         {
           type: "message",
+          id: "msg_assistant",
           role: "assistant",
           content: [{ type: "output_text", text: "Before." }],
           status: "completed",
@@ -1396,6 +1441,7 @@ describe("OpenAI Responses route", () => {
         },
         {
           type: "message",
+          id: "msg_msg_assistant_1",
           role: "assistant",
           content: [{ type: "output_text", text: "After." }],
           status: "completed",