From 021a184ab335d9d2baa689eefcc7f957e5526439 Mon Sep 17 00:00:00 2001 From: Equent Date: Sat, 28 Mar 2026 21:28:33 +0800 Subject: [PATCH] refactor(providers): reuse shared responses helpers Align openai_compat with the shared Responses API utilities so request translation and response parsing stay consistent across providers while preserving the PR's /responses-to-/chat/completions fallback behavior. --- pkg/providers/openai_compat/provider.go | 343 ++---------------- pkg/providers/openai_compat/provider_test.go | 58 +++ .../responses_common.go | 170 ++++++++- .../responses_common_test.go | 34 +- 4 files changed, 279 insertions(+), 326 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index c2a40cc11..8690ba655 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -14,7 +14,10 @@ import ( "strings" "time" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" "github.com/sipeed/picoclaw/pkg/providers/common" + orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -28,7 +31,6 @@ type ( ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ExtraContent = protocoltypes.ExtraContent GoogleExtra = protocoltypes.GoogleExtra - ReasoningDetail = protocoltypes.ReasoningDetail ) type Provider struct { @@ -196,183 +198,56 @@ func (p *Provider) buildResponsesRequestBody( model string, options map[string]any, ) (map[string]any, error) { - input, err := buildResponsesInput(messages) - if err != nil { - return nil, err + input, instructions := orc.TranslateMessages(messages) + requestBody := responses.ResponseNewParams{ + Model: model, + Input: responses.ResponseNewParamsInputUnion{ + OfInputItemList: input, + }, } - - requestBody := map[string]any{ - "model": model, - "input": input, + if instructions != "" { + requestBody.Instructions = openai.Opt(instructions) } nativeSearch, _ := options["native_search"].(bool) nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase) - responseTools := buildResponsesToolsList(tools, nativeSearch) + responseTools := orc.TranslateTools(tools, nativeSearch) if len(responseTools) > 0 { - requestBody["tools"] = responseTools - requestBody["tool_choice"] = "auto" + requestBody.Tools = responseTools + requestBody.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{ + OfToolChoiceMode: openai.Opt(responses.ToolChoiceOptionsAuto), + } } if maxTokens, ok := common.AsInt(options["max_tokens"]); ok { - requestBody["max_output_tokens"] = maxTokens + requestBody.MaxOutputTokens = openai.Opt(int64(maxTokens)) } if temperature, ok := requestTemperature(model, options); ok { - requestBody["temperature"] = temperature + requestBody.Temperature = openai.Opt(temperature) } if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { if supportsPromptCacheKey(p.apiBase) { - requestBody["prompt_cache_key"] = cacheKey + requestBody.PromptCacheKey = openai.Opt(cacheKey) } } + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal responses request: %w", err) + } + + var genericBody map[string]any + if err := json.Unmarshal(jsonData, &genericBody); err != nil { + return nil, fmt.Errorf("failed to normalize responses request body: %w", err) + } + for k, v := range p.extraBody { - requestBody[k] = v + genericBody[k] = v } - return requestBody, nil -} - -func buildResponsesInput(messages []Message) ([]any, error) { - input := make([]any, 0, len(messages)) - - for _, m := range messages { - switch m.Role { - case "system", "user": - input = append(input, map[string]any{ - "type": "message", - "role": m.Role, - "content": serializeResponsesMessageContent(m), - }) - case "assistant": - if strings.TrimSpace(m.Content) != "" || strings.TrimSpace(m.ReasoningContent) != "" || len(m.Media) > 0 || len(m.ToolCalls) == 0 { - input = append(input, map[string]any{ - "type": "message", - "role": m.Role, - "content": serializeResponsesMessageContent(m), - }) - } - - for _, tc := range m.ToolCalls { - name, args, ok := resolveResponseToolCall(tc) - if !ok { - log.Printf("openai_compat: skipping invalid assistant tool call in responses history: id=%q", tc.ID) - continue - } - input = append(input, map[string]any{ - "type": "function_call", - "call_id": tc.ID, - "name": name, - "arguments": args, - }) - } - case "tool": - if strings.TrimSpace(m.ToolCallID) == "" { - return nil, fmt.Errorf("tool message missing tool_call_id") - } - input = append(input, map[string]any{ - "type": "function_call_output", - "call_id": m.ToolCallID, - "output": m.Content, - }) - default: - return nil, fmt.Errorf("unsupported message role: %s", m.Role) - } - } - - return input, nil -} - -func serializeResponsesMessageContent(m Message) any { - effectiveText := m.Content - if effectiveText == "" { - effectiveText = m.ReasoningContent - } - - if len(m.Media) == 0 { - return effectiveText - } - - parts := make([]map[string]any, 0, 1+len(m.Media)) - if effectiveText != "" { - parts = append(parts, map[string]any{ - "type": "input_text", - "text": effectiveText, - }) - } - - for _, mediaURL := range m.Media { - if strings.HasPrefix(mediaURL, "data:image/") { - parts = append(parts, map[string]any{ - "type": "input_image", - "image_url": mediaURL, - }) - } - } - - if len(parts) == 0 { - return effectiveText - } - - return parts -} - -func buildResponsesToolsList(tools []ToolDefinition, nativeSearch bool) []any { - result := make([]any, 0, len(tools)+1) - for _, tool := range tools { - if nativeSearch && strings.EqualFold(tool.Function.Name, "web_search") { - continue - } - if tool.Type != "" && tool.Type != "function" { - continue - } - - entry := map[string]any{ - "type": "function", - "name": tool.Function.Name, - "parameters": tool.Function.Parameters, - } - if entry["parameters"] == nil { - entry["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}} - } - if tool.Function.Description != "" { - entry["description"] = tool.Function.Description - } - - result = append(result, entry) - } - - if nativeSearch { - result = append(result, map[string]any{"type": "web_search_preview"}) - } - - return result -} - -func resolveResponseToolCall(tc ToolCall) (name string, arguments string, ok bool) { - name = tc.Name - if name == "" && tc.Function != nil { - name = tc.Function.Name - } - if name == "" { - return "", "", false - } - - if len(tc.Arguments) > 0 { - argsJSON, err := json.Marshal(tc.Arguments) - if err != nil { - return "", "", false - } - return name, string(argsJSON), true - } - - if tc.Function != nil && tc.Function.Arguments != "" { - return name, tc.Function.Arguments, true - } - - return name, "{}", true + return genericBody, nil } func (p *Provider) Chat( @@ -676,161 +551,7 @@ func parseStreamResponse( } func parseResponsesResponse(body io.Reader) (*LLMResponse, error) { - var apiResponse struct { - Status string `json:"status"` - Error *struct { - Message string `json:"message"` - } `json:"error"` - Output []struct { - ID string `json:"id"` - Type string `json:"type"` - CallID string `json:"call_id"` - Name string `json:"name"` - Arguments string `json:"arguments"` - Summary []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"summary"` - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - Refusal string `json:"refusal"` - } `json:"content"` - } `json:"output"` - IncompleteDetails *struct { - Reason string `json:"reason"` - } `json:"incomplete_details"` - Usage *struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` - } `json:"usage"` - } - - if err := json.NewDecoder(body).Decode(&apiResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - status := strings.TrimSpace(apiResponse.Status) - switch status { - case "completed", "incomplete": - if len(apiResponse.Output) == 0 { - return nil, errors.New("openai responses returned terminal status with empty output") - } - case "failed": - if apiResponse.Error != nil { - if msg := strings.TrimSpace(apiResponse.Error.Message); msg != "" { - return nil, errors.New(msg) - } - } - return nil, errors.New("openai responses request failed") - default: - return nil, fmt.Errorf("openai responses returned unexpected or non-terminal status: %q", status) - } - - var content strings.Builder - var reasoning strings.Builder - var reasoningContent strings.Builder - reasoningDetails := make([]ReasoningDetail, 0) - toolCalls := make([]ToolCall, 0) - for _, item := range apiResponse.Output { - switch item.Type { - case "message": - for _, part := range item.Content { - if part.Text != "" { - content.WriteString(part.Text) - continue - } - if part.Refusal != "" { - content.WriteString(part.Refusal) - } - } - case "reasoning": - for _, part := range item.Summary { - if part.Text == "" { - continue - } - if reasoning.Len() > 0 { - reasoning.WriteString("\n") - } - reasoning.WriteString(part.Text) - reasoningDetails = append(reasoningDetails, ReasoningDetail{ - Format: "text", - Index: len(reasoningDetails), - Type: part.Type, - Text: part.Text, - }) - } - for _, part := range item.Content { - if part.Text == "" { - continue - } - if reasoningContent.Len() > 0 { - reasoningContent.WriteString("\n") - } - reasoningContent.WriteString(part.Text) - reasoningDetails = append(reasoningDetails, ReasoningDetail{ - Format: "text", - Index: len(reasoningDetails), - Type: part.Type, - Text: part.Text, - }) - } - case "function_call": - arguments := make(map[string]any) - if item.Arguments != "" { - if err := json.Unmarshal([]byte(item.Arguments), &arguments); err != nil { - log.Printf("openai_compat: failed to decode responses tool call arguments for %q: %v", item.Name, err) - arguments["raw"] = item.Arguments - } - } - - toolCalls = append(toolCalls, ToolCall{ - ID: firstNonEmpty(item.CallID, item.ID), - Name: item.Name, - Arguments: arguments, - }) - } - } - - finishReason := "stop" - if len(toolCalls) > 0 { - finishReason = "tool_calls" - } else if status == "incomplete" { - finishReason = "length" - if apiResponse.IncompleteDetails != nil && apiResponse.IncompleteDetails.Reason != "" && apiResponse.IncompleteDetails.Reason != "max_output_tokens" { - finishReason = apiResponse.IncompleteDetails.Reason - } - } - - var usage *UsageInfo - if apiResponse.Usage != nil { - usage = &UsageInfo{ - PromptTokens: apiResponse.Usage.InputTokens, - CompletionTokens: apiResponse.Usage.OutputTokens, - TotalTokens: apiResponse.Usage.TotalTokens, - } - } - - return &LLMResponse{ - Content: content.String(), - ReasoningContent: reasoningContent.String(), - Reasoning: reasoning.String(), - ReasoningDetails: reasoningDetails, - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, - }, nil -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - - return "" + return orc.ParseResponseBody(body) } func normalizeModel(model, apiBase string) string { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index bd0b0f681..e9e238a94 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -96,6 +96,64 @@ func TestProviderChat_PrefersResponsesWhenConfigured(t *testing.T) { } } +func TestProviderChat_ResponsesBodyUsesSharedTranslatorSemantics(t *testing.T) { + var requestBody []byte + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + var err error + requestBody, err = io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp := map[string]any{ + "status": "completed", + "output": []map[string]any{{ + "type": "message", + "content": []map[string]any{{"type": "output_text", "text": "ok"}}, + }}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "", WithResponsesPreferred()) + _, err := p.Chat( + t.Context(), + []Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Transcribe this", Media: []string{"data:audio/wav;base64,AAAA"}}, + }, + nil, + "gpt-4o", + nil, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + body := string(requestBody) + if !strings.Contains(body, `"instructions":"You are helpful"`) { + t.Fatalf("expected responses body to contain instructions, got %s", body) + } + if strings.Contains(body, `"role":"system"`) { + t.Fatalf("did not expect system message to remain in input, got %s", body) + } + if !strings.Contains(body, `"type":"input_file"`) { + t.Fatalf("expected audio media to be translated as input_file, got %s", body) + } + if !strings.Contains(body, `"filename":"audio.wav"`) { + t.Fatalf("expected audio filename in multipart payload, got %s", body) + } +} + func TestProviderChat_FallsBackToChatCompletionsWhenResponsesFails(t *testing.T) { var paths []string diff --git a/pkg/providers/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go index 29133a51e..5ddb6041b 100644 --- a/pkg/providers/openai_responses_common/responses_common.go +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -4,6 +4,8 @@ package openai_responses_common import ( "encoding/json" + "errors" + "fmt" "io" "strings" @@ -215,20 +217,184 @@ func TranslateTools(tools []protocoltypes.ToolDefinition, enableWebSearch bool) // ParseResponseBody parses an OpenAI Responses API JSON body into an LLMResponse. // Handles output item types: "message" (output_text + refusal), "function_call", and "reasoning". func ParseResponseBody(body io.Reader) (*protocoltypes.LLMResponse, error) { - var apiResp responses.Response + var apiResp responseEnvelope if err := json.NewDecoder(body).Decode(&apiResp); err != nil { return nil, err } - return parseResponse(&apiResp), nil + return parseResponseEnvelope(&apiResp) } // ParseResponseFromStruct converts a decoded responses.Response into an LLMResponse. // Used by providers that receive the Response struct directly (e.g., via streaming SDK). func ParseResponseFromStruct(resp *responses.Response) *protocoltypes.LLMResponse { + if resp == nil { + return &protocoltypes.LLMResponse{} + } + + raw, err := json.Marshal(resp) + if err == nil { + var apiResp responseEnvelope + if err := json.Unmarshal(raw, &apiResp); err == nil { + if out, err := parseResponseEnvelope(&apiResp); err == nil { + return out + } + } + } + return parseResponse(resp) } +type responseEnvelope struct { + Status string `json:"status"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + Output []struct { + ID string `json:"id"` + Type string `json:"type"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + Summary []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"summary"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + Refusal string `json:"refusal"` + } `json:"content"` + } `json:"output"` + IncompleteDetails *struct { + Reason string `json:"reason"` + } `json:"incomplete_details"` + Usage *struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +func parseResponseEnvelope(apiResp *responseEnvelope) (*protocoltypes.LLMResponse, error) { + status := strings.TrimSpace(apiResp.Status) + switch status { + case "completed", "incomplete": + if len(apiResp.Output) == 0 { + return nil, errors.New("openai responses returned terminal status with empty output") + } + case "failed": + if apiResp.Error != nil { + if msg := strings.TrimSpace(apiResp.Error.Message); msg != "" { + return nil, errors.New(msg) + } + } + return nil, errors.New("openai responses request failed") + default: + return nil, fmt.Errorf("openai responses returned unexpected or non-terminal status: %q", status) + } + + var content strings.Builder + var reasoning strings.Builder + var reasoningContent strings.Builder + reasoningDetails := make([]protocoltypes.ReasoningDetail, 0) + toolCalls := make([]protocoltypes.ToolCall, 0) + + for _, item := range apiResp.Output { + switch item.Type { + case "message": + for _, c := range item.Content { + switch c.Type { + case "output_text": + content.WriteString(c.Text) + case "refusal": + content.WriteString(c.Refusal) + } + } + case "function_call": + var args map[string]any + if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil { + args = map[string]any{"raw": item.Arguments} + } + toolCalls = append(toolCalls, protocoltypes.ToolCall{ + ID: firstNonEmpty(item.CallID, item.ID), + Name: item.Name, + Arguments: args, + }) + case "reasoning": + for _, s := range item.Summary { + if s.Text == "" { + continue + } + if reasoning.Len() > 0 { + reasoning.WriteString("\n") + } + reasoning.WriteString(s.Text) + reasoningDetails = append(reasoningDetails, protocoltypes.ReasoningDetail{ + Format: "text", + Index: len(reasoningDetails), + Type: s.Type, + Text: s.Text, + }) + } + for _, c := range item.Content { + if c.Text == "" { + continue + } + if reasoningContent.Len() > 0 { + reasoningContent.WriteString("\n") + } + reasoningContent.WriteString(c.Text) + reasoningDetails = append(reasoningDetails, protocoltypes.ReasoningDetail{ + Format: "text", + Index: len(reasoningDetails), + Type: c.Type, + Text: c.Text, + }) + } + } + } + + finishReason := "stop" + if len(toolCalls) > 0 { + finishReason = "tool_calls" + } else if status == "incomplete" { + finishReason = "length" + if apiResp.IncompleteDetails != nil && apiResp.IncompleteDetails.Reason != "" && apiResp.IncompleteDetails.Reason != "max_output_tokens" { + finishReason = apiResp.IncompleteDetails.Reason + } + } + + var usage *protocoltypes.UsageInfo + if apiResp.Usage != nil { + usage = &protocoltypes.UsageInfo{ + PromptTokens: apiResp.Usage.InputTokens, + CompletionTokens: apiResp.Usage.OutputTokens, + TotalTokens: apiResp.Usage.TotalTokens, + } + } + + return &protocoltypes.LLMResponse{ + Content: content.String(), + ReasoningContent: reasoningContent.String(), + Reasoning: reasoning.String(), + ReasoningDetails: reasoningDetails, + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + + return "" +} + // parseResponse is the shared implementation for extracting LLMResponse fields // from a decoded responses.Response. func parseResponse(apiResp *responses.Response) *protocoltypes.LLMResponse { diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go index be10e8427..0bba253eb 100644 --- a/pkg/providers/openai_responses_common/responses_common_test.go +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -381,7 +381,8 @@ func TestParseResponseBody_Reasoning(t *testing.T) { { "type": "reasoning", "id": "rs_1", - "summary": [{"type": "summary_text", "text": "Thinking about it..."}] + "summary": [{"type": "summary_text", "text": "Thinking about it..."}], + "content": [{"type": "reasoning_text", "text": "Step by step"}] }, { "type": "message", @@ -404,8 +405,14 @@ func TestParseResponseBody_Reasoning(t *testing.T) { if result.Content != "The answer is 42." { t.Errorf("Content = %q, want %q", result.Content, "The answer is 42.") } - if result.ReasoningContent != "Thinking about it..." { - t.Errorf("ReasoningContent = %q, want %q", result.ReasoningContent, "Thinking about it...") + if result.Reasoning != "Thinking about it..." { + t.Errorf("Reasoning = %q, want %q", result.Reasoning, "Thinking about it...") + } + if result.ReasoningContent != "Step by step" { + t.Errorf("ReasoningContent = %q, want %q", result.ReasoningContent, "Step by step") + } + if len(result.ReasoningDetails) != 2 { + t.Fatalf("len(ReasoningDetails) = %d, want 2", len(result.ReasoningDetails)) } } @@ -442,13 +449,14 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { body := strings.NewReader(`{ "id": "resp_inc", "object": "response", - "status": "incomplete", + "status": " incomplete ", "output": [ { "type": "message", "content": [{"type": "output_text", "text": "partial"}] } ], + "incomplete_details": {"reason": "content_filter"}, "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0}} @@ -458,8 +466,8 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { if err != nil { t.Fatalf("error: %v", err) } - if result.FinishReason != "length" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "length") + if result.FinishReason != "content_filter" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "content_filter") } } @@ -467,20 +475,20 @@ func TestParseResponseBody_FailedStatus(t *testing.T) { body := strings.NewReader(`{ "id": "resp_fail", "object": "response", - "status": "failed", + "status": " failed ", + "error": {"message": "responses failed"}, "output": [], "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0}} }`) - result, err := ParseResponseBody(body) - if err != nil { - t.Fatalf("error: %v", err) + _, err := ParseResponseBody(body) + if err == nil { + t.Fatal("expected error, got nil") } - // failed/canceled statuses are not specially mapped; they fall through to "stop" - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") + if err.Error() != "responses failed" { + t.Fatalf("error = %q, want %q", err.Error(), "responses failed") } }