From e7ac90ec711ca48886813b28c45ef0ff8d81fb8f Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Mon, 27 Jul 2026 22:50:09 +0800 Subject: [PATCH] fix(openai): align Chat Completions and Responses conversions --- core/relay/adaptor/openai/chat.go | 430 +++++++++++++++-------- core/relay/adaptor/openai/chat_test.go | 348 +++++++++++++++++- core/relay/adaptor/openai/claude_test.go | 41 +++ core/relay/model/completions.go | 164 +++++---- core/relay/model/response.go | 114 +++--- core/relay/model/tool.go | 11 +- 6 files changed, 839 insertions(+), 269 deletions(-) diff --git a/core/relay/adaptor/openai/chat.go b/core/relay/adaptor/openai/chat.go index b64c7f97..745b19a1 100644 --- a/core/relay/adaptor/openai/chat.go +++ b/core/relay/adaptor/openai/chat.go @@ -25,6 +25,7 @@ import ( type chatCompletionStreamState struct { messageID string created int64 + serviceTier *string meta *meta.Meta c *gin.Context toolCallIndexByItemID map[string]int @@ -192,15 +193,21 @@ func (s *chatCompletionStreamState) handleResponseCreated( } s.messageID = event.Response.ID + if event.Response.ServiceTier != nil { + s.serviceTier = event.Response.ServiceTier + } + if event.Response.CreatedAt != 0 { s.created = event.Response.CreatedAt } return &relaymodel.ChatCompletionsStreamResponse{ - ID: s.messageID, - Object: relaymodel.ChatCompletionChunkObject, - Created: s.createdAt(), - Model: responseModelName(s.meta), + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: s.createdAt(), + Model: responseModelName(s.meta), + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ { Index: 0, @@ -220,20 +227,53 @@ func (s *chatCompletionStreamState) handleOutputTextDelta( return nil } + choice := &relaymodel.ChatCompletionsStreamResponseChoice{ + Index: 0, + Delta: relaymodel.Message{ + Content: event.Delta, + }, + } + if len(event.Logprobs) > 0 { + choice.Logprobs = &relaymodel.ChoiceLogprobs{Content: event.Logprobs} + } + return &relaymodel.ChatCompletionsStreamResponse{ - ID: s.messageID, - Object: relaymodel.ChatCompletionChunkObject, - Created: s.createdAt(), - Model: responseModelName(s.meta), - Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ - { - Index: 0, - Delta: relaymodel.Message{ - Content: event.Delta, - }, - }, + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: s.createdAt(), + Model: responseModelName(s.meta), + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, + Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{choice}, + } +} + +func (s *chatCompletionStreamState) handleRefusalDelta( + event *relaymodel.ResponseStreamEvent, +) *relaymodel.ChatCompletionsStreamResponse { + if event.Delta == "" { + return nil + } + + choice := &relaymodel.ChatCompletionsStreamResponseChoice{ + Index: 0, + Delta: relaymodel.Message{ + Refusal: event.Delta, }, } + if len(event.Logprobs) > 0 { + choice.Logprobs = &relaymodel.ChoiceLogprobs{Refusal: event.Logprobs} + } + + return &relaymodel.ChatCompletionsStreamResponse{ + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: s.createdAt(), + Model: responseModelName(s.meta), + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, + Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{choice}, + } } func (s *chatCompletionStreamState) handleReasoningSummaryTextDelta( @@ -244,10 +284,12 @@ func (s *chatCompletionStreamState) handleReasoningSummaryTextDelta( } return &relaymodel.ChatCompletionsStreamResponse{ - ID: s.messageID, - Object: relaymodel.ChatCompletionChunkObject, - Created: s.createdAt(), - Model: responseModelName(s.meta), + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: s.createdAt(), + Model: responseModelName(s.meta), + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ { Index: 0, @@ -274,10 +316,12 @@ func (s *chatCompletionStreamState) handleOutputItemAdded( // Send tool call start return &relaymodel.ChatCompletionsStreamResponse{ - ID: s.messageID, - Object: relaymodel.ChatCompletionChunkObject, - Created: s.createdAt(), - Model: responseModelName(s.meta), + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: s.createdAt(), + Model: responseModelName(s.meta), + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ { Index: 0, @@ -301,10 +345,12 @@ func (s *chatCompletionStreamState) handleOutputItemAdded( if event.Item.Type == relaymodel.InputItemTypeMessage { return &relaymodel.ChatCompletionsStreamResponse{ - ID: s.messageID, - Object: relaymodel.ChatCompletionChunkObject, - Created: s.createdAt(), - Model: responseModelName(s.meta), + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: s.createdAt(), + Model: responseModelName(s.meta), + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ { Index: 0, @@ -334,10 +380,12 @@ func (s *chatCompletionStreamState) handleFunctionCallArgumentsDelta( // Send delta return &relaymodel.ChatCompletionsStreamResponse{ - ID: s.messageID, - Object: relaymodel.ChatCompletionChunkObject, - Created: s.createdAt(), - Model: responseModelName(s.meta), + ID: s.messageID, + Object: relaymodel.ChatCompletionChunkObject, + Created: s.createdAt(), + Model: responseModelName(s.meta), + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ { Index: 0, @@ -364,6 +412,10 @@ func (s *chatCompletionStreamState) handleResponseCompleted( return nil } + if event.Response.ServiceTier != nil { + s.serviceTier = event.Response.ServiceTier + } + chatUsage := event.Response.Usage.ToChatUsage() finishReason := responseToChatFinishReason(event.Response) @@ -382,7 +434,10 @@ func (s *chatCompletionStreamState) handleResponseCompleted( FinishReason: finishReason, }, }, - Usage: &chatUsage, + Usage: &chatUsage, + Moderation: event.Response.Moderation, + ServiceTier: s.serviceTier, + Obfuscation: event.Obfuscation, } } @@ -932,11 +987,34 @@ func ConvertToolsToResponseTools(tools []relaymodel.Tool) []relaymodel.ResponseT responseTools := make([]relaymodel.ResponseTool, 0, len(tools)) for _, tool := range tools { + name := tool.Name + if name == "" { + name = tool.Function.Name + } + + description := tool.Description + if description == "" { + description = tool.Function.Description + } + + parameters := tool.Parameters + if parameters == nil { + parameters = tool.Function.Parameters + } + + strict := tool.Strict + if strict == nil { + strict = tool.Function.Strict + } + responseTool := relaymodel.ResponseTool{ - Type: tool.Type, - Name: tool.Function.Name, - Description: tool.Function.Description, - Parameters: CleanToolParameters(tool.Function.Parameters), + Type: tool.Type, + Name: name, + Execution: tool.Execution, + Description: description, + Parameters: CleanToolParameters(parameters), + Strict: strict, + DeferLoading: tool.DeferLoading, } responseTools = append(responseTools, responseTool) } @@ -1066,8 +1144,9 @@ func appendChatContentPartToResponseInput( } inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ - Type: contentType, - Text: text, + Type: contentType, + Text: text, + PromptCacheBreakpoint: part["prompt_cache_breakpoint"], }) case relaymodel.ContentTypeImageURL: imageURL, _ := part["image_url"].(map[string]any) @@ -1079,13 +1158,58 @@ func appendChatContentPartToResponseInput( detail, _ := imageURL["detail"].(string) inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ - Type: "input_image", - ImageURL: url, - Detail: detail, + Type: "input_image", + ImageURL: url, + Detail: detail, + PromptCacheBreakpoint: part["prompt_cache_breakpoint"], }) } } +func appendMessageContentToResponseInput( + inputItem *relaymodel.InputItem, + contentType relaymodel.InputContentType, + content any, +) { + switch content := content.(type) { + case string: + if content != "" { + inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ + Type: contentType, + Text: content, + }) + } + case []relaymodel.MessageContent: + for _, part := range content { + switch part.Type { + case relaymodel.ContentTypeText: + if part.Text != "" { + inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ + Type: contentType, + Text: part.Text, + PromptCacheBreakpoint: part.PromptCacheBreakpoint, + }) + } + case relaymodel.ContentTypeImageURL: + if part.ImageURL != nil && part.ImageURL.URL != "" { + inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ + Type: "input_image", + ImageURL: part.ImageURL.URL, + Detail: part.ImageURL.Detail, + PromptCacheBreakpoint: part.PromptCacheBreakpoint, + }) + } + } + } + case []any: + for _, part := range content { + if partMap, ok := part.(map[string]any); ok { + appendChatContentPartToResponseInput(inputItem, contentType, partMap) + } + } + } +} + // ConvertMessagesToInputItems converts Message array to InputItem array for Responses API func ConvertMessagesToInputItems(messages []relaymodel.Message) []relaymodel.InputItem { inputItems := make([]relaymodel.InputItem, 0, len(messages)) @@ -1126,25 +1250,29 @@ func ConvertMessagesToInputItems(messages []relaymodel.Message) []relaymodel.Inp Arguments: toolCall.Function.Arguments, }) } - // If there's also text content in the message, add it as a separate message item - var textContent string - if content, ok := msg.Content.(string); ok { - textContent = content + + messageItem := relaymodel.InputItem{ + Type: relaymodel.InputItemTypeMessage, + Role: msg.Role, + Content: make([]relaymodel.InputContent, 0), } + appendMessageContentToResponseInput( + &messageItem, + relaymodel.InputContentTypeOutputText, + msg.Content, + ) - if textContent != "" { - inputItems = append(inputItems, relaymodel.InputItem{ - Type: relaymodel.InputItemTypeMessage, - Role: msg.Role, - Content: []relaymodel.InputContent{ - { - Type: relaymodel.InputContentTypeOutputText, - Text: textContent, - }, - }, + if msg.Refusal != "" { + messageItem.Content = append(messageItem.Content, relaymodel.InputContent{ + Type: "refusal", + Refusal: msg.Refusal, }) } + if len(messageItem.Content) > 0 { + inputItems = append(inputItems, messageItem) + } + continue } @@ -1171,33 +1299,13 @@ func ConvertMessagesToInputItems(messages []relaymodel.Message) []relaymodel.Inp contentType = relaymodel.InputContentTypeOutputText } - // Handle regular text content - switch content := msg.Content.(type) { - case string: - // Simple string content - if content != "" { - inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ - Type: contentType, - Text: content, - }) - } - case []relaymodel.MessageContent: - // Array of MessageContent (from Claude conversion) - for _, part := range content { - if part.Type == relaymodel.ContentTypeText && part.Text != "" { - inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ - Type: contentType, - Text: part.Text, - }) - } - } - case []any: - // Array of content parts (multimodal) - for _, part := range content { - if partMap, ok := part.(map[string]any); ok { - appendChatContentPartToResponseInput(&inputItem, contentType, partMap) - } - } + appendMessageContentToResponseInput(&inputItem, contentType, msg.Content) + + if role == relaymodel.RoleAssistant && msg.Refusal != "" { + inputItem.Content = append(inputItem.Content, relaymodel.InputContent{ + Type: "refusal", + Refusal: msg.Refusal, + }) } // Only append the message if it has content @@ -1222,6 +1330,13 @@ func ConvertChatCompletionToResponsesRequest( return adaptor.ConvertResult{}, err } + if chatReq.N > 1 { + return adaptor.ConvertResult{}, convertRequestError( + meta, + "n must be 1 when converting Chat Completions requests to the Responses API", + ) + } + // Create Responses API request responsesReq := relaymodel.CreateResponseRequest{ Model: meta.ActualModel, @@ -1242,6 +1357,16 @@ func ConvertChatCompletionToResponsesRequest( responsesReq.Text = convertChatResponseFormatToResponseText(chatReq.ResponseFormat) } + if chatReq.Verbosity != "" { + if responsesReq.Text == nil { + responsesReq.Text = &relaymodel.ResponseText{ + Format: relaymodel.ResponseTextFormat{Type: "text"}, + } + } + + responsesReq.Text.Verbosity = chatReq.Verbosity + } + if chatReq.TopLogprobs != nil { responsesReq.TopLogprobs = chatReq.TopLogprobs } @@ -1288,6 +1413,8 @@ func ConvertChatCompletionToResponsesRequest( responsesReq.PromptCacheKey = &chatReq.PromptCacheKey } + responsesReq.PromptCacheOptions = chatReq.PromptCacheOptions + // Map prompt cache retention if chatReq.PromptCacheRetention != "" { responsesReq.PromptCacheRetention = &chatReq.PromptCacheRetention @@ -1298,6 +1425,20 @@ func ConvertChatCompletionToResponsesRequest( responsesReq.User = &chatReq.User } + if chatReq.SafetyIdentifier != "" { + responsesReq.SafetyIdentifier = &chatReq.SafetyIdentifier + } + + responsesReq.Moderation = chatReq.Moderation + + if chatReq.StreamOptions != nil { + if chatReq.StreamOptions.IncludeObfuscation != nil { + responsesReq.StreamOptions = &relaymodel.ResponseStreamOptions{ + IncludeObfuscation: chatReq.StreamOptions.IncludeObfuscation, + } + } + } + reasoning := utils.ParseOpenAIReasoning(&chatReq) applyReasoningToResponsesRequestForModel(meta, &responsesReq, reasoning) @@ -1361,79 +1502,77 @@ func ConvertResponsesToChatCompletionResponse( // Convert to ChatCompletion format chatResp := relaymodel.TextResponse{ - ID: responsesResp.ID, - Object: relaymodel.ChatCompletionObject, - Created: responsesResp.CreatedAt, - Model: responseModelName(meta), - Choices: []*relaymodel.TextResponseChoice{}, - Usage: relaymodel.ChatUsage{}, + ID: responsesResp.ID, + Object: relaymodel.ChatCompletionObject, + Created: responsesResp.CreatedAt, + Model: responseModelName(meta), + Choices: []*relaymodel.TextResponseChoice{}, + Usage: relaymodel.ChatUsage{}, + Moderation: responsesResp.Moderation, + ServiceTier: responsesResp.ServiceTier, } reasonContent := responseReasoningSummaryText(&responsesResp) + choice := &relaymodel.TextResponseChoice{ + Index: 0, + Message: relaymodel.Message{ + Role: relaymodel.RoleAssistant, + Content: "", + ReasoningContent: reasonContent, + }, + FinishReason: responseToChatFinishReason(&responsesResp), + } - var toolCallChoice *relaymodel.TextResponseChoice + var ( + contentParts []string + refusalParts []string + contentLogprobs []relaymodel.ChatCompletionTokenLogprob + refusalLogprobs []relaymodel.ChatCompletionTokenLogprob + ) - // Convert output items to choices + // Responses output items belong to one generation and therefore one Chat choice. for _, outputItem := range responsesResp.Output { switch outputItem.Type { case "", relaymodel.InputItemTypeMessage: - role := outputItem.Role - if role == "" { - role = relaymodel.RoleAssistant - } - - choice := relaymodel.TextResponseChoice{ - Index: len(chatResp.Choices), - Message: relaymodel.Message{ - Role: role, - Content: "", - ReasoningContent: reasonContent, - }, + if outputItem.Role != "" { + choice.Message.Role = outputItem.Role } - var contentParts []string for _, content := range outputItem.Content { - if (content.Type == "text" || content.Type == "output_text") && content.Text != "" { - contentParts = append(contentParts, content.Text) + switch content.Type { + case "text", relaymodel.OutputContentTypeOutputText: + if content.Text != "" { + contentParts = append(contentParts, content.Text) + } + + choice.Message.Annotations = append( + choice.Message.Annotations, + content.Annotations..., + ) + contentLogprobs = append(contentLogprobs, content.Logprobs...) + case "refusal": + if content.Refusal != "" { + refusalParts = append(refusalParts, content.Refusal) + } + + refusalLogprobs = append(refusalLogprobs, content.Logprobs...) } } - if len(contentParts) > 0 { - choice.Message.Content = strings.Join(contentParts, "\n") - } - - choice.FinishReason = responseToChatFinishReason(&responsesResp) - chatResp.Choices = append(chatResp.Choices, &choice) - reasonContent = "" - case relaymodel.InputItemTypeFunctionCall: toolCallID := outputItem.CallID if toolCallID == "" { toolCallID = outputItem.ID } - if toolCallChoice == nil { - finishReason := responseToChatFinishReason(&responsesResp) - if finishReason == relaymodel.FinishReasonStop { - finishReason = relaymodel.FinishReasonToolCalls - } - - toolCallChoice = &relaymodel.TextResponseChoice{ - Index: len(chatResp.Choices), - Message: relaymodel.Message{ - Role: relaymodel.RoleAssistant, - ReasoningContent: reasonContent, - }, - FinishReason: finishReason, - } - chatResp.Choices = append(chatResp.Choices, toolCallChoice) - reasonContent = "" + if choice.FinishReason == relaymodel.FinishReasonStop { + choice.FinishReason = relaymodel.FinishReasonToolCalls } - toolCallChoice.Message.ToolCalls = append( - toolCallChoice.Message.ToolCalls, + choice.Message.ToolCalls = append( + choice.Message.ToolCalls, relaymodel.ToolCall{ - Index: len(toolCallChoice.Message.ToolCalls), + Index: len(choice.Message.ToolCalls), ID: toolCallID, Type: relaymodel.ToolChoiceTypeFunction, Function: relaymodel.Function{ @@ -1448,18 +1587,23 @@ func ConvertResponsesToChatCompletionResponse( } } - if len(chatResp.Choices) == 0 { - chatResp.Choices = append(chatResp.Choices, &relaymodel.TextResponseChoice{ - Index: 0, - Message: relaymodel.Message{ - Role: relaymodel.RoleAssistant, - Content: "", - ReasoningContent: reasonContent, - }, - FinishReason: responseToChatFinishReason(&responsesResp), - }) + if len(contentParts) > 0 { + choice.Message.Content = strings.Join(contentParts, "\n") + } + + if len(refusalParts) > 0 { + choice.Message.Refusal = strings.Join(refusalParts, "\n") } + if len(contentLogprobs) > 0 || len(refusalLogprobs) > 0 { + choice.Logprobs = &relaymodel.ChoiceLogprobs{ + Content: contentLogprobs, + Refusal: refusalLogprobs, + } + } + + chatResp.Choices = append(chatResp.Choices, choice) + // Convert usage if responsesResp.Usage != nil { chatResp.Usage = responsesResp.Usage.ToChatUsage() @@ -1668,6 +1812,8 @@ func ConvertResponsesToChatCompletionStreamResponse( pendingInitialChunk = state.handleResponseCreated(&event) case relaymodel.EventOutputTextDelta: chatStreamResp = state.handleOutputTextDelta(&event) + case relaymodel.EventRefusalDelta: + chatStreamResp = state.handleRefusalDelta(&event) case relaymodel.EventReasoningSummaryTextDelta: chatStreamResp = state.handleReasoningSummaryTextDelta(&event) case relaymodel.EventOutputItemAdded: diff --git a/core/relay/adaptor/openai/chat_test.go b/core/relay/adaptor/openai/chat_test.go index c9feeb8f..2368e6ee 100644 --- a/core/relay/adaptor/openai/chat_test.go +++ b/core/relay/adaptor/openai/chat_test.go @@ -372,6 +372,7 @@ func TestConvertChatCompletionToResponsesRequest(t *testing.T) { Function: relaymodel.Function{ Name: "get_weather", Description: "Get weather information", + Strict: new(true), Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -389,9 +390,49 @@ func TestConvertChatCompletionToResponsesRequest(t *testing.T) { t.Helper() require.Len(t, responsesReq.Tools, 1) assert.Equal(t, "get_weather", responsesReq.Tools[0].Name) + require.NotNil(t, responsesReq.Tools[0].Strict) + assert.True(t, *responsesReq.Tools[0].Strict) assert.Equal(t, "auto", responsesReq.ToolChoice) }, }, + { + name: "flattened tool fields take precedence", + inputRequest: relaymodel.GeneralOpenAIRequest{ + Model: "gpt-5.6", + Messages: []relaymodel.Message{ + {Role: "user", Content: "Find the required tool"}, + }, + Tools: []relaymodel.Tool{ + { + Type: "function", + Name: "top_level_tool", + Description: "Top-level description", + Parameters: map[string]any{"type": "object"}, + Strict: new(true), + DeferLoading: new(true), + Function: relaymodel.Function{ + Name: "nested_tool", + Description: "Nested description", + Parameters: map[string]any{"type": "string"}, + Strict: new(false), + }, + }, + }, + }, + checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) { + t.Helper() + require.Len(t, responsesReq.Tools, 1) + + tool := responsesReq.Tools[0] + assert.Equal(t, "top_level_tool", tool.Name) + assert.Equal(t, "Top-level description", tool.Description) + assert.Equal(t, map[string]any{"type": "object"}, tool.Parameters) + require.NotNil(t, tool.Strict) + assert.True(t, *tool.Strict) + require.NotNil(t, tool.DeferLoading) + assert.True(t, *tool.DeferLoading) + }, + }, { name: "request with named tool choice", inputRequest: relaymodel.GeneralOpenAIRequest{ @@ -649,7 +690,55 @@ func TestConvertChatCompletionToResponsesRequest(t *testing.T) { } } -func TestConvertChatCompletionToResponsesRequestAcceptsMultipleChoices(t *testing.T) { +func TestConvertChatCompletionToResponsesRequestPreservesClientToolSearch(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{ + "model":"gpt-5.6", + "messages":[{"role":"user","content":"Find the required tool"}], + "tools":[{ + "type":"tool_search", + "execution":"client", + "description":"Search deferred tool metadata", + "parameters":{ + "type":"object", + "properties":{"query":{"type":"string"}}, + "required":["query"], + "additionalProperties":false + } + }] + }`), + ) + req.Header.Set("Content-Type", "application/json") + + result, err := openai.ConvertChatCompletionToResponsesRequest(&meta.Meta{ + ActualModel: "gpt-5.6", + }, req) + require.NoError(t, err) + + var responsesReq relaymodel.CreateResponseRequest + require.NoError(t, json.NewDecoder(result.Body).Decode(&responsesReq)) + require.Len(t, responsesReq.Tools, 1) + + tool := responsesReq.Tools[0] + assert.Equal(t, "tool_search", tool.Type) + assert.Equal(t, "client", tool.Execution) + assert.Equal(t, "Search deferred tool metadata", tool.Description) + assert.Equal(t, map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + "required": []any{"query"}, + "additionalProperties": false, + }, tool.Parameters) +} + +func TestConvertChatCompletionToResponsesRequestRejectsMultipleChoices(t *testing.T) { inputRequest := relaymodel.GeneralOpenAIRequest{ Model: "gpt-5-codex", Messages: []relaymodel.Message{ @@ -671,27 +760,89 @@ func TestConvertChatCompletionToResponsesRequestAcceptsMultipleChoices(t *testin ActualModel: inputRequest.Model, } - result, err := openai.ConvertChatCompletionToResponsesRequest(m, req) - require.NoError(t, err) + _, err = openai.ConvertChatCompletionToResponsesRequest(m, req) + require.Error(t, err) + assert.Contains(t, err.Error(), "n must be 1") +} - body, err := io.ReadAll(result.Body) - require.NoError(t, err) +func TestConvertChatCompletionToResponsesRequestPreservesDirectFields(t *testing.T) { + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{ + "model":"gpt-5.6", + "messages":[ + {"role":"assistant","content":"Prior answer","refusal":"Policy refusal"}, + {"role":"user","content":[{"type":"text","text":"Hello","prompt_cache_breakpoint":{"mode":"explicit"}}]} + ], + "safety_identifier":"user_hash", + "prompt_cache_options":{"mode":"explicit","ttl":"30m"}, + "moderation":{"model":"omni-moderation-latest","policy":{"input":{"mode":"score"}}}, + "verbosity":"high", + "stream":true, + "stream_options":{"include_usage":true,"include_obfuscation":false} + }`), + ) + req.Header.Set("Content-Type", "application/json") - assert.NotContains(t, string(body), `"n"`) + result, err := openai.ConvertChatCompletionToResponsesRequest(&meta.Meta{ + ActualModel: "gpt-5.6", + }, req) + require.NoError(t, err) var responsesReq relaymodel.CreateResponseRequest + require.NoError(t, json.NewDecoder(result.Body).Decode(&responsesReq)) + + require.NotNil(t, responsesReq.SafetyIdentifier) + assert.Equal(t, "user_hash", *responsesReq.SafetyIdentifier) + require.NotNil(t, responsesReq.PromptCacheOptions) + assert.Equal(t, "explicit", responsesReq.PromptCacheOptions.Mode) + assert.Equal(t, "30m", responsesReq.PromptCacheOptions.TTL) + assert.Equal(t, map[string]any{ + "model": "omni-moderation-latest", + "policy": map[string]any{ + "input": map[string]any{"mode": "score"}, + }, + }, responsesReq.Moderation) + require.NotNil(t, responsesReq.Text) + assert.Equal(t, "text", responsesReq.Text.Format.Type) + assert.Equal(t, "high", responsesReq.Text.Verbosity) + require.NotNil(t, responsesReq.StreamOptions) + require.NotNil(t, responsesReq.StreamOptions.IncludeObfuscation) + assert.False(t, *responsesReq.StreamOptions.IncludeObfuscation) + require.NotNil(t, responsesReq.Store) + assert.False(t, *responsesReq.Store) + + items, ok := responsesReq.Input.([]any) + require.True(t, ok) + require.Len(t, items, 2) + assistantItem, ok := items[0].(map[string]any) + require.True(t, ok) + assistantContent, ok := assistantItem["content"].([]any) + require.True(t, ok) + require.Len(t, assistantContent, 2) + refusalContent, ok := assistantContent[1].(map[string]any) + require.True(t, ok) + assert.Equal(t, "refusal", refusalContent["type"]) + assert.Equal(t, "Policy refusal", refusalContent["refusal"]) - err = json.Unmarshal(body, &responsesReq) - require.NoError(t, err) - - assert.Equal(t, inputRequest.Model, responsesReq.Model) - assert.Equal(t, false, *responsesReq.Store) + userItem, ok := items[1].(map[string]any) + require.True(t, ok) + userContent, ok := userItem["content"].([]any) + require.True(t, ok) + require.Len(t, userContent, 1) + userTextContent, ok := userContent[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, map[string]any{"mode": "explicit"}, + userTextContent["prompt_cache_breakpoint"]) } func TestConvertChatCompletionToResponsesRequestFlattensJSONSchemaTextFormat(t *testing.T) { strict := true inputRequest := relaymodel.GeneralOpenAIRequest{ - Model: "gpt-5-codex", + Model: "gpt-5-codex", + Verbosity: "low", Messages: []relaymodel.Message{ {Role: "user", Content: "Return JSON"}, }, @@ -746,6 +897,7 @@ func TestConvertChatCompletionToResponsesRequestFlattensJSONSchemaTextFormat(t * assert.Equal(t, "Answer payload", format["description"]) assert.Equal(t, true, format["strict"]) assert.NotNil(t, format["schema"]) + assert.Equal(t, "low", text["verbosity"]) } func TestConvertResponsesToChatCompletionResponse(t *testing.T) { @@ -895,13 +1047,117 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { }, checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { t.Helper() - require.Len(t, chatResp.Choices, 2) + require.Len(t, chatResp.Choices, 1) assert.Equal( t, "Need compare options.", chatResp.Choices[0].Message.ReasoningContent, ) - assert.Empty(t, chatResp.Choices[1].Message.ReasoningContent) + assert.Equal(t, "Option A\nOption B", chatResp.Choices[0].Message.Content) + }, + expectedStatus: http.StatusOK, + }, + { + name: "response fields and output items are preserved in one choice", + responsesResp: relaymodel.Response{ + ID: "resp_fields", + Model: "gpt-5.6", + Status: relaymodel.ResponseStatusCompleted, + CreatedAt: 1781355958, + ServiceTier: new("priority"), + Moderation: map[string]any{ + "input": map[string]any{"type": "moderation_result", "flagged": false}, + }, + Output: []relaymodel.OutputItem{ + { + Type: relaymodel.InputItemTypeMessage, + Content: []relaymodel.OutputContent{ + { + Type: relaymodel.OutputContentTypeOutputText, + Text: "Partial answer", + Annotations: []any{map[string]any{"type": "url_citation"}}, + Logprobs: []relaymodel.ChatCompletionTokenLogprob{ + {Token: "Partial", Bytes: []int{80}, Logprob: -0.1}, + }, + }, + { + Type: "refusal", + Refusal: "Restricted detail", + Logprobs: []relaymodel.ChatCompletionTokenLogprob{ + {Token: "Restricted", Bytes: []int{82}, Logprob: -0.2}, + }, + }, + }, + }, + { + ID: "fc_123", + Type: relaymodel.InputItemTypeFunctionCall, + CallID: "call_123", + Name: "lookup", + Arguments: `{"id":1}`, + }, + }, + Usage: &relaymodel.ResponseUsage{InputTokens: 4, OutputTokens: 2, TotalTokens: 6}, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + choice := chatResp.Choices[0] + assert.Equal(t, "Partial answer", choice.Message.Content) + assert.Equal(t, "Restricted detail", choice.Message.Refusal) + require.Len(t, choice.Message.Annotations, 1) + require.NotNil(t, choice.Logprobs) + require.Len(t, choice.Logprobs.Content, 1) + assert.Equal(t, "Partial", choice.Logprobs.Content[0].Token) + require.Len(t, choice.Logprobs.Refusal, 1) + assert.Equal(t, "Restricted", choice.Logprobs.Refusal[0].Token) + require.Len(t, choice.Message.ToolCalls, 1) + assert.Equal(t, relaymodel.FinishReasonToolCalls, choice.FinishReason) + require.NotNil(t, chatResp.ServiceTier) + assert.Equal(t, "priority", *chatResp.ServiceTier) + moderation, ok := chatResp.Moderation.(map[string]any) + require.True(t, ok) + moderationInput, ok := moderation["input"].(map[string]any) + require.True(t, ok) + flagged, ok := moderationInput["flagged"].(bool) + require.True(t, ok) + assert.False(t, flagged) + }, + expectedStatus: http.StatusOK, + }, + { + name: "refusal-only response preserves refusal logprobs", + responsesResp: relaymodel.Response{ + ID: "resp_refusal_logprobs", + Model: "gpt-5.6", + Status: relaymodel.ResponseStatusCompleted, + CreatedAt: 1781355958, + Output: []relaymodel.OutputItem{ + { + Type: relaymodel.InputItemTypeMessage, + Content: []relaymodel.OutputContent{ + { + Type: "refusal", + Refusal: "Request refused", + Logprobs: []relaymodel.ChatCompletionTokenLogprob{ + {Token: "Request", Bytes: []int{82}, Logprob: -0.3}, + }, + }, + }, + }, + }, + Usage: &relaymodel.ResponseUsage{InputTokens: 2, OutputTokens: 1, TotalTokens: 3}, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + choice := chatResp.Choices[0] + assert.Equal(t, "", choice.Message.Content) + assert.Equal(t, "Request refused", choice.Message.Refusal) + require.NotNil(t, choice.Logprobs) + assert.Empty(t, choice.Logprobs.Content) + require.Len(t, choice.Logprobs.Refusal, 1) + assert.Equal(t, "Request", choice.Logprobs.Refusal[0].Token) }, expectedStatus: http.StatusOK, }, @@ -1202,6 +1458,70 @@ func TestConvertResponsesToChatCompletionStreamResponseSkipsOutputItemDoneConten assert.Equal(t, 1, strings.Count(w.Body.String(), "data: [DONE]")) } +func TestConvertResponsesToChatCompletionStreamResponsePreservesResponseFields(t *testing.T) { + gin.SetMode(gin.TestMode) + + stream := strings.Join([]string{ + `data: {"type":"response.created","obfuscation":"created-pad","response":{"id":"resp_fields","object":"response","created_at":1780731105,"status":"in_progress","model":"gpt-5.6","service_tier":"priority","output":[]}}`, + "", + `data: {"type":"response.output_text.delta","delta":"Hello","obfuscation":"text-pad","logprobs":[{"token":"Hello","bytes":[72],"logprob":-0.1,"top_logprobs":[]}]}`, + "", + `data: {"type":"response.refusal.delta","delta":"Restricted","obfuscation":"refusal-pad","logprobs":[{"token":"Restricted","bytes":[82],"logprob":-0.2,"top_logprobs":[]}]}`, + "", + `data: {"type":"response.completed","obfuscation":"completed-pad","response":{"id":"resp_fields","object":"response","created_at":1780731105,"status":"completed","model":"gpt-5.6","service_tier":"priority","moderation":{"input":{"type":"moderation_result","flagged":false}},"output":[],"usage":{"input_tokens":2,"output_tokens":2,"total_tokens":4}}}`, + "", + }, "\n") + + httpResp := &http.Response{ + StatusCode: http.StatusOK, + Body: &mockReadCloser{Reader: bytes.NewReader([]byte(stream))}, + Header: make(http.Header), + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + + _, err := openai.ConvertResponsesToChatCompletionStreamResponse(&meta.Meta{ + ActualModel: "gpt-5.6", + }, c, httpResp) + require.Nil(t, err) + + chunks := collectChatCompletionStreamChunks(t, w.Body.String()) + require.Len(t, chunks, 4) + + for _, chunk := range chunks { + require.NotNil(t, chunk.ServiceTier) + assert.Equal(t, "priority", *chunk.ServiceTier) + } + + assert.Equal(t, "created-pad", chunks[0].Obfuscation) + assert.Equal(t, "text-pad", chunks[1].Obfuscation) + assert.Equal(t, "refusal-pad", chunks[2].Obfuscation) + assert.Equal(t, "completed-pad", chunks[3].Obfuscation) + + require.NotNil(t, chunks[1].Choices[0].Logprobs) + require.Len(t, chunks[1].Choices[0].Logprobs.Content, 1) + assert.Equal(t, "Hello", chunks[1].Choices[0].Logprobs.Content[0].Token) + assert.Equal(t, "Restricted", chunks[2].Choices[0].Delta.Refusal) + require.NotNil(t, chunks[2].Choices[0].Logprobs) + require.Len(t, chunks[2].Choices[0].Logprobs.Refusal, 1) + assert.Equal(t, "Restricted", chunks[2].Choices[0].Logprobs.Refusal[0].Token) + require.NotNil(t, chunks[3].Usage) + assert.Equal(t, int64(4), chunks[3].Usage.TotalTokens) + moderation, ok := chunks[3].Moderation.(map[string]any) + require.True(t, ok) + moderationInput, ok := moderation["input"].(map[string]any) + require.True(t, ok) + flagged, ok := moderationInput["flagged"].(bool) + require.True(t, ok) + assert.False(t, flagged) +} + func TestConvertResponsesToChatCompletionStreamResponseReturnsErrorBeforeDownstreamWrite( t *testing.T, ) { diff --git a/core/relay/adaptor/openai/claude_test.go b/core/relay/adaptor/openai/claude_test.go index fa83d5f6..3da5785b 100644 --- a/core/relay/adaptor/openai/claude_test.go +++ b/core/relay/adaptor/openai/claude_test.go @@ -171,6 +171,47 @@ func TestConvertClaudeToResponsesRequest(t *testing.T) { } } +func TestConvertClaudeToResponsesRequestPreservesImages(t *testing.T) { + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/messages", + strings.NewReader(`{ + "model":"gpt-5.6", + "max_tokens":128, + "messages":[{ + "role":"user", + "content":[ + {"type":"text","text":"Describe this image"}, + {"type":"image","source":{"type":"url","url":"https://example.com/image.png"}} + ] + }] + }`), + ) + req.Header.Set("Content-Type", "application/json") + + result, err := openai.ConvertClaudeToResponsesRequest(&meta.Meta{ + ActualModel: "gpt-5.6", + }, req) + require.NoError(t, err) + + var responsesReq relaymodel.CreateResponseRequest + require.NoError(t, json.NewDecoder(result.Body).Decode(&responsesReq)) + + items, ok := responsesReq.Input.([]any) + require.True(t, ok) + require.Len(t, items, 1) + item, ok := items[0].(map[string]any) + require.True(t, ok) + content, ok := item["content"].([]any) + require.True(t, ok) + require.Len(t, content, 2) + image, ok := content[1].(map[string]any) + require.True(t, ok) + assert.Equal(t, "input_image", image["type"]) + assert.Equal(t, "https://example.com/image.png", image["image_url"]) +} + func TestConvertClaudeRequest_ReasoningEffortCompatibility(t *testing.T) { t.Parallel() diff --git a/core/relay/model/completions.go b/core/relay/model/completions.go index 67bcf114..ca2e0fdd 100644 --- a/core/relay/model/completions.go +++ b/core/relay/model/completions.go @@ -29,47 +29,57 @@ type OutputAudio struct { } type StreamOptions struct { - IncludeUsage bool `json:"include_usage,omitempty"` + IncludeUsage bool `json:"include_usage,omitempty"` + IncludeObfuscation *bool `json:"include_obfuscation,omitempty"` +} + +type PromptCacheOptions struct { + Mode string `json:"mode,omitempty"` + TTL string `json:"ttl,omitempty"` } type GeneralOpenAIRequest struct { - Prompt any `json:"prompt,omitempty"` - Input any `json:"input,omitempty"` - Metadata any `json:"metadata,omitempty"` - Functions any `json:"functions,omitempty"` - LogitBias any `json:"logit_bias,omitempty"` - FunctionCall any `json:"function_call,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - Stop any `json:"stop,omitempty"` - TopLogprobs *int `json:"top_logprobs,omitempty"` - PresencePenalty *float64 `json:"presence_penalty,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - Audio *Audio `json:"audio,omitempty"` - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` - Logprobs *bool `json:"logprobs,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - PromptCacheKey string `json:"prompt_cache_key,omitempty"` - PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` - Model string `json:"model,omitempty"` - User string `json:"user,omitempty"` - Size string `json:"size,omitempty"` - Messages []Message `json:"messages,omitempty"` - Tools []Tool `json:"tools,omitempty"` - Modalities []string `json:"modalities,omitempty"` - Seed float64 `json:"seed,omitempty"` - N int `json:"n,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` - TopK int `json:"top_k,omitempty"` - NumCtx int `json:"num_ctx,omitempty"` - Stream bool `json:"stream,omitempty"` - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` - ReasoningEffort *string `json:"reasoning_effort,omitempty"` - EnableThinking *bool `json:"enable_thinking,omitempty"` - ThinkingBudget *int `json:"thinking_budget,omitempty"` + Prompt any `json:"prompt,omitempty"` + Input any `json:"input,omitempty"` + Metadata any `json:"metadata,omitempty"` + Functions any `json:"functions,omitempty"` + LogitBias any `json:"logit_bias,omitempty"` + FunctionCall any `json:"function_call,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Stop any `json:"stop,omitempty"` + TopLogprobs *int `json:"top_logprobs,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + Audio *Audio `json:"audio,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + Logprobs *bool `json:"logprobs,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheOptions *PromptCacheOptions `json:"prompt_cache_options,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + Moderation any `json:"moderation,omitempty"` + Verbosity string `json:"verbosity,omitempty"` + Model string `json:"model,omitempty"` + User string `json:"user,omitempty"` + Size string `json:"size,omitempty"` + Messages []Message `json:"messages,omitempty"` + Tools []Tool `json:"tools,omitempty"` + Modalities []string `json:"modalities,omitempty"` + Seed float64 `json:"seed,omitempty"` + N int `json:"n,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + TopK int `json:"top_k,omitempty"` + NumCtx int `json:"num_ctx,omitempty"` + Stream bool `json:"stream,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` + EnableThinking *bool `json:"enable_thinking,omitempty"` + ThinkingBudget *int `json:"thinking_budget,omitempty"` // aiproxy control field Thinking *GeneralThinking `json:"thinking,omitempty"` } @@ -102,41 +112,68 @@ type GeneralOpenAIThinkingRequest struct { } type ChatCompletionsStreamResponseChoice struct { - FinishReason FinishReason `json:"finish_reason,omitempty"` - Delta Message `json:"delta"` - Index int `json:"index"` - Text string `json:"text,omitempty"` + FinishReason FinishReason `json:"finish_reason,omitempty"` + Delta Message `json:"delta"` + Logprobs *ChoiceLogprobs `json:"logprobs,omitempty"` + Index int `json:"index"` + Text string `json:"text,omitempty"` } type ChatCompletionsStreamResponse struct { - Usage *ChatUsage `json:"usage,omitempty"` - ID string `json:"id"` - Object string `json:"object"` - Model string `json:"model"` - Choices []*ChatCompletionsStreamResponseChoice `json:"choices"` - Created int64 `json:"created"` + Usage *ChatUsage `json:"usage,omitempty"` + Moderation any `json:"moderation,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` + Obfuscation string `json:"obfuscation,omitempty"` + ID string `json:"id"` + Object string `json:"object"` + Model string `json:"model"` + Choices []*ChatCompletionsStreamResponseChoice `json:"choices"` + Created int64 `json:"created"` +} + +type TopLogprob struct { + Token string `json:"token"` + Bytes []int `json:"bytes"` + Logprob float64 `json:"logprob"` +} + +type ChatCompletionTokenLogprob struct { + Token string `json:"token"` + Bytes []int `json:"bytes"` + Logprob float64 `json:"logprob"` + TopLogprobs []TopLogprob `json:"top_logprobs"` +} + +type ChoiceLogprobs struct { + Content []ChatCompletionTokenLogprob `json:"content,omitempty"` + Refusal []ChatCompletionTokenLogprob `json:"refusal,omitempty"` } type TextResponseChoice struct { - FinishReason FinishReason `json:"finish_reason"` - Message Message `json:"message"` - Index int `json:"index"` - Text string `json:"text,omitempty"` + FinishReason FinishReason `json:"finish_reason"` + Message Message `json:"message"` + Logprobs *ChoiceLogprobs `json:"logprobs,omitempty"` + Index int `json:"index"` + Text string `json:"text,omitempty"` } type TextResponse struct { - ID string `json:"id"` - Model string `json:"model,omitempty"` - Object string `json:"object"` - Choices []*TextResponseChoice `json:"choices"` - Usage ChatUsage `json:"usage"` - Created int64 `json:"created"` + ID string `json:"id"` + Model string `json:"model,omitempty"` + Object string `json:"object"` + Choices []*TextResponseChoice `json:"choices"` + Usage ChatUsage `json:"usage"` + Moderation any `json:"moderation,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` + Created int64 `json:"created"` } type Message struct { Content any `json:"content,omitempty"` Audio *OutputAudio `json:"audio,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + Refusal string `json:"refusal,omitempty"` + Annotations []any `json:"annotations,omitempty"` Signature string `json:"signature,omitempty"` Name *string `json:"name,omitempty"` Role string `json:"role,omitempty"` @@ -326,9 +363,10 @@ type VideoURL struct { } type MessageContent struct { - ImageURL *ImageURL `json:"image_url,omitempty"` - InputAudio *InputAudio `json:"input_audio,omitempty"` - VideoURL *VideoURL `json:"video_url,omitempty"` - Type string `json:"type,omitempty"` - Text string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` + InputAudio *InputAudio `json:"input_audio,omitempty"` + VideoURL *VideoURL `json:"video_url,omitempty"` + PromptCacheBreakpoint any `json:"prompt_cache_breakpoint,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` } diff --git a/core/relay/model/response.go b/core/relay/model/response.go index 65d10f04..6f1391f1 100644 --- a/core/relay/model/response.go +++ b/core/relay/model/response.go @@ -165,10 +165,13 @@ type ResponseError struct { // ResponseTool represents a tool in the Responses API format (flattened structure) type ResponseTool struct { - Type string `json:"type"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Parameters any `json:"parameters,omitempty"` + Type string `json:"type"` + Name string `json:"name,omitempty"` + Execution string `json:"execution,omitempty"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` + Strict *bool `json:"strict,omitempty"` + DeferLoading *bool `json:"defer_loading,omitempty"` } // IncompleteDetails represents details about why a response is incomplete @@ -199,14 +202,21 @@ type ResponseTextFormat struct { // ResponseText represents text configuration type ResponseText struct { - Format ResponseTextFormat `json:"format"` + Format ResponseTextFormat `json:"format"` + Verbosity string `json:"verbosity,omitempty"` } // OutputContent represents content in an output item type OutputContent struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Annotations []any `json:"annotations,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Refusal string `json:"refusal,omitempty"` + Annotations []any `json:"annotations,omitempty"` + Logprobs []ChatCompletionTokenLogprob `json:"logprobs,omitempty"` +} + +type ResponseStreamOptions struct { + IncludeObfuscation *bool `json:"include_obfuscation,omitempty"` } // OutputItem represents an output item in a response @@ -224,8 +234,10 @@ type OutputItem struct { // InputContent represents content in an input item type InputContent struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Refusal string `json:"refusal,omitempty"` + PromptCacheBreakpoint any `json:"prompt_cache_breakpoint,omitempty"` // Fields for input_image type ImageURL string `json:"image_url,omitempty"` FileID string `json:"file_id,omitempty"` @@ -323,36 +335,40 @@ type Response struct { ServiceTier *string `json:"service_tier,omitempty"` User *string `json:"user"` Metadata map[string]any `json:"metadata"` + Moderation any `json:"moderation,omitempty"` } // CreateResponseRequest represents a request to create a response type CreateResponseRequest struct { - Model string `json:"model"` - Input any `json:"input"` - Background *bool `json:"background,omitempty"` - Conversation any `json:"conversation,omitempty"` // string or object - Include []string `json:"include,omitempty"` - Instructions *string `json:"instructions,omitempty"` - MaxOutputTokens *int `json:"max_output_tokens,omitempty"` - MaxToolCalls *int `json:"max_tool_calls,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` - PreviousResponseID *string `json:"previous_response_id,omitempty"` - PromptCacheKey *string `json:"prompt_cache_key,omitempty"` - PromptCacheRetention *string `json:"prompt_cache_retention,omitempty"` - Reasoning *ResponseReasoning `json:"reasoning,omitempty"` - SafetyIdentifier *string `json:"safety_identifier,omitempty"` - ServiceTier *string `json:"service_tier,omitempty"` - Store *bool `json:"store,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - Text *ResponseText `json:"text,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - Tools []ResponseTool `json:"tools,omitempty"` - TopLogprobs *int `json:"top_logprobs,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Truncation *string `json:"truncation,omitempty"` - User *string `json:"user,omitempty"` // Deprecated, use prompt_cache_key + Model string `json:"model"` + Input any `json:"input"` + Background *bool `json:"background,omitempty"` + Conversation any `json:"conversation,omitempty"` // string or object + Include []string `json:"include,omitempty"` + Instructions *string `json:"instructions,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + MaxToolCalls *int `json:"max_tool_calls,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + PreviousResponseID *string `json:"previous_response_id,omitempty"` + PromptCacheKey *string `json:"prompt_cache_key,omitempty"` + PromptCacheOptions *PromptCacheOptions `json:"prompt_cache_options,omitempty"` + PromptCacheRetention *string `json:"prompt_cache_retention,omitempty"` + Reasoning *ResponseReasoning `json:"reasoning,omitempty"` + SafetyIdentifier *string `json:"safety_identifier,omitempty"` + Moderation any `json:"moderation,omitempty"` + ServiceTier *string `json:"service_tier,omitempty"` + Store *bool `json:"store,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *ResponseStreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Text *ResponseText `json:"text,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Tools []ResponseTool `json:"tools,omitempty"` + TopLogprobs *int `json:"top_logprobs,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Truncation *string `json:"truncation,omitempty"` + User *string `json:"user,omitempty"` // Deprecated, use prompt_cache_key } // InputItemList represents a list of input items @@ -366,18 +382,20 @@ type InputItemList struct { // ResponseStreamEvent represents a server-sent event for response streaming type ResponseStreamEvent struct { - Type string `json:"type"` - Response *Response `json:"response,omitempty"` - Error *OpenAIError `json:"error,omitempty"` - OutputIndex *int `json:"output_index,omitempty"` - Item *OutputItem `json:"item,omitempty"` - ItemID string `json:"item_id,omitempty"` - ContentIndex *int `json:"content_index,omitempty"` - Part *OutputContent `json:"part,omitempty"` // For content_part events - Delta string `json:"delta,omitempty"` // For text.delta, function_call_arguments.delta - Text string `json:"text,omitempty"` // For text content - Arguments ResponseArguments `json:"arguments,omitempty"` // For function_call_arguments.done - SequenceNumber int `json:"sequence_number,omitempty"` + Type string `json:"type"` + Response *Response `json:"response,omitempty"` + Error *OpenAIError `json:"error,omitempty"` + OutputIndex *int `json:"output_index,omitempty"` + Item *OutputItem `json:"item,omitempty"` + ItemID string `json:"item_id,omitempty"` + ContentIndex *int `json:"content_index,omitempty"` + Part *OutputContent `json:"part,omitempty"` // For content_part events + Delta string `json:"delta,omitempty"` // For text.delta, function_call_arguments.delta + Logprobs []ChatCompletionTokenLogprob `json:"logprobs,omitempty"` + Obfuscation string `json:"obfuscation,omitempty"` + Text string `json:"text,omitempty"` // For text content + Arguments ResponseArguments `json:"arguments,omitempty"` // For function_call_arguments.done + SequenceNumber int `json:"sequence_number,omitempty"` } func (r *Response) ToolUsageWebSearchCallCount() int64 { diff --git a/core/relay/model/tool.go b/core/relay/model/tool.go index d1c21650..5d358c3a 100644 --- a/core/relay/model/tool.go +++ b/core/relay/model/tool.go @@ -1,8 +1,14 @@ package model type Tool struct { - Type string `json:"type"` - Function Function `json:"function"` + Type string `json:"type"` + Function Function `json:"function"` + Name string `json:"name,omitempty"` + Execution string `json:"execution,omitempty"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` + Strict *bool `json:"strict,omitempty"` + DeferLoading *bool `json:"defer_loading,omitempty"` } type Function struct { @@ -10,6 +16,7 @@ type Function struct { Arguments string `json:"arguments,omitempty"` Description string `json:"description,omitempty"` Name string `json:"name,omitempty"` + Strict *bool `json:"strict,omitempty"` } type GoogleExtraContent struct {