Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1428,8 +1428,13 @@ export namespace Provider {
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (combined) opts.signal = combined

// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
// Strip responses item ids unless we are deliberately replaying stored Azure items.
// Azure uses a dedicated SDK package, so the check must cover both providers.
if (
(model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") &&
opts.body &&
opts.method === "POST"
) {
const body = JSON.parse(opts.body as string)
const isAzure = model.providerID.includes("azure")
const keepIds = isAzure && body.store === true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "@ai-sdk/provider"
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types"
import type { OpenAIResponsesInput, OpenAIResponsesInputItem, OpenAIResponsesReasoning } from "./openai-responses-api-types"
import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell"

/**
Expand Down Expand Up @@ -118,13 +118,58 @@ export async function convertToOpenAIResponsesInput({
}

case "assistant": {
const assistantInput: OpenAIResponsesInput = []
const reasoningMessages: Record<string, OpenAIResponsesReasoning> = {}
const toolCallParts: Record<string, LanguageModelV3ToolCallPart> = {}
let pendingOutput: OpenAIResponsesInputItem | undefined
let pendingReasoning: OpenAIResponsesInputItem | undefined

const pushAssistantOutput = (item: OpenAIResponsesInputItem) => {
if (pendingReasoning !== undefined) {
assistantInput.push(pendingReasoning)
pendingReasoning = undefined
assistantInput.push(item)
return
}

if (pendingOutput !== undefined) {
assistantInput.push(pendingOutput)
}

pendingOutput = item
}

const pushAssistantReasoning = (item: OpenAIResponsesInputItem) => {
if (pendingOutput !== undefined) {
assistantInput.push(item)
assistantInput.push(pendingOutput)
pendingOutput = undefined
return
}

if (pendingReasoning !== undefined) {
assistantInput.push(pendingReasoning)
}

pendingReasoning = item
}

const flushAssistantPending = () => {
if (pendingReasoning !== undefined) {
assistantInput.push(pendingReasoning)
pendingReasoning = undefined
}

if (pendingOutput !== undefined) {
assistantInput.push(pendingOutput)
pendingOutput = undefined
}
}

for (const part of content) {
switch (part.type) {
case "text": {
input.push({
pushAssistantOutput({
role: "assistant",
content: [{ type: "output_text", text: part.text }],
id: (part.providerOptions?.openai?.itemId as string) ?? undefined,
Expand All @@ -140,7 +185,7 @@ export async function convertToOpenAIResponsesInput({

if (hasLocalShellTool && part.toolName === "local_shell") {
const parsedInput = localShellInputSchema.parse(part.input)
input.push({
pushAssistantOutput({
type: "local_shell_call",
call_id: part.toolCallId,
id: (part.providerOptions?.openai?.itemId as string) ?? undefined,
Expand All @@ -157,7 +202,7 @@ export async function convertToOpenAIResponsesInput({
break
}

input.push({
pushAssistantOutput({
type: "function_call",
call_id: part.toolCallId,
name: part.toolName,
Expand All @@ -171,7 +216,7 @@ export async function convertToOpenAIResponsesInput({
case "tool-result": {
if (store) {
// use item references to refer to tool results from built-in tools
input.push({ type: "item_reference", id: part.toolCallId })
pushAssistantOutput({ type: "item_reference", id: part.toolCallId })
} else {
warnings.push({
type: "other",
Expand All @@ -197,7 +242,7 @@ export async function convertToOpenAIResponsesInput({
if (store) {
if (reasoningMessage === undefined) {
// use item references to refer to reasoning (single reference)
input.push({ type: "item_reference", id: reasoningId })
pushAssistantReasoning({ type: "item_reference", id: reasoningId })

// store unused reasoning message to mark id as used
reasoningMessages[reasoningId] = {
Expand Down Expand Up @@ -231,7 +276,7 @@ export async function convertToOpenAIResponsesInput({
encrypted_content: providerOptions?.reasoningEncryptedContent,
summary: summaryParts,
}
input.push(reasoningMessages[reasoningId])
pushAssistantReasoning(reasoningMessages[reasoningId])
} else {
reasoningMessage.summary.push(...summaryParts)
}
Expand All @@ -247,6 +292,9 @@ export async function convertToOpenAIResponsesInput({
}
}

flushAssistantPending()
input.push(...assistantInput)

break
}

Expand Down
103 changes: 103 additions & 0 deletions packages/opencode/test/provider/openai-responses-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test"
import { convertToOpenAIResponsesInput } from "../../src/provider/sdk/copilot/responses/convert-to-openai-responses-input"

describe("convertToOpenAIResponsesInput", () => {
test("emits reasoning before an assistant message when replay order arrives reversed", async () => {
const result = await convertToOpenAIResponsesInput({
prompt: [
{
role: "assistant",
content: [
{
type: "text",
text: "Hello",
providerOptions: {
openai: {
itemId: "msg_123",
},
},
},
{
type: "reasoning",
text: "thinking",
providerOptions: {
copilot: {
itemId: "rs_123",
reasoningEncryptedContent: "encrypted",
},
},
},
],
},
] as any,
systemMessageMode: "system",
store: false,
})

expect(result.input).toMatchObject([
{
type: "reasoning",
id: "rs_123",
encrypted_content: "encrypted",
summary: [{ type: "summary_text", text: "thinking" }],
},
{
role: "assistant",
id: "msg_123",
content: [{ type: "output_text", text: "Hello" }],
},
])
})

test("emits reasoning before a function call when replay order arrives reversed", async () => {
const result = await convertToOpenAIResponsesInput({
prompt: [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_123",
toolName: "read_file",
input: { filePath: "/README.md" },
providerExecuted: false,
providerOptions: {
openai: {
itemId: "fc_123",
},
},
},
{
type: "reasoning",
text: "thinking",
providerOptions: {
copilot: {
itemId: "rs_456",
reasoningEncryptedContent: "encrypted",
},
},
},
],
},
] as any,
systemMessageMode: "system",
store: false,
})

expect(result.input).toMatchObject([
{
type: "reasoning",
id: "rs_456",
encrypted_content: "encrypted",
summary: [{ type: "summary_text", text: "thinking" }],
},
{
type: "function_call",
id: "fc_123",
call_id: "call_123",
name: "read_file",
arguments: '{"filePath":"/README.md"}',
},
])
})
})
Loading