919 lines
26 KiB
Go
919 lines
26 KiB
Go
package openai_compat
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
|
)
|
|
|
|
type (
|
|
ToolCall = protocoltypes.ToolCall
|
|
FunctionCall = protocoltypes.FunctionCall
|
|
LLMResponse = protocoltypes.LLMResponse
|
|
UsageInfo = protocoltypes.UsageInfo
|
|
Message = protocoltypes.Message
|
|
ToolDefinition = protocoltypes.ToolDefinition
|
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
|
ExtraContent = protocoltypes.ExtraContent
|
|
GoogleExtra = protocoltypes.GoogleExtra
|
|
ReasoningDetail = protocoltypes.ReasoningDetail
|
|
)
|
|
|
|
type Provider struct {
|
|
apiKey string
|
|
apiBase string
|
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
|
httpClient *http.Client
|
|
preferResponses bool // Prefer /responses for OpenAI-native models selected via the factory.
|
|
}
|
|
|
|
type Option func(*Provider)
|
|
|
|
const defaultRequestTimeout = 120 * time.Second
|
|
|
|
func WithMaxTokensField(maxTokensField string) Option {
|
|
return func(p *Provider) {
|
|
p.maxTokensField = maxTokensField
|
|
}
|
|
}
|
|
|
|
func WithRequestTimeout(timeout time.Duration) Option {
|
|
return func(p *Provider) {
|
|
if timeout > 0 {
|
|
p.httpClient.Timeout = timeout
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithResponsesPreferred marks this provider instance as OpenAI-native so it
|
|
// prefers /responses even after the factory strips the outer "openai/" prefix.
|
|
func WithResponsesPreferred() Option {
|
|
return func(p *Provider) {
|
|
p.preferResponses = true
|
|
}
|
|
}
|
|
|
|
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
|
client := &http.Client{
|
|
Timeout: defaultRequestTimeout,
|
|
}
|
|
|
|
if proxy != "" {
|
|
parsed, err := url.Parse(proxy)
|
|
if err == nil {
|
|
client.Transport = &http.Transport{
|
|
Proxy: http.ProxyURL(parsed),
|
|
}
|
|
} else {
|
|
log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err)
|
|
}
|
|
}
|
|
|
|
p := &Provider{
|
|
apiKey: apiKey,
|
|
apiBase: strings.TrimRight(apiBase, "/"),
|
|
httpClient: client,
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(p)
|
|
}
|
|
}
|
|
|
|
return p
|
|
}
|
|
|
|
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
|
|
return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))
|
|
}
|
|
|
|
func NewProviderWithMaxTokensFieldAndTimeout(
|
|
apiKey, apiBase, proxy, maxTokensField string,
|
|
requestTimeoutSeconds int,
|
|
) *Provider {
|
|
return NewProvider(
|
|
apiKey,
|
|
apiBase,
|
|
proxy,
|
|
WithMaxTokensField(maxTokensField),
|
|
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
|
)
|
|
}
|
|
|
|
func (p *Provider) Chat(
|
|
ctx context.Context,
|
|
messages []Message,
|
|
tools []ToolDefinition,
|
|
model string,
|
|
options map[string]any,
|
|
) (*LLMResponse, error) {
|
|
if p.apiBase == "" {
|
|
return nil, fmt.Errorf("API base not configured")
|
|
}
|
|
|
|
normalizedModel := normalizeModel(model, p.apiBase)
|
|
// Keep the legacy chat/completions path for histories that already depend on
|
|
// reasoning_content, because Responses represents reasoning state differently.
|
|
if shouldPreferResponses(model, normalizedModel, p.preferResponses) && !hasReasoningContentHistory(messages) {
|
|
out, err := p.chatResponses(ctx, messages, tools, normalizedModel, options)
|
|
if err == nil {
|
|
return out, nil
|
|
}
|
|
if ctx.Err() != nil {
|
|
return nil, err
|
|
}
|
|
log.Printf("openai_compat: /responses failed for %q, falling back to /chat/completions: %v", normalizedModel, err)
|
|
|
|
fallbackOut, fallbackErr := p.chatCompletions(ctx, messages, tools, normalizedModel, options)
|
|
if fallbackErr != nil {
|
|
return nil, fmt.Errorf("responses request failed: %w; fallback chat/completions failed: %v", err, fallbackErr)
|
|
}
|
|
return fallbackOut, nil
|
|
}
|
|
|
|
return p.chatCompletions(ctx, messages, tools, normalizedModel, options)
|
|
}
|
|
|
|
func (p *Provider) chatCompletions(
|
|
ctx context.Context,
|
|
messages []Message,
|
|
tools []ToolDefinition,
|
|
model string,
|
|
options map[string]any,
|
|
) (*LLMResponse, error) {
|
|
requestBody := buildChatCompletionsRequestBody(messages, tools, model, options, p.maxTokensField, p.apiBase)
|
|
return p.doRequest(ctx, "/chat/completions", requestBody, parseResponse)
|
|
}
|
|
|
|
func (p *Provider) chatResponses(
|
|
ctx context.Context,
|
|
messages []Message,
|
|
tools []ToolDefinition,
|
|
model string,
|
|
options map[string]any,
|
|
) (*LLMResponse, error) {
|
|
requestBody, err := buildResponsesRequestBody(messages, tools, model, options, p.apiBase)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return p.doRequest(ctx, "/responses", requestBody, parseResponsesResponse)
|
|
}
|
|
|
|
func buildChatCompletionsRequestBody(
|
|
messages []Message,
|
|
tools []ToolDefinition,
|
|
model string,
|
|
options map[string]any,
|
|
maxTokensField string,
|
|
apiBase string,
|
|
) map[string]any {
|
|
requestBody := map[string]any{
|
|
"model": model,
|
|
"messages": serializeMessages(messages),
|
|
}
|
|
|
|
if len(tools) > 0 {
|
|
requestBody["tools"] = tools
|
|
requestBody["tool_choice"] = "auto"
|
|
}
|
|
|
|
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
|
// Use configured maxTokensField if specified, otherwise fallback to model-based detection.
|
|
fieldName := maxTokensField
|
|
if fieldName == "" {
|
|
// Fallback: detect from model name for backward compatibility.
|
|
lowerModel := strings.ToLower(model)
|
|
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
|
|
strings.Contains(lowerModel, "gpt-5") {
|
|
fieldName = "max_completion_tokens"
|
|
} else {
|
|
fieldName = "max_tokens"
|
|
}
|
|
}
|
|
requestBody[fieldName] = maxTokens
|
|
}
|
|
|
|
if temperature, ok := requestTemperature(model, options); ok {
|
|
requestBody["temperature"] = temperature
|
|
}
|
|
|
|
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
|
// with the same key and reuse prefix KV cache across calls.
|
|
// The key is typically the agent ID - stable per agent, shared across requests.
|
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
|
// Prompt caching is only supported by OpenAI-native endpoints.
|
|
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
|
|
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
|
if !strings.Contains(apiBase, "generativelanguage.googleapis.com") {
|
|
requestBody["prompt_cache_key"] = cacheKey
|
|
}
|
|
}
|
|
|
|
return requestBody
|
|
}
|
|
|
|
// buildResponsesRequestBody keeps the option handling close to the legacy
|
|
// chat/completions path so the new route can reuse the existing compatibility
|
|
// knobs with minimal behavioral drift.
|
|
func buildResponsesRequestBody(
|
|
messages []Message,
|
|
tools []ToolDefinition,
|
|
model string,
|
|
options map[string]any,
|
|
apiBase string,
|
|
) (map[string]any, error) {
|
|
input, err := buildResponsesInput(messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestBody := map[string]any{
|
|
"model": model,
|
|
"input": input,
|
|
}
|
|
|
|
if len(tools) > 0 {
|
|
requestBody["tools"] = serializeResponseTools(tools)
|
|
requestBody["tool_choice"] = "auto"
|
|
}
|
|
|
|
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
|
requestBody["max_output_tokens"] = maxTokens
|
|
}
|
|
|
|
if temperature, ok := requestTemperature(model, options); ok {
|
|
requestBody["temperature"] = temperature
|
|
}
|
|
|
|
// Prompt caching follows the same compatibility rule as chat/completions:
|
|
// send the key only to endpoints that are expected to understand it.
|
|
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
|
if !strings.Contains(apiBase, "generativelanguage.googleapis.com") {
|
|
requestBody["prompt_cache_key"] = cacheKey
|
|
}
|
|
}
|
|
|
|
return requestBody, nil
|
|
}
|
|
|
|
// buildResponsesInput translates the existing conversation format into the
|
|
// item-based Responses input shape while preserving tool call history.
|
|
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
|
|
}
|
|
|
|
// serializeResponsesMessageContent converts plain text and inline image data
|
|
// into the content format expected by the Responses API.
|
|
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
|
|
}
|
|
|
|
// serializeResponseTools maps the existing OpenAI-compatible tool schema to the
|
|
// smaller function-tool shape accepted by the Responses API.
|
|
func serializeResponseTools(tools []ToolDefinition) []map[string]any {
|
|
result := make([]map[string]any, 0, len(tools))
|
|
for _, tool := range tools {
|
|
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)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// resolveResponseToolCall rebuilds the assistant-side tool call record into the
|
|
// stringified argument form required by Responses conversation history.
|
|
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 requestTemperature(model string, options map[string]any) (float64, bool) {
|
|
temperature, ok := asFloat(options["temperature"])
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
|
|
lowerModel := strings.ToLower(model)
|
|
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
|
return 1.0, true
|
|
}
|
|
return temperature, true
|
|
}
|
|
|
|
// shouldPreferResponses centralizes the opt-in rule so OpenAI-native configs
|
|
// and gpt-5 models can try /responses first while other compat backends keep
|
|
// their existing chat/completions behavior.
|
|
func shouldPreferResponses(rawModel, normalizedModel string, preferOpenAIModels bool) bool {
|
|
rawModel = strings.ToLower(strings.TrimSpace(rawModel))
|
|
normalizedModel = strings.ToLower(strings.TrimSpace(normalizedModel))
|
|
|
|
return preferOpenAIModels || strings.HasPrefix(rawModel, "openai/") ||
|
|
strings.HasPrefix(rawModel, "gpt-5") ||
|
|
strings.HasPrefix(normalizedModel, "gpt-5")
|
|
}
|
|
|
|
// hasReasoningContentHistory detects histories that already rely on the legacy
|
|
// reasoning_content field so they can stay on the older wire format.
|
|
func hasReasoningContentHistory(messages []Message) bool {
|
|
for _, message := range messages {
|
|
if strings.TrimSpace(message.ReasoningContent) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Provider) doRequest(
|
|
ctx context.Context,
|
|
path string,
|
|
requestBody map[string]any,
|
|
parse func(io.Reader) (*LLMResponse, error),
|
|
) (*LLMResponse, error) {
|
|
jsonData, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+path, bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if p.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", readErr)
|
|
}
|
|
if looksLikeHTML(body, contentType) {
|
|
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"API request failed:\n Status: %d\n Body: %s",
|
|
resp.StatusCode,
|
|
responsePreview(body, 128),
|
|
)
|
|
}
|
|
|
|
// Peek without consuming so the full stream reaches the JSON decoder.
|
|
reader := bufio.NewReader(resp.Body)
|
|
prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort
|
|
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
|
|
return nil, fmt.Errorf("failed to inspect response: %w", err)
|
|
}
|
|
if looksLikeHTML(prefix, contentType) {
|
|
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
|
|
}
|
|
|
|
out, err := parse(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
|
|
respPreview := responsePreview(body, 128)
|
|
return fmt.Errorf(
|
|
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
|
|
apiBase,
|
|
contentType,
|
|
statusCode,
|
|
respPreview,
|
|
)
|
|
}
|
|
|
|
func looksLikeHTML(body []byte, contentType string) bool {
|
|
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
|
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
|
|
return true
|
|
}
|
|
prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
|
|
return bytes.HasPrefix(prefix, []byte("<!doctype html")) ||
|
|
bytes.HasPrefix(prefix, []byte("<html")) ||
|
|
bytes.HasPrefix(prefix, []byte("<head")) ||
|
|
bytes.HasPrefix(prefix, []byte("<body"))
|
|
}
|
|
|
|
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
|
|
i := 0
|
|
for i < len(body) {
|
|
switch body[i] {
|
|
case ' ', '\t', '\n', '\r', '\f', '\v':
|
|
i++
|
|
default:
|
|
end := i + maxLen
|
|
if end > len(body) {
|
|
end = len(body)
|
|
}
|
|
return body[i:end]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func responsePreview(body []byte, maxLen int) string {
|
|
trimmed := bytes.TrimSpace(body)
|
|
if len(trimmed) == 0 {
|
|
return "<empty>"
|
|
}
|
|
if len(trimmed) <= maxLen {
|
|
return string(trimmed)
|
|
}
|
|
return string(trimmed[:maxLen]) + "..."
|
|
}
|
|
|
|
func parseResponse(body io.Reader) (*LLMResponse, error) {
|
|
var apiResponse struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
Reasoning string `json:"reasoning"`
|
|
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
|
ToolCalls []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function *struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
ExtraContent *struct {
|
|
Google *struct {
|
|
ThoughtSignature string `json:"thought_signature"`
|
|
} `json:"google"`
|
|
} `json:"extra_content"`
|
|
} `json:"tool_calls"`
|
|
} `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Usage *UsageInfo `json:"usage"`
|
|
}
|
|
|
|
if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
if len(apiResponse.Choices) == 0 {
|
|
return &LLMResponse{
|
|
Content: "",
|
|
FinishReason: "stop",
|
|
}, nil
|
|
}
|
|
|
|
choice := apiResponse.Choices[0]
|
|
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
|
for _, tc := range choice.Message.ToolCalls {
|
|
arguments := make(map[string]any)
|
|
name := ""
|
|
|
|
// Extract thought_signature from Gemini/Google-specific extra content
|
|
thoughtSignature := ""
|
|
if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
|
|
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
|
|
}
|
|
|
|
if tc.Function != nil {
|
|
name = tc.Function.Name
|
|
if tc.Function.Arguments != "" {
|
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
|
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
|
|
arguments["raw"] = tc.Function.Arguments
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
|
|
toolCall := ToolCall{
|
|
ID: tc.ID,
|
|
Name: name,
|
|
Arguments: arguments,
|
|
ThoughtSignature: thoughtSignature,
|
|
}
|
|
|
|
if thoughtSignature != "" {
|
|
toolCall.ExtraContent = &ExtraContent{
|
|
Google: &GoogleExtra{
|
|
ThoughtSignature: thoughtSignature,
|
|
},
|
|
}
|
|
}
|
|
|
|
toolCalls = append(toolCalls, toolCall)
|
|
}
|
|
|
|
return &LLMResponse{
|
|
Content: choice.Message.Content,
|
|
ReasoningContent: choice.Message.ReasoningContent,
|
|
Reasoning: choice.Message.Reasoning,
|
|
ReasoningDetails: choice.Message.ReasoningDetails,
|
|
ToolCalls: toolCalls,
|
|
FinishReason: choice.FinishReason,
|
|
Usage: apiResponse.Usage,
|
|
}, nil
|
|
}
|
|
|
|
// parseResponsesResponse maps the Responses API envelope back to the legacy
|
|
// provider response shape used by the rest of the codebase.
|
|
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)
|
|
}
|
|
if strings.TrimSpace(apiResponse.Status) == "" && len(apiResponse.Output) == 0 {
|
|
return nil, errors.New("openai responses returned unexpected response shape")
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
if apiResponse.Status == "failed" {
|
|
if apiResponse.Error != nil && apiResponse.Error.Message != "" {
|
|
return nil, errors.New(apiResponse.Error.Message)
|
|
}
|
|
return nil, errors.New("openai responses request failed")
|
|
}
|
|
|
|
finishReason := "stop"
|
|
if len(toolCalls) > 0 {
|
|
finishReason = "tool_calls"
|
|
} else if apiResponse.Status == "incomplete" {
|
|
finishReason = "length"
|
|
if apiResponse.IncompleteDetails != nil && apiResponse.IncompleteDetails.Reason != "" && apiResponse.IncompleteDetails.Reason != "max_output_tokens" {
|
|
finishReason = apiResponse.IncompleteDetails.Reason
|
|
}
|
|
} else if apiResponse.Status == "failed" {
|
|
finishReason = "error"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// firstNonEmpty prefers call_id but falls back to the raw item id when the
|
|
// response item omits it.
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
|
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
|
// internal field that would be unknown to third-party endpoints.
|
|
type openaiMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
}
|
|
|
|
// serializeMessages converts internal Message structs to the OpenAI wire format.
|
|
// - Strips SystemParts (unknown to third-party endpoints)
|
|
// - Converts messages with Media to multipart content format (text + image_url parts)
|
|
// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages
|
|
func serializeMessages(messages []Message) []any {
|
|
out := make([]any, 0, len(messages))
|
|
for _, m := range messages {
|
|
if len(m.Media) == 0 {
|
|
out = append(out, openaiMessage{
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
ReasoningContent: m.ReasoningContent,
|
|
ToolCalls: m.ToolCalls,
|
|
ToolCallID: m.ToolCallID,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Multipart content format for messages with media
|
|
parts := make([]map[string]any, 0, 1+len(m.Media))
|
|
if m.Content != "" {
|
|
parts = append(parts, map[string]any{
|
|
"type": "text",
|
|
"text": m.Content,
|
|
})
|
|
}
|
|
for _, mediaURL := range m.Media {
|
|
if strings.HasPrefix(mediaURL, "data:image/") {
|
|
parts = append(parts, map[string]any{
|
|
"type": "image_url",
|
|
"image_url": map[string]any{
|
|
"url": mediaURL,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
msg := map[string]any{
|
|
"role": m.Role,
|
|
"content": parts,
|
|
}
|
|
if m.ToolCallID != "" {
|
|
msg["tool_call_id"] = m.ToolCallID
|
|
}
|
|
if len(m.ToolCalls) > 0 {
|
|
msg["tool_calls"] = m.ToolCalls
|
|
}
|
|
if m.ReasoningContent != "" {
|
|
msg["reasoning_content"] = m.ReasoningContent
|
|
}
|
|
out = append(out, msg)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeModel(model, apiBase string) string {
|
|
before, after, ok := strings.Cut(model, "/")
|
|
if !ok {
|
|
return model
|
|
}
|
|
|
|
if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") {
|
|
return model
|
|
}
|
|
|
|
prefix := strings.ToLower(before)
|
|
switch prefix {
|
|
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
|
|
return after
|
|
default:
|
|
return model
|
|
}
|
|
}
|
|
|
|
func asInt(v any) (int, bool) {
|
|
switch val := v.(type) {
|
|
case int:
|
|
return val, true
|
|
case int64:
|
|
return int(val), true
|
|
case float64:
|
|
return int(val), true
|
|
case float32:
|
|
return int(val), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func asFloat(v any) (float64, bool) {
|
|
switch val := v.(type) {
|
|
case float64:
|
|
return val, true
|
|
case float32:
|
|
return float64(val), true
|
|
case int:
|
|
return float64(val), true
|
|
case int64:
|
|
return float64(val), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|