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.
This commit is contained in:
Equent 2026-03-28 21:28:33 +08:00
parent f807157a52
commit 021a184ab3
4 changed files with 279 additions and 326 deletions

View file

@ -14,7 +14,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/responses"
"github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/common"
orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
@ -28,7 +31,6 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra GoogleExtra = protocoltypes.GoogleExtra
ReasoningDetail = protocoltypes.ReasoningDetail
) )
type Provider struct { type Provider struct {
@ -196,183 +198,56 @@ func (p *Provider) buildResponsesRequestBody(
model string, model string,
options map[string]any, options map[string]any,
) (map[string]any, error) { ) (map[string]any, error) {
input, err := buildResponsesInput(messages) input, instructions := orc.TranslateMessages(messages)
if err != nil { requestBody := responses.ResponseNewParams{
return nil, err Model: model,
Input: responses.ResponseNewParamsInputUnion{
OfInputItemList: input,
},
} }
if instructions != "" {
requestBody := map[string]any{ requestBody.Instructions = openai.Opt(instructions)
"model": model,
"input": input,
} }
nativeSearch, _ := options["native_search"].(bool) nativeSearch, _ := options["native_search"].(bool)
nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase) nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase)
responseTools := buildResponsesToolsList(tools, nativeSearch) responseTools := orc.TranslateTools(tools, nativeSearch)
if len(responseTools) > 0 { if len(responseTools) > 0 {
requestBody["tools"] = responseTools requestBody.Tools = responseTools
requestBody["tool_choice"] = "auto" requestBody.ToolChoice = responses.ResponseNewParamsToolChoiceUnion{
OfToolChoiceMode: openai.Opt(responses.ToolChoiceOptionsAuto),
}
} }
if maxTokens, ok := common.AsInt(options["max_tokens"]); ok { 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 { 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 cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
if supportsPromptCacheKey(p.apiBase) { 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 { for k, v := range p.extraBody {
requestBody[k] = v genericBody[k] = v
} }
return requestBody, nil return genericBody, 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
} }
func (p *Provider) Chat( func (p *Provider) Chat(
@ -676,161 +551,7 @@ func parseStreamResponse(
} }
func parseResponsesResponse(body io.Reader) (*LLMResponse, error) { func parseResponsesResponse(body io.Reader) (*LLMResponse, error) {
var apiResponse struct { return orc.ParseResponseBody(body)
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 ""
} }
func normalizeModel(model, apiBase string) string { func normalizeModel(model, apiBase string) string {

View file

@ -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) { func TestProviderChat_FallsBackToChatCompletionsWhenResponsesFails(t *testing.T) {
var paths []string var paths []string

View file

@ -4,6 +4,8 @@ package openai_responses_common
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"io" "io"
"strings" "strings"
@ -215,20 +217,184 @@ func TranslateTools(tools []protocoltypes.ToolDefinition, enableWebSearch bool)
// ParseResponseBody parses an OpenAI Responses API JSON body into an LLMResponse. // ParseResponseBody parses an OpenAI Responses API JSON body into an LLMResponse.
// Handles output item types: "message" (output_text + refusal), "function_call", and "reasoning". // Handles output item types: "message" (output_text + refusal), "function_call", and "reasoning".
func ParseResponseBody(body io.Reader) (*protocoltypes.LLMResponse, error) { func ParseResponseBody(body io.Reader) (*protocoltypes.LLMResponse, error) {
var apiResp responses.Response var apiResp responseEnvelope
if err := json.NewDecoder(body).Decode(&apiResp); err != nil { if err := json.NewDecoder(body).Decode(&apiResp); err != nil {
return nil, err return nil, err
} }
return parseResponse(&apiResp), nil return parseResponseEnvelope(&apiResp)
} }
// ParseResponseFromStruct converts a decoded responses.Response into an LLMResponse. // ParseResponseFromStruct converts a decoded responses.Response into an LLMResponse.
// Used by providers that receive the Response struct directly (e.g., via streaming SDK). // Used by providers that receive the Response struct directly (e.g., via streaming SDK).
func ParseResponseFromStruct(resp *responses.Response) *protocoltypes.LLMResponse { 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) 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 // parseResponse is the shared implementation for extracting LLMResponse fields
// from a decoded responses.Response. // from a decoded responses.Response.
func parseResponse(apiResp *responses.Response) *protocoltypes.LLMResponse { func parseResponse(apiResp *responses.Response) *protocoltypes.LLMResponse {

View file

@ -381,7 +381,8 @@ func TestParseResponseBody_Reasoning(t *testing.T) {
{ {
"type": "reasoning", "type": "reasoning",
"id": "rs_1", "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", "type": "message",
@ -404,8 +405,14 @@ func TestParseResponseBody_Reasoning(t *testing.T) {
if result.Content != "The answer is 42." { if result.Content != "The answer is 42." {
t.Errorf("Content = %q, want %q", result.Content, "The answer is 42.") t.Errorf("Content = %q, want %q", result.Content, "The answer is 42.")
} }
if result.ReasoningContent != "Thinking about it..." { if result.Reasoning != "Thinking about it..." {
t.Errorf("ReasoningContent = %q, want %q", result.ReasoningContent, "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))
} }
} }
@ -449,6 +456,7 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) {
"content": [{"type": "output_text", "text": "partial"}] "content": [{"type": "output_text", "text": "partial"}]
} }
], ],
"incomplete_details": {"reason": "content_filter"},
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7, "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7,
"input_tokens_details": {"cached_tokens": 0}, "input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0}} "output_tokens_details": {"reasoning_tokens": 0}}
@ -458,8 +466,8 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("error: %v", err) t.Fatalf("error: %v", err)
} }
if result.FinishReason != "length" { if result.FinishReason != "content_filter" {
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "length") t.Errorf("FinishReason = %q, want %q", result.FinishReason, "content_filter")
} }
} }
@ -468,19 +476,19 @@ func TestParseResponseBody_FailedStatus(t *testing.T) {
"id": "resp_fail", "id": "resp_fail",
"object": "response", "object": "response",
"status": " failed ", "status": " failed ",
"error": {"message": "responses failed"},
"output": [], "output": [],
"usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
"input_tokens_details": {"cached_tokens": 0}, "input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0}} "output_tokens_details": {"reasoning_tokens": 0}}
}`) }`)
result, err := ParseResponseBody(body) _, err := ParseResponseBody(body)
if err != nil { if err == nil {
t.Fatalf("error: %v", err) t.Fatal("expected error, got nil")
} }
// failed/canceled statuses are not specially mapped; they fall through to "stop" if err.Error() != "responses failed" {
if result.FinishReason != "stop" { t.Fatalf("error = %q, want %q", err.Error(), "responses failed")
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
} }
} }