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:
parent
f807157a52
commit
021a184ab3
4 changed files with 279 additions and 326 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue