feat: publish sanitized custom.97 source
This commit is contained in:
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()),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user