feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

@@ -2,7 +2,6 @@ package oaichat
import (
"fmt"
"strings"
"context"
@@ -13,6 +12,7 @@ import (
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
@@ -265,24 +265,27 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
if source == nil {
continue
}
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
// Claude content blocks carry images, PDF documents, and text
// documents; audio and video have no Claude block.
reason := fmt.Sprintf("Claude Messages cannot carry %s content", mediaMessage.Type)
var block dto.ClaudeMediaMessage
if mediaMessage.Type != dto.ContentTypeInputAudio && mediaMessage.Type != dto.ContentTypeVideoUrl {
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
block, reason = sharedclaude.MediaBlock(base64Data, mimeType, mediaMessage.Type == dto.ContentTypeImageURL)
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
if reason != "" {
convdiag.Add(c, types.ConversionDiagnostic{
Code: "unsupported_media_type",
Path: "messages.content",
Message: reason + "; the part was omitted",
Severity: types.ConversionDiagnosticError,
})
continue
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
claudeMediaMessages = append(claudeMediaMessages, block)
continue
}
}
@@ -303,6 +306,10 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
})
}
}
if len(claudeMediaMessages) == 0 {
// Every part was omitted; Claude rejects an empty content array.
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("...")})
}
claudeMessage.Content = claudeMediaMessages
}
claudeMessages = append(claudeMessages, claudeMessage)
@@ -241,9 +241,6 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
state.FinishReason = *chosenChoice.FinishReason
}
var claudeResponse dto.ClaudeResponse
var isEmpty bool
claudeResponse.Type = "content_block_delta"
if len(chosenChoice.Delta.ToolCalls) > 0 {
toolCalls := chosenChoice.Delta.ToolCalls
if state.LastMessagesType != convmeta.LastMessageTypeTools {
@@ -339,55 +336,58 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
}
} else {
// Some upstreams put the last reasoning token and the first answer
// text in one chunk: the thinking block closes before the text block opens.
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
if reasoning != "" || textContent != "" {
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeThinking
claudeResponse.Delta = &dto.ClaudeMediaMessage{
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeThinking
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: kitutil.GetPointer[int](state.Index),
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
}
} else {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeText
claudeResponse.Delta = &dto.ClaudeMediaMessage{
},
})
}
if textContent != "" {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
}
state.LastMessagesType = convmeta.LastMessageTypeText
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: kitutil.GetPointer[int](state.Index),
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](textContent),
}
}
} else {
isEmpty = true
},
})
}
}
claudeResponse.Index = kitutil.GetPointer[int](state.Index)
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
@@ -186,11 +186,8 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
if len(textRequest.ResponseFormat.JsonSchema) > 0 {
var jsonSchema dto.FormatJsonSchema
if err := kitutil.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
cleanedSchema := sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
}
sharedgemini.ApplyResponseSchema(c, &geminiRequest.GenerationConfig, textRequest.ResponseFormat.JsonSchema,
"response_format.json_schema.schema", opts.Gemini.SupportsJSONSchemaModel(upstreamModelName))
}
}
@@ -339,7 +336,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
if !sharedgemini.IsSupportedMimeType(mimeType) {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
}
@@ -9,6 +9,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/samber/lo"
@@ -227,9 +228,14 @@ func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.Gene
"text": part.Text,
})
case dto.ContentTypeImageURL:
var sourceDetail string
if img := part.GetImageMedia(); img != nil {
sourceDetail = img.Detail
}
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": normalizeChatImageURLToString(part.ImageUrl),
"detail": relaymedia.ResponsesImageDetail(sourceDetail),
})
case dto.ContentTypeInputAudio:
contentParts = append(contentParts, map[string]any{
@@ -237,10 +243,20 @@ func ChatCompletionsRequestToResponsesRequest(ctx context.Context, req *dto.Gene
"input_audio": part.InputAudio,
})
case dto.ContentTypeFile:
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file": part.File,
})
// Responses carries the file fields on the input_file part itself.
inputFile := map[string]any{"type": "input_file"}
if file := part.GetFile(); file != nil {
if file.FileName != "" {
inputFile["filename"] = file.FileName
}
if file.FileData != "" {
inputFile["file_data"] = file.FileData
}
if file.FileId != "" {
inputFile["file_id"] = file.FileId
}
}
contentParts = append(contentParts, inputFile)
case dto.ContentTypeVideoUrl:
contentParts = append(contentParts, map[string]any{
"type": "input_video",
@@ -40,6 +40,37 @@ func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
}
func TestChatCompletionsRequestToResponsesRequestImageDetail(t *testing.T) {
tests := []struct {
name string
imageURL any
want string
}{
{name: "object without detail defaults to auto", imageURL: map[string]any{"url": "https://example.test/a.png"}, want: "auto"},
{name: "low detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "low"}, want: "low"},
{name: "high detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "high"}, want: "high"},
{name: "auto detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "auto"}, want: "auto"},
{name: "original detail is preserved", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "original"}, want: "original"},
{name: "unknown detail falls back to auto", imageURL: map[string]any{"url": "https://example.test/a.png", "detail": "bogus"}, want: "auto"},
{name: "plain string url defaults to auto", imageURL: "https://example.test/a.png", want: "auto"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ChatCompletionsRequestToResponsesRequest(context.Background(), &dto.GeneralOpenAIRequest{
Model: "gpt-test",
Messages: []dto.Message{{Role: "user", Content: []any{
map[string]any{"type": "image_url", "image_url": tt.imageURL},
}}},
})
require.NoError(t, err)
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.0.type").String())
assert.Equal(t, tt.want, gjson.GetBytes(got.Input, "0.content.0.detail").String())
})
}
}
func TestChatCompletionsRequestToResponsesRequestPreservesPromptCacheKey(t *testing.T) {
t.Run("present", func(t *testing.T) {
key := "session-\"quoted\"\\path\n世界"
@@ -8,6 +8,7 @@ import (
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
@@ -24,9 +25,14 @@ const (
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
responsesEventReasoningSummaryPartAdded = "response.reasoning_summary_part.added"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesEventReasoningSummaryPartDone = "response.reasoning_summary_part.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeCustomToolCall = "custom_tool_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
@@ -34,6 +40,12 @@ const (
)
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
return ChatCompletionsResponseToResponsesResponseWithTools(resp, id, nil)
}
// ChatCompletionsResponseToResponsesResponseWithTools also restores function
// calls that tools marks as Responses custom tools into custom_tool_call items.
func ChatCompletionsResponseToResponsesResponseWithTools(resp *dto.OpenAITextResponse, id string, tools *convmeta.ResponsesToolState) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
@@ -93,7 +105,10 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
}
for i, toolCall := range choice.Message.ParseToolCalls() {
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
if (toolCall.Type == "" || toolCall.Type == "function") && strings.TrimSpace(toolCall.Function.Name) == "" {
continue
}
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out), tools)
if err != nil {
return nil, nil, err
}
@@ -202,18 +217,31 @@ func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
return strings.TrimSpace(status)
}
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string, tools *convmeta.ResponsesToolState) (dto.ResponsesOutput, error) {
callID := strings.TrimSpace(toolCall.ID)
if callID == "" {
callID = fmt.Sprintf("%s_call_%d", responseID, index)
}
if toolCall.Type == "" || toolCall.Type == "function" {
namespace, name := tools.ResponsesToolName(toolCall.Function.Name)
if tools.IsCustomTool(toolCall.Function.Name) {
return dto.ResponsesOutput{
Type: responsesOutputTypeCustomToolCall,
ID: callID,
Status: status,
CallId: callID,
Name: name,
Namespace: namespace,
Input: chatArgumentsRawMessage(customToolInputFromArguments(toolCall.Function.Arguments)),
}, nil
}
return dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: callID,
Status: status,
CallId: callID,
Name: toolCall.Function.Name,
Name: name,
Namespace: namespace,
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
}, nil
}
@@ -226,6 +254,28 @@ func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID stri
}, nil
}
// customToolInputFromArguments unwraps the raw custom tool input from the
// {"input": ...} arguments a Chat function call carries. Arguments of any other
// shape are returned unchanged so the model's output is never dropped.
func customToolInputFromArguments(arguments string) string {
var value map[string]any
if err := kitutil.UnmarshalJsonStr(arguments, &value); err != nil {
return arguments
}
input, ok := value[convmeta.CustomToolInputArgument]
if !ok {
return arguments
}
if text, ok := input.(string); ok {
return text
}
raw, err := kitutil.Marshal(input)
if err != nil {
return arguments
}
return string(raw)
}
func chatArgumentsRawMessage(arguments string) []byte {
raw, err := kitutil.Marshal(arguments)
if err != nil {
@@ -4,6 +4,8 @@ import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -41,6 +43,54 @@ func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *tes
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
}
func TestChatCompletionsResponseToResponsesDropsEmptyNameToolCalls(t *testing.T) {
usage := dto.Usage{PromptTokens: 20, CompletionTokens: 5, TotalTokens: 25}
usage.PromptTokensDetails.CachedTokens = 7
usage.BillingUsage = dto.NewOpenAIChatBillingUsage(&usage)
message := dto.Message{Role: "assistant"}
message.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_invalid",
Type: "function",
Function: dto.FunctionRequest{
Arguments: `{"ok":true}`,
},
},
{
ID: "call_whitespace",
Function: dto.FunctionRequest{Name: " \t\n", Arguments: `{}`},
},
{
ID: "call_valid",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
})
resp, convertedUsage, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Usage: usage,
Choices: []dto.OpenAITextResponseChoice{{
Message: message,
FinishReason: "tool_calls",
}},
}, "resp_1")
require.NoError(t, err)
require.Len(t, resp.Output, 1)
assert.Equal(t, "call_valid", resp.Output[0].CallId)
assert.Equal(t, "lookup", resp.Output[0].Name)
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[0].Arguments))
assert.Equal(t, 20, convertedUsage.InputTokens)
assert.Equal(t, 5, convertedUsage.OutputTokens)
assert.Equal(t, 25, convertedUsage.TotalTokens)
require.NotNil(t, convertedUsage.InputTokensDetails)
assert.Equal(t, 7, convertedUsage.InputTokensDetails.CachedTokens)
assert.Equal(t, usage.BillingUsage, convertedUsage.BillingUsage)
}
func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = lo.ToPtr("thinking summary")
@@ -153,6 +203,366 @@ func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *tes
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
}
func TestChatCompletionsStreamToResponsesEmitsReasoningSummaryPartLifecycle(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
stop := "stop"
var events []ChatToResponsesStreamEvent
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1", Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("think")},
}},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("answer")},
FinishReason: &stop,
}},
})...)
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
types := make([]string, 0, len(events))
for _, event := range events {
types = append(types, event.Type)
}
assert.Equal(t, []string{
responsesEventCreated,
responsesEventOutputItemAdded,
responsesEventReasoningSummaryPartAdded,
responsesEventReasoningSummaryDelta,
responsesEventOutputItemAdded,
responsesEventOutputTextDelta,
"response.output_text.done",
responsesEventOutputItemDone,
responsesEventReasoningSummaryDone,
responsesEventReasoningSummaryPartDone,
responsesEventOutputItemDone,
responsesEventCompleted,
}, types)
// The Responses schema requires summary on every reasoning item, including
// the one announced before any summary text exists.
itemAdded, err := kitutil.Marshal(events[1].Payload)
require.NoError(t, err)
assert.Contains(t, string(itemAdded), `"summary":[]`)
partAdded := events[2].Payload
assert.Equal(t, "resp_1_reasoning_0", partAdded.ItemID)
assert.Equal(t, 0, lo.FromPtr(partAdded.OutputIndex))
assert.Equal(t, 0, lo.FromPtr(partAdded.SummaryIndex))
require.NotNil(t, partAdded.Part)
assert.Equal(t, dto.ResponsesReasoningSummaryPart{Type: "summary_text"}, *partAdded.Part)
partDone := events[9].Payload
assert.Equal(t, "resp_1_reasoning_0", partDone.ItemID)
require.NotNil(t, partDone.Part)
assert.Equal(t, dto.ResponsesReasoningSummaryPart{Type: "summary_text", Text: "think"}, *partDone.Part)
}
func TestChatCompletionsStreamToResponsesReopensItemsAfterMidStreamFinishReason(t *testing.T) {
for _, emitSequenceNumber := range []bool{false, true} {
t.Run(map[bool]string{false: "legacy", true: "sequence_numbers"}[emitSequenceNumber], func(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.EmitSequenceNumber = emitSequenceNumber
toolIndex := 0
toolCalls := "tool_calls"
stop := "stop"
chunks := []*dto.ChatCompletionsStreamResponse{
{Id: "chatcmpl_1", Model: "gpt-test", Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("round 1")},
}}},
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("calling")},
}}},
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{{
Index: &toolIndex, ID: "call_1", Type: "function",
Function: dto.FunctionResponse{Name: "lookup", Arguments: "{}"},
}}},
}}},
{Choices: []dto.ChatCompletionsStreamResponseChoice{{FinishReason: &toolCalls}}},
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ReasoningContent: lo.ToPtr("round 2")},
}}},
{Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("final")},
FinishReason: &stop,
}}},
}
var events []ChatToResponsesStreamEvent
for _, chunk := range chunks {
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk)...)
}
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
open := map[string]bool{}
partAdded, partDone := 0, 0
for _, event := range events {
itemID := event.Payload.ItemID
if event.Payload.Item != nil {
itemID = event.Payload.Item.ID
}
switch event.Type {
case responsesEventOutputItemAdded:
assert.Falsef(t, open[itemID], "item %q added twice", itemID)
open[itemID] = true
case responsesEventOutputItemDone:
assert.Truef(t, open[itemID], "item %q done without being open", itemID)
delete(open, itemID)
case responsesEventReasoningSummaryPartAdded:
partAdded++
assert.Truef(t, open[itemID], "part added for closed item %q", itemID)
case responsesEventReasoningSummaryPartDone:
partDone++
assert.Truef(t, open[itemID], "part done for closed item %q", itemID)
case responsesEventReasoningSummaryDelta, responsesEventOutputTextDelta:
assert.Truef(t, open[itemID], "%s for %q arrived without an active item", event.Type, itemID)
}
}
assert.Empty(t, open)
assert.Equal(t, 2, partAdded)
assert.Equal(t, 2, partDone)
completed := events[len(events)-1]
require.Equal(t, responsesEventCompleted, completed.Type)
require.NotNil(t, completed.Payload.Response)
output := completed.Payload.Response.Output
require.Len(t, output, 5)
assert.Equal(t, []string{
responsesOutputTypeReasoning, responsesOutputTypeMessage, responsesOutputTypeFunctionCall,
responsesOutputTypeReasoning, responsesOutputTypeMessage,
}, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type, output[4].Type})
assert.Equal(t, "resp_1_reasoning_0", output[0].ID)
assert.Equal(t, "round 1", output[0].Summary[0].Text)
assert.Equal(t, "resp_1_msg_0", output[1].ID)
assert.Equal(t, "calling", output[1].Content[0].Text)
assert.Equal(t, "call_1", output[2].CallId)
assert.Equal(t, "resp_1_reasoning_1", output[3].ID)
assert.Equal(t, "round 2", output[3].Summary[0].Text)
assert.Equal(t, "resp_1_msg_1", output[4].ID)
assert.Equal(t, "final", output[4].Content[0].Text)
for _, item := range output {
assert.Equal(t, "completed", item.Status)
}
})
}
}
func execCustomToolState() *convmeta.ResponsesToolState {
return &convmeta.ResponsesToolState{CustomToolNames: map[string]struct{}{"exec": {}}}
}
func TestChatCompletionsResponseToResponsesRestoresCustomToolCalls(t *testing.T) {
message := dto.Message{Role: "assistant"}
message.SetToolCalls([]dto.ToolCallRequest{
{ID: "call_exec", Type: "function", Function: dto.FunctionRequest{Name: "exec", Arguments: `{"input":"echo \"hi\" && ls"}`}},
{ID: "call_wait", Type: "function", Function: dto.FunctionRequest{Name: "wait", Arguments: `{"ms":1}`}},
})
chat := &dto.OpenAITextResponse{
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{{Message: message, FinishReason: "tool_calls"}},
}
resp, _, err := ChatCompletionsResponseToResponsesResponseWithTools(chat, "resp_1", execCustomToolState())
require.NoError(t, err)
require.Len(t, resp.Output, 2)
custom, err := kitutil.Marshal(resp.Output[0])
require.NoError(t, err)
assert.JSONEq(t, `{"type":"custom_tool_call","id":"call_exec","status":"completed","call_id":"call_exec","name":"exec","input":"echo \"hi\" && ls"}`, string(custom))
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
assert.Equal(t, `"{\"ms\":1}"`, string(resp.Output[1].Arguments))
plain, _, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
require.NoError(t, err)
assert.Equal(t, responsesOutputTypeFunctionCall, plain.Output[0].Type)
}
func TestCustomToolInputFromArguments(t *testing.T) {
tests := map[string]string{
`{"input":"ls -la"}`: "ls -la",
`{"input":""}`: "",
`{"input":{"cmd":"ls"}}`: `{"cmd":"ls"}`,
`{"cmd":"ls"}`: `{"cmd":"ls"}`,
`ls -la`: "ls -la",
``: "",
`{"input":"a","extra":true}`: "a",
`{"input":"\u003cdiv\u003e"}`: "<div>",
}
for arguments, want := range tests {
assert.Equalf(t, want, customToolInputFromArguments(arguments), "arguments %q", arguments)
}
}
func TestChatCompletionsStreamToResponsesRestoresCustomToolCalls(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.EmitSequenceNumber = true
state.Tools = execCustomToolState()
execIndex, waitIndex := 0, 1
var events []ChatToResponsesStreamEvent
for _, delta := range []dto.ToolCallResponse{
{Index: &execIndex, ID: "call_exec", Type: "function", Function: dto.FunctionResponse{Name: "exec"}},
{Index: &execIndex, Function: dto.FunctionResponse{Arguments: `{"input":"echo `}},
{Index: &execIndex, Function: dto.FunctionResponse{Arguments: `\"hi\""}`}},
{Index: &waitIndex, ID: "call_wait", Type: "function", Function: dto.FunctionResponse{Name: "wait", Arguments: `{"ms":1}`}},
} {
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{delta}}},
},
})...)
}
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
types := make([]string, 0, len(events))
for _, event := range events {
types = append(types, event.Type)
}
assert.Equal(t, []string{
responsesEventCreated,
responsesEventOutputItemAdded,
responsesEventOutputItemAdded,
responsesEventFunctionArgsDelta,
responsesEventCustomToolInputDelta,
responsesEventCustomToolInputDone,
responsesEventOutputItemDone,
responsesEventFunctionArgsDone,
responsesEventOutputItemDone,
responsesEventCompleted,
}, types)
added, err := kitutil.Marshal(events[1].Payload.Item)
require.NoError(t, err)
assert.JSONEq(t, `{"type":"custom_tool_call","id":"call_exec","status":"in_progress","call_id":"call_exec","name":"exec","input":""}`, string(added))
assert.Equal(t, `echo "hi"`, events[4].Payload.Delta)
assert.Equal(t, "call_exec", events[4].Payload.ItemID)
require.NotNil(t, events[5].Payload.Input)
assert.Equal(t, `echo "hi"`, *events[5].Payload.Input)
assert.Equal(t, responsesOutputTypeCustomToolCall, events[6].Payload.Item.Type)
assert.Equal(t, `"echo \"hi\""`, string(events[6].Payload.Item.Input))
output := events[len(events)-1].Payload.Response.Output
require.Len(t, output, 2)
assert.Equal(t, responsesOutputTypeCustomToolCall, output[0].Type)
assert.Equal(t, `"echo \"hi\""`, string(output[0].Input))
assert.Equal(t, responsesOutputTypeFunctionCall, output[1].Type)
assert.Equal(t, `"{\"ms\":1}"`, string(output[1].Arguments))
}
func TestChatCompletionsStreamToResponsesHoldsNamelessToolUntilNameArrives(t *testing.T) {
toolIndex := 0
chunk := func(call dto.ToolCallResponse) *dto.ChatCompletionsStreamResponse {
return &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{call}}},
},
}
}
t.Run("custom tool", func(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.Tools = execCustomToolState()
first := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_exec", Function: dto.FunctionResponse{Arguments: `{"input":`}}))
for _, event := range first {
assert.NotEqual(t, responsesEventOutputItemAdded, event.Type)
assert.NotEqual(t, responsesEventFunctionArgsDelta, event.Type)
}
second := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Function: dto.FunctionResponse{Name: "exec", Arguments: `"ls"}`}}))
require.Len(t, second, 1)
assert.Equal(t, responsesEventOutputItemAdded, second[0].Type)
assert.Equal(t, responsesOutputTypeCustomToolCall, second[0].Payload.Item.Type)
done := FinalizeChatCompletionsStreamToResponses(state)
require.NotEmpty(t, done)
output := done[len(done)-1].Payload.Response.Output
require.Len(t, output, 1)
assert.Equal(t, `"ls"`, string(output[0].Input))
})
t.Run("function tool flushes held arguments", func(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
first := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Type: "function", Function: dto.FunctionResponse{Arguments: `{"ms":`}}))
require.Len(t, first, 1)
assert.Equal(t, responsesEventCreated, first[0].Type)
second := mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_wait", Function: dto.FunctionResponse{Name: "wait", Arguments: `1}`}}))
require.Len(t, second, 2)
assert.Equal(t, responsesEventOutputItemAdded, second[0].Type)
assert.Equal(t, responsesOutputTypeFunctionCall, second[0].Payload.Item.Type)
assert.Equal(t, "call_wait", second[0].Payload.Item.ID)
assert.Equal(t, "call_wait", second[0].Payload.Item.CallId)
assert.Equal(t, responsesEventFunctionArgsDelta, second[1].Type)
assert.Equal(t, "call_wait", second[1].Payload.ItemID)
assert.Equal(t, `{"ms":1}`, second[1].Payload.Delta)
done := FinalizeChatCompletionsStreamToResponses(state)
output := done[len(done)-1].Payload.Response.Output
require.Len(t, output, 1)
assert.Equal(t, "call_wait", output[0].ID)
assert.Equal(t, `"{\"ms\":1}"`, string(output[0].Arguments))
})
t.Run("held tool takes the next output index when announced", func(t *testing.T) {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.Tools = execCustomToolState()
waitIndex := 1
var events []ChatToResponsesStreamEvent
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, ID: "call_exec"}))...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &waitIndex, ID: "call_wait", Function: dto.FunctionResponse{Name: "wait", Arguments: `{}`}}))...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("done")}}},
})...)
events = append(events, mustResponsesEventsFromChatChunk(t, state, chunk(dto.ToolCallResponse{Index: &toolIndex, Function: dto.FunctionResponse{Name: "exec", Arguments: `{"input":"ls"}`}}))...)
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
var addedIndexes []int
var addedTypes []string
for _, event := range events {
if event.Type == responsesEventOutputItemAdded {
addedIndexes = append(addedIndexes, lo.FromPtr(event.Payload.OutputIndex))
addedTypes = append(addedTypes, event.Payload.Item.Type)
}
}
assert.Equal(t, []int{0, 1, 2}, addedIndexes)
assert.Equal(t, []string{responsesOutputTypeFunctionCall, responsesOutputTypeMessage, responsesOutputTypeCustomToolCall}, addedTypes)
output := events[len(events)-1].Payload.Response.Output
require.Len(t, output, 3)
assert.Equal(t, addedTypes, []string{output[0].Type, output[1].Type, output[2].Type})
})
t.Run("name never arrives", func(t *testing.T) {
for _, tools := range []*convmeta.ResponsesToolState{nil, execCustomToolState()} {
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
state.EmitSequenceNumber = true
state.Tools = tools
missingIndex, validIndex, blankIndex := 0, 1, 2
var events []ChatToResponsesStreamEvent
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
Choices: []dto.ChatCompletionsStreamResponseChoice{{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
{Index: &missingIndex, ID: "call_missing", Type: "function", Function: dto.FunctionResponse{Arguments: `{"ok":true}`}},
{Index: &validIndex, ID: "call_valid", Type: "function", Function: dto.FunctionResponse{Name: "lookup", Arguments: `{"q":"x"}`}},
{Index: &blankIndex, ID: "call_blank", Function: dto.FunctionResponse{Name: " \t\n", Arguments: `{}`}},
}}}},
})...)
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
for i, event := range events {
require.NotNil(t, event.Payload.SequenceNumber)
assert.Equal(t, i, *event.Payload.SequenceNumber)
if event.Payload.OutputIndex != nil {
assert.Equal(t, 0, *event.Payload.OutputIndex)
}
if event.Payload.Item != nil {
assert.Equal(t, "call_valid", event.Payload.Item.CallId)
}
}
output := events[len(events)-1].Payload.Response.Output
require.Len(t, output, 1)
assert.Equal(t, "call_valid", output[0].CallId)
assert.Equal(t, "lookup", output[0].Name)
}
})
}
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
t.Helper()
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
@@ -8,6 +8,7 @@ import (
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
@@ -25,6 +26,9 @@ type ChatToResponsesStreamState struct {
// EmitSequenceNumber enables the required sequence_number field for current
// Responses API SSE consumers while preserving the legacy relaykit default.
EmitSequenceNumber bool
// Tools marks the Chat function calls that encode Responses custom tools;
// they are streamed back as custom_tool_call items.
Tools *convmeta.ResponsesToolState
status string
incompleteDetails *dto.IncompleteDetails
@@ -44,6 +48,17 @@ type ChatToResponsesStreamState struct {
text strings.Builder
annotations []any
reasoning strings.Builder
// usageText collects every text, reasoning, and tool argument delta sent
// to the client. It is the missing-usage estimate and tells whether any
// output reached the client.
usageText strings.Builder
// A mid-stream finish_reason closes the message and reasoning items. Deltas that
// arrive afterwards open a fresh item (next segment ordinal) instead of touching the
// closed one; the closed items are snapshotted so the final response keeps them.
textSegment int
reasoningSegment int
closedMessages []dto.ResponsesOutput
closedReasonings []dto.ResponsesOutput
}
type chatToResponsesStreamTool struct {
@@ -53,11 +68,16 @@ type chatToResponsesStreamTool struct {
CallID string
Name string
Arguments strings.Builder
Done bool
// Announced records that output_item.added was sent. Custom reports that
// the call restores a Responses custom tool; it is fixed at announcement.
Announced bool
Custom bool
Done bool
}
type chatToResponsesOutputRef struct {
Kind string
Segment int
ToolIndex int
HostedID string
}
@@ -386,12 +406,13 @@ func (s *ChatToResponsesStreamState) UsageText() string {
if s == nil {
return ""
}
return s.text.String()
return s.usageText.String()
}
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
events := s.startText()
s.text.WriteString(delta)
s.usageText.WriteString(delta)
events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextDelta,
OutputIndex: intPtr(s.textOutputIndex),
@@ -403,9 +424,16 @@ func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToRespo
}
func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
if s.textDone {
s.textSegment++
s.textStarted = false
s.textDone = false
s.text.Reset()
s.annotations = nil
}
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
s.textOutputIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "message", Segment: s.textSegment})
return []ChatToResponsesStreamEvent{s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.textOutputIndex),
@@ -447,10 +475,16 @@ func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatTo
}
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0, 2)
events := make([]ChatToResponsesStreamEvent, 0, 3)
if s.reasoningDone {
s.reasoningSegment++
s.reasoningStarted = false
s.reasoningDone = false
s.reasoning.Reset()
}
if !s.reasoningStarted {
s.reasoningStarted = true
s.reasoningIndex = s.nextIndex("reasoning", -1)
s.reasoningIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "reasoning", Segment: s.reasoningSegment})
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.reasoningIndex),
@@ -461,8 +495,16 @@ func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatTo
Summary: []dto.ResponsesReasoningSummaryPart{},
},
}))
events = append(events, s.event(responsesEventReasoningSummaryPartAdded, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryPartAdded,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
ItemID: s.reasoningID(),
Part: &dto.ResponsesReasoningSummaryPart{Type: "summary_text"},
}))
}
s.reasoning.WriteString(delta)
s.usageText.WriteString(delta)
events = append(events, s.event(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDelta,
OutputIndex: intPtr(s.reasoningIndex),
@@ -483,29 +525,15 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
events := make([]ChatToResponsesStreamEvent, 0, 2)
if tool == nil {
tool = &chatToResponsesStreamTool{
ChatIndex: chatIndex,
OutputIndex: s.nextIndex("tool", chatIndex),
CallID: incomingID,
Name: strings.TrimSpace(toolCall.Function.Name),
ChatIndex: chatIndex,
CallID: incomingID,
Name: strings.TrimSpace(toolCall.Function.Name),
}
tool.ItemID = incomingID
if tool.ItemID == "" {
tool.ItemID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
}
s.toolsByIndex[chatIndex] = tool
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: "in_progress",
CallId: tool.callID(),
Name: tool.Name,
Arguments: []byte(`""`),
},
}))
}
if tool.Done {
return nil, fmt.Errorf("tool-call stream index %d received data after completion", chatIndex)
@@ -515,6 +543,9 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
}
tool.CallID = incomingID
if !tool.Announced {
tool.ItemID = incomingID
}
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
@@ -523,18 +554,59 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
}
tool.Name = incomingName
}
if toolCall.Function.Arguments != "" {
tool.Arguments.WriteString(toolCall.Function.Arguments)
delta := toolCall.Function.Arguments
tool.Arguments.WriteString(delta)
// A nameless call is invalid Responses output, so hold it back until a
// valid name arrives. Announcement also determines whether it is custom.
if !tool.Announced && tool.Name != "" {
events = append(events, s.announceTool(tool))
delta = tool.Arguments.String()
}
if tool.Announced {
// Announced calls reach the client; custom tool input at completion.
s.usageText.WriteString(delta)
}
// Custom tool input is unwrapped from the complete arguments at the end.
if tool.Announced && !tool.Custom && delta != "" {
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Delta: toolCall.Function.Arguments,
Delta: delta,
}))
}
return events, nil
}
// announceTool also allocates the output index, so a held tool never ends up
// behind items that were added to the stream before it.
func (s *ChatToResponsesStreamState) announceTool(tool *chatToResponsesStreamTool) ChatToResponsesStreamEvent {
tool.Announced = true
tool.OutputIndex = s.nextIndex(chatToResponsesOutputRef{Kind: "tool", ToolIndex: tool.ChatIndex})
tool.Custom = s.Tools.IsCustomTool(tool.Name)
namespace, name := s.Tools.ResponsesToolName(tool.Name)
item := &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: "in_progress",
CallId: tool.callID(),
Name: name,
Namespace: namespace,
Arguments: []byte(`""`),
}
if tool.Custom {
item.Type = responsesOutputTypeCustomToolCall
item.Arguments = nil
item.Input = []byte(`""`)
}
return s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Item: item,
})
}
func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0)
status := s.outputStatus()
@@ -550,11 +622,13 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
textDone.Text = kitutil.GetPointer(s.text.String())
}
events = append(events, s.event("response.output_text.done", textDone))
closedMessage := s.messageOutput(status)
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.textOutputIndex),
Item: s.messageOutput(status),
Item: closedMessage,
}))
s.closedMessages = append(s.closedMessages, *closedMessage)
}
if s.reasoningStarted && !s.reasoningDone {
s.reasoningDone = true
@@ -573,27 +647,71 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
reasoningDone.Part = nil
}
events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
events = append(events, s.event(responsesEventReasoningSummaryPartDone, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryPartDone,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
ItemID: s.reasoningID(),
Part: &dto.ResponsesReasoningSummaryPart{
Type: "summary_text",
Text: s.reasoning.String(),
},
}))
closedReasoning := s.reasoningOutput(status)
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.reasoningIndex),
Item: s.reasoningOutput(status),
Item: closedReasoning,
}))
s.closedReasonings = append(s.closedReasonings, *closedReasoning)
}
for _, tool := range s.sortedTools() {
if tool.Done {
continue
}
tool.Done = true
argumentsDone := dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
if !tool.Announced {
if tool.Name == "" {
continue
}
events = append(events, s.announceTool(tool))
if !tool.Custom && tool.Arguments.Len() > 0 {
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Delta: tool.Arguments.String(),
}))
}
}
if s.EmitSequenceNumber {
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
argumentsDone.Name = tool.Name
if tool.Custom {
input := customToolInputFromArguments(tool.Arguments.String())
if input != "" {
events = append(events, s.event(responsesEventCustomToolInputDelta, dto.ResponsesStreamResponse{
Type: responsesEventCustomToolInputDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Delta: input,
}))
}
events = append(events, s.event(responsesEventCustomToolInputDone, dto.ResponsesStreamResponse{
Type: responsesEventCustomToolInputDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
Input: kitutil.GetPointer(input),
}))
} else {
argumentsDone := dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ItemID,
}
if s.EmitSequenceNumber {
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
_, argumentsDone.Name = s.Tools.ResponsesToolName(tool.Name)
}
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
}
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(tool.OutputIndex),
@@ -649,8 +767,20 @@ func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesRespons
for _, ref := range s.outputOrder {
switch ref.Kind {
case "message":
if ref.Segment < len(s.closedMessages) {
closed := s.closedMessages[ref.Segment]
closed.Status = status
output = append(output, closed)
continue
}
output = append(output, *s.messageOutput(status))
case "reasoning":
if ref.Segment < len(s.closedReasonings) {
closed := s.closedReasonings[ref.Segment]
closed.Status = status
output = append(output, closed)
continue
}
output = append(output, *s.reasoningOutput(status))
case "tool":
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
@@ -685,10 +815,10 @@ func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesRespo
}
}
func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
func (s *ChatToResponsesStreamState) nextIndex(ref chatToResponsesOutputRef) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex})
s.outputOrder = append(s.outputOrder, ref)
return index
}
@@ -720,11 +850,11 @@ func (s *ChatToResponsesStreamState) outputStatus() string {
}
func (s *ChatToResponsesStreamState) messageID() string {
return fmt.Sprintf("%s_msg_0", s.ID)
return fmt.Sprintf("%s_msg_%d", s.ID, s.textSegment)
}
func (s *ChatToResponsesStreamState) reasoningID() string {
return fmt.Sprintf("%s_reasoning_0", s.ID)
return fmt.Sprintf("%s_reasoning_%d", s.ID, s.reasoningSegment)
}
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
@@ -762,12 +892,25 @@ func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.Respons
}
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
namespace, name := s.Tools.ResponsesToolName(tool.Name)
if tool.Custom {
return &dto.ResponsesOutput{
Type: responsesOutputTypeCustomToolCall,
ID: tool.ItemID,
Status: status,
CallId: tool.callID(),
Name: name,
Namespace: namespace,
Input: chatArgumentsRawMessage(customToolInputFromArguments(tool.Arguments.String())),
}
}
return &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
Status: status,
CallId: tool.callID(),
Name: tool.Name,
Name: name,
Namespace: namespace,
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
}
}