diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 3499f46..d53e711 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -25,6 +25,39 @@ const ( DefaultHaikuModel = "gpt-5-mini" ) +// isReasoningModel detects if a model uses reasoning/extended thinking capabilities. +// Per OpenAI API specs, reasoning models require max_completion_tokens instead of max_tokens. +// See: https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_completion_tokens +// +// This includes: +// - OpenAI o-series: o1, o2, o3, o4 (reasoning models) +// - OpenAI GPT-5 series: gpt-5, gpt-5-mini, etc. +// - Azure variants: azure/o1, azure/gpt-5, etc. +func isReasoningModel(modelName string) bool { + model := strings.ToLower(modelName) + + // Remove provider prefixes for pattern matching + model = strings.TrimPrefix(model, "azure/") + model = strings.TrimPrefix(model, "openai/") + + // Check for o-series reasoning models (o1, o2, o3, o4, etc.) + // Matches: o1, o1-preview, o2, o2-mini, o3, o3-mini, o4, etc. + if strings.HasPrefix(model, "o1") || + strings.HasPrefix(model, "o2") || + strings.HasPrefix(model, "o3") || + strings.HasPrefix(model, "o4") { + return true + } + + // Check for GPT-5 series (gpt-5, gpt-5-mini, gpt-5-turbo, etc.) + // Prefixes are already stripped above, so simple prefix check suffices + if strings.HasPrefix(model, "gpt-5") { + return true + } + + return false +} + // extractSystemText extracts system text from Claude's flexible system parameter. // Claude supports both string format ("system": "text") and array format with content blocks. // This function normalizes both formats to a single string for OpenAI compatibility. @@ -139,8 +172,9 @@ func ConvertRequest(claudeReq models.ClaudeRequest, cfg *config.Config) (*models // Set token limit if claudeReq.MaxTokens > 0 { - // Use max_completion_tokens for newer models - if strings.HasPrefix(openaiModel, "gpt-5") { + // Reasoning models (o1, o3, o4, gpt-5) require max_completion_tokens + // instead of the legacy max_tokens parameter + if isReasoningModel(openaiModel) { openaiReq.MaxCompletionTokens = claudeReq.MaxTokens } else { openaiReq.MaxTokens = claudeReq.MaxTokens diff --git a/internal/converter/reasoning_model_test.go b/internal/converter/reasoning_model_test.go new file mode 100644 index 0000000..62aca95 --- /dev/null +++ b/internal/converter/reasoning_model_test.go @@ -0,0 +1,159 @@ +package converter + +import ( + "testing" + + "github.com/claude-code-proxy/proxy/internal/config" + "github.com/claude-code-proxy/proxy/pkg/models" +) + +func TestIsReasoningModel(t *testing.T) { + tests := []struct { + name string + model string + expected bool + }{ + // GPT-5 series (reasoning models) + {"gpt-5", "gpt-5", true}, + {"gpt-5 uppercase", "GPT-5", true}, + {"gpt-5-mini", "gpt-5-mini", true}, + {"gpt-5-turbo", "gpt-5-turbo", true}, + {"azure/gpt-5", "azure/gpt-5", true}, + {"openai/gpt-5", "openai/gpt-5", true}, + {"azure/gpt-5-mini", "azure/gpt-5-mini", true}, + + // o-series reasoning models + {"o1", "o1", true}, + {"o1-preview", "o1-preview", true}, + {"o1-mini", "o1-mini", true}, + {"o2", "o2", true}, + {"o2-preview", "o2-preview", true}, + {"o2-mini", "o2-mini", true}, + {"o3", "o3", true}, + {"o3-mini", "o3-mini", true}, + {"o4", "o4", true}, + {"o4-turbo", "o4-turbo", true}, + {"azure/o1", "azure/o1", true}, + {"azure/o2", "azure/o2", true}, + {"openai/o3", "openai/o3", true}, + + // GPT-4 series (NOT reasoning models) + {"gpt-4", "gpt-4", false}, + {"gpt-4o", "gpt-4o", false}, + {"gpt-4-turbo", "gpt-4-turbo", false}, + {"gpt-4.1", "gpt-4.1", false}, + {"gpt-4o-mini", "gpt-4o-mini", false}, + {"azure/gpt-4o", "azure/gpt-4o", false}, + {"openai/gpt-4-turbo", "openai/gpt-4-turbo", false}, + + // GPT-3.5 series (NOT reasoning models) + {"gpt-3.5-turbo", "gpt-3.5-turbo", false}, + {"gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k", false}, + + // Other models (NOT reasoning models) + {"claude-3-opus", "claude-3-opus", false}, + {"claude-sonnet-4", "claude-sonnet-4", false}, + {"gemini-pro", "gemini-pro", false}, + {"llama-3-70b", "llama-3-70b", false}, + + // Edge cases + {"empty string", "", false}, + {"o prefix but not reasoning", "ollama", false}, + {"contains gpt-5 but not start", "meta-gpt-5", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isReasoningModel(tt.model) + if result != tt.expected { + t.Errorf("isReasoningModel(%q) = %v, expected %v", tt.model, result, tt.expected) + } + }) + } +} + +func TestReasoningModelTokenParameter(t *testing.T) { + tests := []struct { + name string + model string + maxTokens int + expectMaxTokens int + expectMaxCompletion int + }{ + { + name: "gpt-5 uses max_completion_tokens", + model: "gpt-5", + maxTokens: 100, + expectMaxTokens: 0, + expectMaxCompletion: 100, + }, + { + name: "o1 uses max_completion_tokens", + model: "o1", + maxTokens: 200, + expectMaxTokens: 0, + expectMaxCompletion: 200, + }, + { + name: "o2 uses max_completion_tokens", + model: "o2", + maxTokens: 150, + expectMaxTokens: 0, + expectMaxCompletion: 150, + }, + { + name: "azure/o3 uses max_completion_tokens", + model: "azure/o3", + maxTokens: 150, + expectMaxTokens: 0, + expectMaxCompletion: 150, + }, + { + name: "gpt-4o uses max_tokens", + model: "gpt-4o", + maxTokens: 100, + expectMaxTokens: 100, + expectMaxCompletion: 0, + }, + { + name: "gpt-4-turbo uses max_tokens", + model: "gpt-4-turbo", + maxTokens: 200, + expectMaxTokens: 200, + expectMaxCompletion: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a minimal Claude request + claudeReq := models.ClaudeRequest{ + Model: tt.model, + MaxTokens: tt.maxTokens, + Messages: []models.ClaudeMessage{ + {Role: "user", Content: "test"}, + }, + } + + // Create a minimal config + cfg := &config.Config{ + OpenAIAPIKey: "test-key", + OpenAIBaseURL: "https://api.openai.com/v1", + } + + // Convert the request + openaiReq, err := ConvertRequest(claudeReq, cfg) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + + // Verify token parameters + if openaiReq.MaxTokens != tt.expectMaxTokens { + t.Errorf("MaxTokens = %d, expected %d", openaiReq.MaxTokens, tt.expectMaxTokens) + } + if openaiReq.MaxCompletionTokens != tt.expectMaxCompletion { + t.Errorf("MaxCompletionTokens = %d, expected %d", openaiReq.MaxCompletionTokens, tt.expectMaxCompletion) + } + }) + } +}