feat(routing): implement unified model routing with OAuth and API key providers

- Added a new routing package to manage provider registration and model resolution.
- Introduced Router, Executor, and Provider interfaces to handle different provider types.
- Implemented OAuthProvider and APIKeyProvider to support OAuth and API key authentication.
- Enhanced DefaultModelMapper to include OAuth model alias handling and fallback mechanisms.
- Updated context management in API handlers to preserve fallback models.
- Added tests for routing logic and provider selection.
- Enhanced Claude request conversion to handle reasoning content based on thinking mode.
This commit is contained in:
이대희
2026-01-30 21:29:05 +09:00
committed by hkfires
parent 09044e8ccc
commit 89907231c1
12 changed files with 1041 additions and 55 deletions

View File

@@ -61,10 +61,13 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
out, _ = sjson.Set(out, "stream", stream)
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
// Also track if thinking is enabled to ensure reasoning_content is added for tool_calls
thinkingEnabled := false
if thinkingConfig := root.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingType := thinkingConfig.Get("type"); thinkingType.Exists() {
switch thinkingType.String() {
case "enabled":
thinkingEnabled = true
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int())
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
@@ -217,6 +220,10 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Add reasoning_content if present
if hasReasoning {
msgJSON, _ = sjson.Set(msgJSON, "reasoning_content", reasoningContent)
} else if thinkingEnabled && hasToolCalls {
// Claude API requires reasoning_content in assistant messages with tool_calls
// when thinking mode is enabled, even if empty
msgJSON, _ = sjson.Set(msgJSON, "reasoning_content", "")
}
// Add tool_calls if present (in same message as content)

View File

@@ -588,3 +588,124 @@ func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *t
t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got)
}
}
// TestConvertClaudeRequestToOpenAI_ThinkingEnabledToolCallsNoReasoning tests that
// when thinking mode is enabled and assistant message has tool_calls but no thinking content,
// an empty reasoning_content is added to satisfy Claude API requirements.
func TestConvertClaudeRequestToOpenAI_ThinkingEnabledToolCallsNoReasoning(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantHasReasoningContent bool
wantReasoningContent string
}{
{
name: "thinking enabled with tool_calls but no thinking content adds empty reasoning_content",
inputJSON: `{
"model": "claude-3-opus",
"thinking": {"type": "enabled", "budget_tokens": 4000},
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "I will help you."},
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {"path": "/test.txt"}}
]
}]
}`,
wantHasReasoningContent: true,
wantReasoningContent: "",
},
{
name: "thinking enabled with tool_calls and thinking content uses actual reasoning",
inputJSON: `{
"model": "claude-3-opus",
"thinking": {"type": "enabled", "budget_tokens": 4000},
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me analyze this..."},
{"type": "text", "text": "I will help you."},
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {"path": "/test.txt"}}
]
}]
}`,
wantHasReasoningContent: true,
wantReasoningContent: "Let me analyze this...",
},
{
name: "thinking disabled with tool_calls does not add reasoning_content",
inputJSON: `{
"model": "claude-3-opus",
"thinking": {"type": "disabled"},
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "I will help you."},
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {"path": "/test.txt"}}
]
}]
}`,
wantHasReasoningContent: false,
wantReasoningContent: "",
},
{
name: "no thinking config with tool_calls does not add reasoning_content",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "I will help you."},
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {"path": "/test.txt"}}
]
}]
}`,
wantHasReasoningContent: false,
wantReasoningContent: "",
},
{
name: "thinking enabled without tool_calls and no thinking content does not add reasoning_content",
inputJSON: `{
"model": "claude-3-opus",
"thinking": {"type": "enabled", "budget_tokens": 4000},
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "Simple response without tools."}
]
}]
}`,
wantHasReasoningContent: false,
wantReasoningContent: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) == 0 {
t.Fatal("Expected at least one message")
}
assistantMsg := messages[0]
if assistantMsg.Get("role").String() != "assistant" {
t.Fatalf("Expected assistant message, got %s", assistantMsg.Get("role").String())
}
hasReasoningContent := assistantMsg.Get("reasoning_content").Exists()
if hasReasoningContent != tt.wantHasReasoningContent {
t.Errorf("reasoning_content existence = %v, want %v", hasReasoningContent, tt.wantHasReasoningContent)
}
if hasReasoningContent {
gotReasoningContent := assistantMsg.Get("reasoning_content").String()
if gotReasoningContent != tt.wantReasoningContent {
t.Errorf("reasoning_content = %q, want %q", gotReasoningContent, tt.wantReasoningContent)
}
}
})
}
}