Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions internal/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
159 changes: 159 additions & 0 deletions internal/converter/reasoning_model_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading