feat(providers): prefer OpenAI responses API with fallback
This commit is contained in:
parent
4768edc67b
commit
1655561ac8
5 changed files with 1074 additions and 30 deletions
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
|
||||||
)
|
)
|
||||||
|
|
||||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||||
|
|
@ -84,12 +85,16 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
|
// The factory strips the outer protocol prefix before calling the HTTP
|
||||||
|
// provider, so pass an explicit hint to preserve the requested
|
||||||
|
// OpenAI-specific /responses-first behavior.
|
||||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
cfg.APIKey,
|
cfg.APIKey,
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
|
openai_compat.WithResponsesPreferred(),
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ package providers
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -99,6 +100,56 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_OpenAIUsesResponsesFirst(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"from responses"}]}]}`))
|
||||||
|
case "/chat/completions":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"from chat completions"},"finish_reason":"stop"}]}`))
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-openai",
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
APIKey: "test-key",
|
||||||
|
APIBase: server.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := provider.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
modelID,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "from responses" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "from responses")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/responses"}) {
|
||||||
|
t.Fatalf("paths = %v, want [/responses]", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ type HTTPProvider struct {
|
||||||
delegate *openai_compat.Provider
|
delegate *openai_compat.Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
// NewHTTPProvider forwards optional provider-specific compatibility flags
|
||||||
|
// without changing the shared HTTP provider interface.
|
||||||
|
func NewHTTPProvider(apiKey, apiBase, proxy string, opts ...openai_compat.Option) *HTTPProvider {
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
delegate: openai_compat.NewProvider(apiKey, apiBase, proxy),
|
delegate: openai_compat.NewProvider(apiKey, apiBase, proxy, opts...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,15 +32,18 @@ func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField st
|
||||||
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
apiKey, apiBase, proxy, maxTokensField string,
|
apiKey, apiBase, proxy, maxTokensField string,
|
||||||
requestTimeoutSeconds int,
|
requestTimeoutSeconds int,
|
||||||
|
opts ...openai_compat.Option,
|
||||||
) *HTTPProvider {
|
) *HTTPProvider {
|
||||||
return &HTTPProvider{
|
// Apply the legacy defaults first, then append any protocol-specific
|
||||||
delegate: openai_compat.NewProvider(
|
// behavior switches such as OpenAI's /responses preference.
|
||||||
apiKey,
|
providerOpts := []openai_compat.Option{
|
||||||
apiBase,
|
|
||||||
proxy,
|
|
||||||
openai_compat.WithMaxTokensField(maxTokensField),
|
openai_compat.WithMaxTokensField(maxTokensField),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds) * time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds) * time.Second),
|
||||||
),
|
}
|
||||||
|
providerOpts = append(providerOpts, opts...)
|
||||||
|
|
||||||
|
return &HTTPProvider{
|
||||||
|
delegate: openai_compat.NewProvider(apiKey, apiBase, proxy, providerOpts...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -34,6 +35,7 @@ type Provider struct {
|
||||||
apiBase string
|
apiBase string
|
||||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
preferResponses bool // Prefer /responses for OpenAI-native models selected via the factory.
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Provider)
|
type Option func(*Provider)
|
||||||
|
|
@ -54,6 +56,14 @@ func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: defaultRequestTimeout,
|
Timeout: defaultRequestTimeout,
|
||||||
|
|
@ -113,8 +123,62 @@ func (p *Provider) Chat(
|
||||||
return nil, fmt.Errorf("API base not configured")
|
return nil, fmt.Errorf("API base not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
model = normalizeModel(model, p.apiBase)
|
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{
|
requestBody := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": serializeMessages(messages),
|
"messages": serializeMessages(messages),
|
||||||
|
|
@ -126,10 +190,10 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
if maxTokens, ok := asInt(options["max_tokens"]); ok {
|
||||||
// Use configured maxTokensField if specified, otherwise fallback to model-based detection
|
// Use configured maxTokensField if specified, otherwise fallback to model-based detection.
|
||||||
fieldName := p.maxTokensField
|
fieldName := maxTokensField
|
||||||
if fieldName == "" {
|
if fieldName == "" {
|
||||||
// Fallback: detect from model name for backward compatibility
|
// Fallback: detect from model name for backward compatibility.
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
|
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
|
||||||
strings.Contains(lowerModel, "gpt-5") {
|
strings.Contains(lowerModel, "gpt-5") {
|
||||||
|
|
@ -141,34 +205,257 @@ func (p *Provider) Chat(
|
||||||
requestBody[fieldName] = maxTokens
|
requestBody[fieldName] = maxTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
if temperature, ok := asFloat(options["temperature"]); ok {
|
if temperature, ok := requestTemperature(model, options); ok {
|
||||||
lowerModel := strings.ToLower(model)
|
|
||||||
// Kimi k2 models only support temperature=1.
|
|
||||||
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
|
||||||
requestBody["temperature"] = 1.0
|
|
||||||
} else {
|
|
||||||
requestBody["temperature"] = temperature
|
requestBody["temperature"] = temperature
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
|
||||||
// with the same key and reuse prefix KV cache across calls.
|
// with the same key and reuse prefix KV cache across calls.
|
||||||
// The key is typically the agent ID — stable per agent, shared across requests.
|
// The key is typically the agent ID - stable per agent, shared across requests.
|
||||||
// See: https://platform.openai.com/docs/guides/prompt-caching
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
||||||
// Prompt caching is only supported by OpenAI-native endpoints.
|
// Prompt caching is only supported by OpenAI-native endpoints.
|
||||||
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
|
// Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs.
|
||||||
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
|
||||||
if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") {
|
if !strings.Contains(apiBase, "generativelanguage.googleapis.com") {
|
||||||
requestBody["prompt_cache_key"] = cacheKey
|
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)
|
jsonData, err := json.Marshal(requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+path, bytes.NewReader(jsonData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +472,6 @@ func (p *Provider) Chat(
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
|
||||||
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
|
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
|
|
@ -212,7 +498,7 @@ func (p *Provider) Chat(
|
||||||
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
|
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := parseResponse(reader)
|
out, err := parse(reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -361,6 +647,162 @@ func parseResponse(body io.Reader) (*LLMResponse, error) {
|
||||||
}, nil
|
}, 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.
|
// openaiMessage is the wire-format message for OpenAI-compatible APIs.
|
||||||
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
// It mirrors protocoltypes.Message but omits SystemParts, which is an
|
||||||
// internal field that would be unknown to third-party endpoints.
|
// internal field that would be unknown to third-party endpoints.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -15,6 +16,546 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestProviderChat_PrefersResponsesForOpenAIPrefixedModel(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
var responsesBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&responsesBody); 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": "from responses"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": 12,
|
||||||
|
"output_tokens": 3,
|
||||||
|
"total_tokens": 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "from chat completions"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"openai/gpt-4o",
|
||||||
|
map[string]any{"max_tokens": 256},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "from responses" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "from responses")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/responses"}) {
|
||||||
|
t.Fatalf("paths = %v, want [/responses]", paths)
|
||||||
|
}
|
||||||
|
if responsesBody["model"] != "openai/gpt-4o" {
|
||||||
|
t.Fatalf("model = %v, want openai/gpt-4o", responsesBody["model"])
|
||||||
|
}
|
||||||
|
if _, ok := responsesBody["input"]; !ok {
|
||||||
|
t.Fatalf("expected responses request body to contain input")
|
||||||
|
}
|
||||||
|
if _, ok := responsesBody["messages"]; ok {
|
||||||
|
t.Fatalf("did not expect messages in responses request body")
|
||||||
|
}
|
||||||
|
if responsesBody["max_output_tokens"] != float64(256) {
|
||||||
|
t.Fatalf("max_output_tokens = %v, want 256", responsesBody["max_output_tokens"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_FallsBackToChatCompletionsWhenResponsesFails(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"responses not supported"}`))
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "fallback chat completion"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"gpt-5.2",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "fallback chat completion" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "fallback chat completion")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/responses", "/chat/completions"}) {
|
||||||
|
t.Fatalf("paths = %v, want [/responses /chat/completions]", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_ParsesToolCallsFromResponses(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "completed",
|
||||||
|
"output": []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"id": "fc_1",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": "{\"city\":\"SF\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": 9,
|
||||||
|
"output_tokens": 4,
|
||||||
|
"total_tokens": 13,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "from chat completions"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "weather?"}},
|
||||||
|
[]ToolDefinition{{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunctionDefinition{
|
||||||
|
Name: "get_weather",
|
||||||
|
Description: "Get weather",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"city": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
"gpt-5.2",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.FinishReason != "tool_calls" {
|
||||||
|
t.Fatalf("FinishReason = %q, want tool_calls", out.FinishReason)
|
||||||
|
}
|
||||||
|
if len(out.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||||
|
}
|
||||||
|
if out.ToolCalls[0].ID != "call_1" {
|
||||||
|
t.Fatalf("ToolCalls[0].ID = %q, want call_1", out.ToolCalls[0].ID)
|
||||||
|
}
|
||||||
|
if out.ToolCalls[0].Name != "get_weather" {
|
||||||
|
t.Fatalf("ToolCalls[0].Name = %q, want get_weather", out.ToolCalls[0].Name)
|
||||||
|
}
|
||||||
|
if out.ToolCalls[0].Arguments["city"] != "SF" {
|
||||||
|
t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_FallsBackWhenResponsesStatusFailed(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "failed",
|
||||||
|
"error": map[string]any{
|
||||||
|
"message": "responses failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "fallback after failed status"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"gpt-5.2",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "fallback after failed status" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "fallback after failed status")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/responses", "/chat/completions"}) {
|
||||||
|
t.Fatalf("paths = %v, want [/responses /chat/completions]", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_ParsesReasoningContentFromResponses(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "completed",
|
||||||
|
"output": []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": []map[string]any{
|
||||||
|
{"type": "summary_text", "text": "brief reasoning"},
|
||||||
|
},
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "reasoning_text", "text": "step by step"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "output_text", "text": "final answer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "chat fallback"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "why?"}},
|
||||||
|
nil,
|
||||||
|
"gpt-5.2",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "final answer" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "final answer")
|
||||||
|
}
|
||||||
|
if out.Reasoning != "brief reasoning" {
|
||||||
|
t.Fatalf("Reasoning = %q, want %q", out.Reasoning, "brief reasoning")
|
||||||
|
}
|
||||||
|
if out.ReasoningContent != "step by step" {
|
||||||
|
t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "step by step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_FallsBackWhenResponsesReturnsUnexpected200Body(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "wrong envelope"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "fallback after invalid responses body"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"gpt-5.2",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "fallback after invalid responses body" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "fallback after invalid responses body")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/responses", "/chat/completions"}) {
|
||||||
|
t.Fatalf("paths = %v, want [/responses /chat/completions]", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_SkipsResponsesWhenHistoryHasReasoningContent(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "completed",
|
||||||
|
"output": []map[string]any{{
|
||||||
|
"type": "message",
|
||||||
|
"content": []map[string]any{{"type": "output_text", "text": "responses path"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "chat path"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{
|
||||||
|
{Role: "user", Content: "1+1?"},
|
||||||
|
{Role: "assistant", Content: "2", ReasoningContent: "internal reasoning"},
|
||||||
|
{Role: "user", Content: "2+2?"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
"gpt-5.2",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "chat path" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "chat path")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/chat/completions"}) {
|
||||||
|
t.Fatalf("paths = %v, want [/chat/completions]", paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqMessages, ok := requestBody["messages"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||||
|
}
|
||||||
|
assistantMsg, ok := reqMessages[1].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1])
|
||||||
|
}
|
||||||
|
if assistantMsg["reasoning_content"] != "internal reasoning" {
|
||||||
|
t.Fatalf("reasoning_content = %v, want internal reasoning", assistantMsg["reasoning_content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_DoesNotPreferResponsesForNestedOpenAINamespace(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "completed",
|
||||||
|
"output": []map[string]any{{
|
||||||
|
"type": "message",
|
||||||
|
"content": []map[string]any{{"type": "output_text", "text": "responses path"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "chat path"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"groq/openai/gpt-oss-120b",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "chat path" {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "chat path")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(paths, []string{"/chat/completions"}) {
|
||||||
|
t.Fatalf("paths = %v, want [/chat/completions]", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_ParsesRefusalFromResponses(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/responses":
|
||||||
|
resp := map[string]any{
|
||||||
|
"status": "completed",
|
||||||
|
"output": []map[string]any{{
|
||||||
|
"type": "message",
|
||||||
|
"content": []map[string]any{{"type": "refusal", "refusal": "I can't help with that."}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
case "/chat/completions":
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"content": "chat fallback"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "unsafe request"}},
|
||||||
|
nil,
|
||||||
|
"gpt-5.2",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "I can't help with that." {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, "I can't help with that.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
|
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue