Browse Source

feat: responses api only model support request by chat/claude/gemini … (#429)

* feat: responses api only model support request by chat/claude/gemini protocol

* feat: chat/claude/gemini convert to responses
zijiren 1 tháng trước cách đây
mục cha
commit
6e040335c8

+ 7 - 1
README.md

@@ -23,7 +23,8 @@ AI Proxy is a powerful, production-ready AI gateway that provides intelligent re
 - **Smart Retry Logic**: Intelligent retry strategies with automatic error recovery
 - **Priority-based Channel Selection**: Route requests based on channel priority and error rates
 - **Load Balancing**: Efficiently distribute traffic across multiple AI providers
-- **Protocol Conversion**: Seamless Claude to OpenAI API protocol conversion
+- **Protocol Conversion**: Seamless protocol conversion between OpenAI Chat Completions, Claude Messages, Gemini, and OpenAI Responses API
+  - Chat/Claude/Gemini → Responses API: Use responses-only models with any protocol
 
 ### 📊 **Comprehensive Monitoring & Analytics**
 
@@ -327,6 +328,11 @@ env_key = "AIPROXY_API_KEY"
 wire_api = "chat"
 ```
 
+**Protocol Conversion Support**:
+- **Responses-only models**: AI Proxy automatically converts Chat/Claude/Gemini requests to Responses API format for models that only support the Responses API
+- **Multi-protocol access**: Use any protocol (Chat Completions, Claude Messages, or Gemini) to access responses-only models
+- **Transparent conversion**: No client-side changes needed - AI Proxy handles protocol translation automatically
+
 ### MCP (Model Context Protocol)
 
 AI Proxy provides comprehensive MCP support for extending AI capabilities:

+ 7 - 1
README.zh.md

@@ -23,7 +23,8 @@ AI Proxy 是一个强大的、生产就绪的 AI 网关,提供智能请求路
 - **智能重试机制**:智能重试策略与自动错误恢复
 - **基于优先级的渠道选择**:根据渠道优先级和错误率路由请求
 - **负载均衡**:高效地在多个 AI 提供商之间分配流量
-- **协议转换**:无缝的 Claude 到 OpenAI API 协议转换
+- **协议转换**:在 OpenAI Chat Completions、Claude Messages、Gemini 和 OpenAI Responses API 之间无缝转换
+  - Chat/Claude/Gemini → Responses API:使用任意协议访问仅支持 Responses 的模型
 
 ### 📊 **全面监控与分析**
 
@@ -326,6 +327,11 @@ env_key = "AIPROXY_API_KEY"
 wire_api = "chat"
 ```
 
+**协议转换支持**:
+- **仅支持 Responses 的模型**:AI Proxy 自动将 Chat/Claude/Gemini 请求转换为 Responses API 格式,支持仅提供 Responses API 的模型
+- **多协议访问**:使用任意协议(Chat Completions、Claude Messages 或 Gemini)访问仅支持 Responses 的模型
+- **透明转换**:无需客户端修改 - AI Proxy 自动处理协议转换
+
 ### MCP (模型上下文协议)
 
 AI Proxy 提供全面的 MCP 支持,扩展 AI 能力:

+ 98 - 7
core/docs/docs.go

@@ -10039,17 +10039,27 @@ const docTemplate = `{
         "model.CreateResponseRequest": {
             "type": "object",
             "properties": {
+                "background": {
+                    "type": "boolean"
+                },
+                "conversation": {
+                    "description": "string or object"
+                },
+                "include": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "input": {},
                 "instructions": {
                     "type": "string"
                 },
                 "max_output_tokens": {
                     "type": "integer"
                 },
-                "messages": {
-                    "type": "array",
-                    "items": {
-                        "$ref": "#/definitions/model.Message"
-                    }
+                "max_tool_calls": {
+                    "type": "integer"
                 },
                 "metadata": {
                     "type": "object",
@@ -10064,6 +10074,15 @@ const docTemplate = `{
                 "previous_response_id": {
                     "type": "string"
                 },
+                "prompt_cache_key": {
+                    "type": "string"
+                },
+                "safety_identifier": {
+                    "type": "string"
+                },
+                "service_tier": {
+                    "type": "string"
+                },
                 "store": {
                     "type": "boolean"
                 },
@@ -10073,13 +10092,19 @@ const docTemplate = `{
                 "temperature": {
                     "type": "number"
                 },
+                "text": {
+                    "$ref": "#/definitions/model.ResponseText"
+                },
                 "tool_choice": {},
                 "tools": {
                     "type": "array",
                     "items": {
-                        "$ref": "#/definitions/model.Tool"
+                        "$ref": "#/definitions/model.ResponseTool"
                     }
                 },
+                "top_logprobs": {
+                    "type": "integer"
+                },
                 "top_p": {
                     "type": "number"
                 },
@@ -10087,6 +10112,7 @@ const docTemplate = `{
                     "type": "string"
                 },
                 "user": {
+                    "description": "Deprecated, use prompt_cache_key",
                     "type": "string"
                 }
             }
@@ -10871,6 +10897,23 @@ const docTemplate = `{
         "model.InputContent": {
             "type": "object",
             "properties": {
+                "arguments": {
+                    "type": "string"
+                },
+                "call_id": {
+                    "description": "Fields for function_result type",
+                    "type": "string"
+                },
+                "id": {
+                    "description": "Fields for function_call type",
+                    "type": "string"
+                },
+                "name": {
+                    "type": "string"
+                },
+                "output": {
+                    "type": "string"
+                },
                 "text": {
                     "type": "string"
                 },
@@ -10882,6 +10925,13 @@ const docTemplate = `{
         "model.InputItem": {
             "type": "object",
             "properties": {
+                "arguments": {
+                    "type": "string"
+                },
+                "call_id": {
+                    "description": "Fields for function_result type",
+                    "type": "string"
+                },
                 "content": {
                     "type": "array",
                     "items": {
@@ -10891,6 +10941,13 @@ const docTemplate = `{
                 "id": {
                     "type": "string"
                 },
+                "name": {
+                    "description": "Fields for function_call type",
+                    "type": "string"
+                },
+                "output": {
+                    "type": "string"
+                },
                 "role": {
                     "type": "string"
                 },
@@ -11274,6 +11331,14 @@ const docTemplate = `{
         "model.OutputItem": {
             "type": "object",
             "properties": {
+                "arguments": {
+                    "description": "For function_call type",
+                    "type": "string"
+                },
+                "call_id": {
+                    "description": "For function_call type",
+                    "type": "string"
+                },
                 "content": {
                     "type": "array",
                     "items": {
@@ -11283,12 +11348,23 @@ const docTemplate = `{
                 "id": {
                     "type": "string"
                 },
+                "name": {
+                    "description": "For function_call type",
+                    "type": "string"
+                },
                 "role": {
                     "type": "string"
                 },
                 "status": {
                     "$ref": "#/definitions/model.ResponseStatus"
                 },
+                "summary": {
+                    "description": "For reasoning type",
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
                 "type": {
                     "type": "string"
                 }
@@ -11770,7 +11846,7 @@ const docTemplate = `{
                 "tools": {
                     "type": "array",
                     "items": {
-                        "$ref": "#/definitions/model.Tool"
+                        "$ref": "#/definitions/model.ResponseTool"
                     }
                 },
                 "top_p": {
@@ -11853,6 +11929,21 @@ const docTemplate = `{
                 }
             }
         },
+        "model.ResponseTool": {
+            "type": "object",
+            "properties": {
+                "description": {
+                    "type": "string"
+                },
+                "name": {
+                    "type": "string"
+                },
+                "parameters": {},
+                "type": {
+                    "type": "string"
+                }
+            }
+        },
         "model.ResponseUsage": {
             "type": "object",
             "properties": {

+ 98 - 7
core/docs/swagger.json

@@ -10030,17 +10030,27 @@
         "model.CreateResponseRequest": {
             "type": "object",
             "properties": {
+                "background": {
+                    "type": "boolean"
+                },
+                "conversation": {
+                    "description": "string or object"
+                },
+                "include": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "input": {},
                 "instructions": {
                     "type": "string"
                 },
                 "max_output_tokens": {
                     "type": "integer"
                 },
-                "messages": {
-                    "type": "array",
-                    "items": {
-                        "$ref": "#/definitions/model.Message"
-                    }
+                "max_tool_calls": {
+                    "type": "integer"
                 },
                 "metadata": {
                     "type": "object",
@@ -10055,6 +10065,15 @@
                 "previous_response_id": {
                     "type": "string"
                 },
+                "prompt_cache_key": {
+                    "type": "string"
+                },
+                "safety_identifier": {
+                    "type": "string"
+                },
+                "service_tier": {
+                    "type": "string"
+                },
                 "store": {
                     "type": "boolean"
                 },
@@ -10064,13 +10083,19 @@
                 "temperature": {
                     "type": "number"
                 },
+                "text": {
+                    "$ref": "#/definitions/model.ResponseText"
+                },
                 "tool_choice": {},
                 "tools": {
                     "type": "array",
                     "items": {
-                        "$ref": "#/definitions/model.Tool"
+                        "$ref": "#/definitions/model.ResponseTool"
                     }
                 },
+                "top_logprobs": {
+                    "type": "integer"
+                },
                 "top_p": {
                     "type": "number"
                 },
@@ -10078,6 +10103,7 @@
                     "type": "string"
                 },
                 "user": {
+                    "description": "Deprecated, use prompt_cache_key",
                     "type": "string"
                 }
             }
@@ -10862,6 +10888,23 @@
         "model.InputContent": {
             "type": "object",
             "properties": {
+                "arguments": {
+                    "type": "string"
+                },
+                "call_id": {
+                    "description": "Fields for function_result type",
+                    "type": "string"
+                },
+                "id": {
+                    "description": "Fields for function_call type",
+                    "type": "string"
+                },
+                "name": {
+                    "type": "string"
+                },
+                "output": {
+                    "type": "string"
+                },
                 "text": {
                     "type": "string"
                 },
@@ -10873,6 +10916,13 @@
         "model.InputItem": {
             "type": "object",
             "properties": {
+                "arguments": {
+                    "type": "string"
+                },
+                "call_id": {
+                    "description": "Fields for function_result type",
+                    "type": "string"
+                },
                 "content": {
                     "type": "array",
                     "items": {
@@ -10882,6 +10932,13 @@
                 "id": {
                     "type": "string"
                 },
+                "name": {
+                    "description": "Fields for function_call type",
+                    "type": "string"
+                },
+                "output": {
+                    "type": "string"
+                },
                 "role": {
                     "type": "string"
                 },
@@ -11265,6 +11322,14 @@
         "model.OutputItem": {
             "type": "object",
             "properties": {
+                "arguments": {
+                    "description": "For function_call type",
+                    "type": "string"
+                },
+                "call_id": {
+                    "description": "For function_call type",
+                    "type": "string"
+                },
                 "content": {
                     "type": "array",
                     "items": {
@@ -11274,12 +11339,23 @@
                 "id": {
                     "type": "string"
                 },
+                "name": {
+                    "description": "For function_call type",
+                    "type": "string"
+                },
                 "role": {
                     "type": "string"
                 },
                 "status": {
                     "$ref": "#/definitions/model.ResponseStatus"
                 },
+                "summary": {
+                    "description": "For reasoning type",
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
                 "type": {
                     "type": "string"
                 }
@@ -11761,7 +11837,7 @@
                 "tools": {
                     "type": "array",
                     "items": {
-                        "$ref": "#/definitions/model.Tool"
+                        "$ref": "#/definitions/model.ResponseTool"
                     }
                 },
                 "top_p": {
@@ -11844,6 +11920,21 @@
                 }
             }
         },
+        "model.ResponseTool": {
+            "type": "object",
+            "properties": {
+                "description": {
+                    "type": "string"
+                },
+                "name": {
+                    "type": "string"
+                },
+                "parameters": {},
+                "type": {
+                    "type": "string"
+                }
+            }
+        },
         "model.ResponseUsage": {
             "type": "object",
             "properties": {

+ 78 - 14
core/docs/swagger.yaml

@@ -1071,16 +1071,16 @@ definitions:
         type: integer
       retry_count:
         type: integer
-      status_400_count:
-        type: integer
-      status_429_count:
-        type: integer
       status_4xx_count:
         type: integer
       status_500_count:
         type: integer
       status_5xx_count:
         type: integer
+      status_400_count:
+        type: integer
+      status_429_count:
+        type: integer
       timestamp:
         type: integer
       total_time_milliseconds:
@@ -1137,14 +1137,21 @@ definitions:
     type: object
   model.CreateResponseRequest:
     properties:
+      background:
+        type: boolean
+      conversation:
+        description: string or object
+      include:
+        items:
+          type: string
+        type: array
+      input: {}
       instructions:
         type: string
       max_output_tokens:
         type: integer
-      messages:
-        items:
-          $ref: '#/definitions/model.Message'
-        type: array
+      max_tool_calls:
+        type: integer
       metadata:
         additionalProperties: {}
         type: object
@@ -1154,22 +1161,33 @@ definitions:
         type: boolean
       previous_response_id:
         type: string
+      prompt_cache_key:
+        type: string
+      safety_identifier:
+        type: string
+      service_tier:
+        type: string
       store:
         type: boolean
       stream:
         type: boolean
       temperature:
         type: number
+      text:
+        $ref: '#/definitions/model.ResponseText'
       tool_choice: {}
       tools:
         items:
-          $ref: '#/definitions/model.Tool'
+          $ref: '#/definitions/model.ResponseTool'
         type: array
+      top_logprobs:
+        type: integer
       top_p:
         type: number
       truncation:
         type: string
       user:
+        description: Deprecated, use prompt_cache_key
         type: string
     type: object
   model.DashboardResponse:
@@ -1492,8 +1510,6 @@ definitions:
         type: integer
       rpm:
         type: integer
-      status_5xx_count:
-        type: integer
       status_400_count:
         type: integer
       status_429_count:
@@ -1502,6 +1518,8 @@ definitions:
         type: integer
       status_500_count:
         type: integer
+      status_5xx_count:
+        type: integer
       token_names:
         items:
           type: string
@@ -1695,6 +1713,18 @@ definitions:
     type: object
   model.InputContent:
     properties:
+      arguments:
+        type: string
+      call_id:
+        description: Fields for function_result type
+        type: string
+      id:
+        description: Fields for function_call type
+        type: string
+      name:
+        type: string
+      output:
+        type: string
       text:
         type: string
       type:
@@ -1702,12 +1732,22 @@ definitions:
     type: object
   model.InputItem:
     properties:
+      arguments:
+        type: string
+      call_id:
+        description: Fields for function_result type
+        type: string
       content:
         items:
           $ref: '#/definitions/model.InputContent'
         type: array
       id:
         type: string
+      name:
+        description: Fields for function_call type
+        type: string
+      output:
+        type: string
       role:
         type: string
       type:
@@ -1984,16 +2024,30 @@ definitions:
     type: object
   model.OutputItem:
     properties:
+      arguments:
+        description: For function_call type
+        type: string
+      call_id:
+        description: For function_call type
+        type: string
       content:
         items:
           $ref: '#/definitions/model.OutputContent'
         type: array
       id:
         type: string
+      name:
+        description: For function_call type
+        type: string
       role:
         type: string
       status:
         $ref: '#/definitions/model.ResponseStatus'
+      summary:
+        description: For reasoning type
+        items:
+          type: string
+        type: array
       type:
         type: string
     type: object
@@ -2318,7 +2372,7 @@ definitions:
       tool_choice: {}
       tools:
         items:
-          $ref: '#/definitions/model.Tool'
+          $ref: '#/definitions/model.ResponseTool'
         type: array
       top_p:
         type: number
@@ -2374,6 +2428,16 @@ definitions:
       type:
         type: string
     type: object
+  model.ResponseTool:
+    properties:
+      description:
+        type: string
+      name:
+        type: string
+      parameters: {}
+      type:
+        type: string
+    type: object
   model.ResponseUsage:
     properties:
       input_tokens:
@@ -2445,8 +2509,6 @@ definitions:
         type: integer
       retry_count:
         type: integer
-      status_5xx_count:
-        type: integer
       status_400_count:
         type: integer
       status_429_count:
@@ -2455,6 +2517,8 @@ definitions:
         type: integer
       status_500_count:
         type: integer
+      status_5xx_count:
+        type: integer
       timestamp:
         type: integer
       token_name:

+ 48 - 48
core/relay/adaptor/anthropic/gemini.go

@@ -130,40 +130,40 @@ func ConvertClaudeToGeminiResponse(
 	candidate := &relaymodel.GeminiChatCandidate{
 		Index: 0,
 		Content: relaymodel.GeminiChatContent{
-			Role:  "model",
+			Role:  relaymodel.GeminiRoleModel,
 			Parts: []*relaymodel.GeminiPart{},
 		},
 	}
 
 	// Convert stop reason
 	switch claudeResp.StopReason {
-	case "end_turn":
-		candidate.FinishReason = "STOP"
-	case "max_tokens":
-		candidate.FinishReason = "MAX_TOKENS"
-	case "tool_use":
-		candidate.FinishReason = "STOP"
+	case relaymodel.ClaudeStopReasonEndTurn:
+		candidate.FinishReason = relaymodel.GeminiFinishReasonStop
+	case relaymodel.ClaudeStopReasonMaxTokens:
+		candidate.FinishReason = relaymodel.GeminiFinishReasonMaxTokens
+	case relaymodel.ClaudeStopReasonToolUse:
+		candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 	default:
-		candidate.FinishReason = "STOP"
+		candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 	}
 
 	// Convert content
 	for _, content := range claudeResp.Content {
 		switch content.Type {
-		case "text":
+		case relaymodel.ClaudeContentTypeText:
 			if content.Text != "" {
 				candidate.Content.Parts = append(candidate.Content.Parts, &relaymodel.GeminiPart{
 					Text: content.Text,
 				})
 			}
-		case "thinking":
+		case relaymodel.ClaudeContentTypeThinking:
 			if content.Thinking != "" {
 				candidate.Content.Parts = append(candidate.Content.Parts, &relaymodel.GeminiPart{
 					Text:    content.Thinking,
 					Thought: true,
 				})
 			}
-		case "tool_use":
+		case relaymodel.ClaudeContentTypeToolUse:
 			if inputMap, ok := content.Input.(map[string]any); ok {
 				candidate.Content.Parts = append(candidate.Content.Parts, &relaymodel.GeminiPart{
 					FunctionCall: &relaymodel.GeminiFunctionCall{
@@ -307,13 +307,13 @@ func (s *GeminiStreamState) ConvertClaudeStreamToGemini(
 	candidate := &relaymodel.GeminiChatCandidate{
 		Index: 0,
 		Content: relaymodel.GeminiChatContent{
-			Role:  "model",
+			Role:  relaymodel.GeminiRoleModel,
 			Parts: []*relaymodel.GeminiPart{},
 		},
 	}
 
 	switch claudeResp.Type {
-	case "message_start":
+	case relaymodel.ClaudeStreamTypeMessageStart:
 		if claudeResp.Message != nil && claudeResp.Message.Usage.InputTokens > 0 {
 			geminiResp.UsageMetadata = &relaymodel.GeminiUsageMetadata{
 				PromptTokenCount: claudeResp.Message.Usage.InputTokens,
@@ -322,27 +322,27 @@ func (s *GeminiStreamState) ConvertClaudeStreamToGemini(
 
 		return geminiResp
 
-	case "content_block_delta":
+	case relaymodel.ClaudeStreamTypeContentBlockDelta:
 		if claudeResp.Delta != nil {
 			switch {
-			case claudeResp.Delta.Type == "text_delta" && claudeResp.Delta.Text != "":
+			case claudeResp.Delta.Type == relaymodel.ClaudeDeltaTypeTextDelta && claudeResp.Delta.Text != "":
 				candidate.Content.Parts = append(candidate.Content.Parts, &relaymodel.GeminiPart{
 					Text: claudeResp.Delta.Text,
 				})
-			case claudeResp.Delta.Type == "thinking_delta" && claudeResp.Delta.Thinking != "":
+			case claudeResp.Delta.Type == relaymodel.ClaudeDeltaTypeThinkingDelta && claudeResp.Delta.Thinking != "":
 				candidate.Content.Parts = append(candidate.Content.Parts, &relaymodel.GeminiPart{
 					Text:    claudeResp.Delta.Thinking,
 					Thought: true,
 				})
-			case claudeResp.Delta.Type == "input_json_delta" && claudeResp.Delta.PartialJSON != "":
+			case claudeResp.Delta.Type == relaymodel.ClaudeDeltaTypeInputJSONDelta && claudeResp.Delta.PartialJSON != "":
 				s.CurrentToolArgs.WriteString(claudeResp.Delta.PartialJSON)
 				return nil
 			}
 		}
 
-	case "content_block_start":
+	case relaymodel.ClaudeStreamTypeContentBlockStart:
 		if claudeResp.ContentBlock != nil {
-			if claudeResp.ContentBlock.Type == "tool_use" {
+			if claudeResp.ContentBlock.Type == relaymodel.ClaudeContentTypeToolUse {
 				s.CurrentToolName = claudeResp.ContentBlock.Name
 				s.CurrentToolID = claudeResp.ContentBlock.ID
 				s.CurrentToolArgs.Reset()
@@ -358,7 +358,7 @@ func (s *GeminiStreamState) ConvertClaudeStreamToGemini(
 			}
 		}
 
-	case "content_block_stop":
+	case relaymodel.ClaudeStreamTypeContentBlockStop:
 		if s.CurrentToolName != "" {
 			argsStr := s.CurrentToolArgs.String()
 
@@ -381,17 +381,17 @@ func (s *GeminiStreamState) ConvertClaudeStreamToGemini(
 			return nil
 		}
 
-	case "message_delta":
+	case relaymodel.ClaudeStreamTypeMessageDelta:
 		if claudeResp.Delta != nil && claudeResp.Delta.StopReason != nil {
 			switch *claudeResp.Delta.StopReason {
-			case "end_turn":
-				candidate.FinishReason = "STOP"
-			case "max_tokens":
-				candidate.FinishReason = "MAX_TOKENS"
-			case "tool_use":
-				candidate.FinishReason = "STOP"
+			case relaymodel.ClaudeStopReasonEndTurn:
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
+			case relaymodel.ClaudeStopReasonMaxTokens:
+				candidate.FinishReason = relaymodel.GeminiFinishReasonMaxTokens
+			case relaymodel.ClaudeStopReasonToolUse:
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 			default:
-				candidate.FinishReason = "STOP"
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 			}
 		}
 
@@ -403,7 +403,7 @@ func (s *GeminiStreamState) ConvertClaudeStreamToGemini(
 			}
 		}
 
-	case "message_stop":
+	case relaymodel.ClaudeStreamTypeMessageStop:
 		return nil
 	}
 
@@ -424,7 +424,7 @@ func convertGeminiSystemInstruction(
 		for _, part := range geminiReq.SystemInstruction.Parts {
 			if part.Text != "" {
 				system = append(system, relaymodel.ClaudeContent{
-					Type: "text",
+					Type: relaymodel.ClaudeContentTypeText,
 					Text: part.Text,
 				})
 			}
@@ -443,14 +443,14 @@ func convertGeminiContent(
 
 	// Map role
 	if content.Role == "" {
-		content.Role = "user"
+		content.Role = relaymodel.GeminiRoleUser
 	}
 
 	switch content.Role {
-	case "model":
-		msg.Role = "assistant"
-	case "user":
-		msg.Role = "user"
+	case relaymodel.GeminiRoleModel:
+		msg.Role = relaymodel.RoleAssistant
+	case relaymodel.GeminiRoleUser:
+		msg.Role = relaymodel.RoleUser
 	default:
 		msg.Role = content.Role
 	}
@@ -466,14 +466,14 @@ func convertGeminiContent(
 			toolCallMap[part.FunctionCall.Name] = append(toolCallMap[part.FunctionCall.Name], id)
 
 			msg.Content = append(msg.Content, relaymodel.ClaudeContent{
-				Type:  "tool_use",
+				Type:  relaymodel.ClaudeContentTypeToolUse,
 				ID:    id,
 				Name:  part.FunctionCall.Name,
 				Input: part.FunctionCall.Args,
 			})
 		case part.FunctionResponse != nil:
 			// Handle function response - convert to tool result
-			msg.Role = "user"
+			msg.Role = relaymodel.RoleUser
 			content, _ := sonic.MarshalString(part.FunctionResponse.Response)
 
 			// Retrieve the corresponding tool call ID
@@ -486,14 +486,14 @@ func convertGeminiContent(
 
 			if id != "" {
 				msg.Content = append(msg.Content, relaymodel.ClaudeContent{
-					Type:      "tool_result",
+					Type:      relaymodel.ClaudeContentTypeToolResult,
 					ToolUseID: id,
 					Content:   content,
 				})
 			} else {
 				// Orphaned result - convert to text to avoid validation error
 				msg.Content = append(msg.Content, relaymodel.ClaudeContent{
-					Type: "text",
+					Type: relaymodel.ClaudeContentTypeText,
 					Text: fmt.Sprintf("Tool result for %s: %s", part.FunctionResponse.Name, content),
 				})
 			}
@@ -501,13 +501,13 @@ func convertGeminiContent(
 			if part.Thought {
 				// Handle thinking content
 				msg.Content = append(msg.Content, relaymodel.ClaudeContent{
-					Type:     "thinking",
+					Type:     relaymodel.ClaudeContentTypeThinking,
 					Thinking: part.Text,
 				})
 			} else {
 				// Handle text content
 				msg.Content = append(msg.Content, relaymodel.ClaudeContent{
-					Type: "text",
+					Type: relaymodel.ClaudeContentTypeText,
 					Text: part.Text,
 				})
 			}
@@ -522,9 +522,9 @@ func convertGeminiContent(
 			}
 
 			msg.Content = append(msg.Content, relaymodel.ClaudeContent{
-				Type: "image",
+				Type: relaymodel.ClaudeContentTypeImage,
 				Source: &relaymodel.ClaudeImageSource{
-					Type:      "base64",
+					Type:      relaymodel.ClaudeImageSourceTypeBase64,
 					MediaType: part.InlineData.MimeType,
 					Data:      imageData,
 				},
@@ -610,17 +610,17 @@ func convertGeminiToolConfig(geminiReq *relaymodel.GeminiChatRequest) any {
 	}
 
 	switch geminiReq.ToolConfig.FunctionCallingConfig.Mode {
-	case "AUTO":
-		return map[string]any{"type": "auto"}
-	case "ANY":
+	case relaymodel.GeminiFunctionCallingModeAuto:
+		return map[string]any{"type": relaymodel.ToolChoiceAuto}
+	case relaymodel.GeminiFunctionCallingModeAny:
 		if len(geminiReq.ToolConfig.FunctionCallingConfig.AllowedFunctionNames) > 0 {
 			return map[string]any{
-				"type": "tool",
+				"type": relaymodel.ToolChoiceTypeTool,
 				"name": geminiReq.ToolConfig.FunctionCallingConfig.AllowedFunctionNames[0],
 			}
 		}
 
-		return map[string]any{"type": "any"}
+		return map[string]any{"type": relaymodel.ToolChoiceAny}
 	}
 
 	return nil

+ 1 - 1
core/relay/adaptor/anthropic/main.go

@@ -176,7 +176,7 @@ func ConvertImage2Base64(ctx context.Context, node *ast.Node) error {
 
 		err := contentNode.ForEach(func(_ ast.Sequence, contentItem *ast.Node) bool {
 			contentType, err := contentItem.Get("type").String()
-			if err == nil && contentType == conetentTypeImage {
+			if err == nil && contentType == relaymodel.ClaudeContentTypeImage {
 				sourceNode := contentItem.Get("source")
 				if sourceNode != nil {
 					imageType, err := sourceNode.Get("type").String()

+ 22 - 26
core/relay/adaptor/anthropic/openai.go

@@ -25,22 +25,18 @@ import (
 )
 
 const (
-	toolUseType             = "tool_use"
 	serverToolUseType       = "server_tool_use"
 	webSearchToolResult     = "web_search_tool_result"
 	codeExecutionToolResult = "code_execution_tool_result"
-	conetentTypeText        = "text"
-	conetentTypeThinking    = "thinking"
-	conetentTypeImage       = "image"
 )
 
 func stopReasonClaude2OpenAI(reason string) string {
 	switch reason {
-	case "end_turn", "stop_sequence":
+	case relaymodel.ClaudeStopReasonEndTurn, relaymodel.ClaudeStopReasonStopSequence:
 		return relaymodel.FinishReasonStop
-	case "max_tokens":
+	case relaymodel.ClaudeStopReasonMaxTokens:
 		return relaymodel.FinishReasonLength
-	case toolUseType:
+	case relaymodel.ClaudeStopReasonToolUse:
 		return relaymodel.FinishReasonToolCalls
 	case "null":
 		return ""
@@ -145,7 +141,7 @@ func OpenAIConvertRequest(meta *meta.Meta, req *http.Request) (*relaymodel.Claud
 		}{Type: "auto"}
 		if choice, ok := textRequest.ToolChoice.(map[string]any); ok {
 			if function, ok := choice["function"].(map[string]any); ok {
-				claudeToolChoice.Type = "tool"
+				claudeToolChoice.Type = relaymodel.RoleTool
 				name, _ := function["name"].(string)
 				claudeToolChoice.Name = name
 			}
@@ -163,9 +159,9 @@ func OpenAIConvertRequest(meta *meta.Meta, req *http.Request) (*relaymodel.Claud
 	hasToolCalls := false
 
 	for _, message := range textRequest.Messages {
-		if message.Role == "system" {
+		if message.Role == relaymodel.RoleSystem {
 			claudeRequest.System = append(claudeRequest.System, relaymodel.ClaudeContent{
-				Type:         conetentTypeText,
+				Type:         relaymodel.ClaudeContentTypeText,
 				Text:         message.StringContent(),
 				CacheControl: message.CacheControl.ResetTTL(),
 			})
@@ -181,11 +177,11 @@ func OpenAIConvertRequest(meta *meta.Meta, req *http.Request) (*relaymodel.Claud
 
 		content.CacheControl = message.CacheControl.ResetTTL()
 		if message.IsStringContent() {
-			content.Type = conetentTypeText
+			content.Type = relaymodel.ClaudeContentTypeText
 
 			content.Text = message.StringContent()
-			if message.Role == "tool" {
-				claudeMessage.Role = "user"
+			if message.Role == relaymodel.RoleTool {
+				claudeMessage.Role = relaymodel.RoleUser
 				content.Type = "tool_result"
 				content.Content = content.Text
 				content.Text = ""
@@ -193,7 +189,7 @@ func OpenAIConvertRequest(meta *meta.Meta, req *http.Request) (*relaymodel.Claud
 			}
 
 			//nolint:staticcheck
-			if !(message.Role == "assistant" && content.Text == "" && len(message.ToolCalls) > 0) {
+			if !(message.Role == relaymodel.RoleAssistant && content.Text == "" && len(message.ToolCalls) > 0) {
 				claudeMessage.Content = append(claudeMessage.Content, content)
 			}
 		} else {
@@ -201,19 +197,19 @@ func OpenAIConvertRequest(meta *meta.Meta, req *http.Request) (*relaymodel.Claud
 
 			openaiContent := message.ParseContent()
 			for _, part := range openaiContent {
-				if message.Role == "assistant" && part.Text == "" && len(message.ToolCalls) > 0 {
+				if message.Role == relaymodel.RoleAssistant && part.Text == "" && len(message.ToolCalls) > 0 {
 					continue
 				}
 
 				var content relaymodel.ClaudeContent
 				switch part.Type {
 				case relaymodel.ContentTypeText:
-					content.Type = conetentTypeText
+					content.Type = relaymodel.ClaudeContentTypeText
 					content.Text = part.Text
 				case relaymodel.ContentTypeImageURL:
-					content.Type = conetentTypeImage
+					content.Type = relaymodel.ClaudeContentTypeImage
 					content.Source = &relaymodel.ClaudeImageSource{
-						Type: "url",
+						Type: relaymodel.ClaudeImageSourceTypeURL,
 						URL:  part.ImageURL.URL,
 					}
 					imageTasks = append(imageTasks, &content)
@@ -230,7 +226,7 @@ func OpenAIConvertRequest(meta *meta.Meta, req *http.Request) (*relaymodel.Claud
 			inputParam := make(map[string]any)
 			_ = sonic.UnmarshalString(toolCall.Function.Arguments, &inputParam)
 			claudeMessage.Content = append(claudeMessage.Content, relaymodel.ClaudeContent{
-				Type:  toolUseType,
+				Type:  relaymodel.ClaudeContentTypeToolUse,
 				ID:    toolCall.ID,
 				Name:  toolCall.Function.Name,
 				Input: inputParam,
@@ -283,7 +279,7 @@ func batchPatchImage2Base64(ctx context.Context, imageTasks []*relaymodel.Claude
 				return
 			}
 
-			task.Source.Type = "base64"
+			task.Source.Type = relaymodel.ClaudeImageSourceTypeBase64
 			task.Source.URL = ""
 			task.Source.MediaType = mimeType
 			task.Source.Data = data
@@ -370,7 +366,7 @@ func (s *StreamState) StreamResponse2OpenAI(
 	case "content_block_start":
 		if claudeResponse.ContentBlock != nil {
 			content = claudeResponse.ContentBlock.Text
-			if claudeResponse.ContentBlock.Type == toolUseType {
+			if claudeResponse.ContentBlock.Type == relaymodel.ClaudeContentTypeToolUse {
 				toolCallIndex := s.getToolCallIndex(claudeResponse.Index, true)
 				tools = append(tools, relaymodel.ToolCall{
 					Index: toolCallIndex,
@@ -426,7 +422,7 @@ func (s *StreamState) StreamResponse2OpenAI(
 			ReasoningContent: thinking,
 			Signature:        signature,
 			ToolCalls:        tools,
-			Role:             "assistant",
+			Role:             relaymodel.RoleAssistant,
 		},
 		Index:        0,
 		FinishReason: stopReasonClaude2OpenAI(stopReason),
@@ -475,12 +471,12 @@ func Response2OpenAI(
 	tools := make([]relaymodel.ToolCall, 0)
 	for _, v := range claudeResponse.Content {
 		switch v.Type {
-		case conetentTypeText:
+		case relaymodel.ClaudeContentTypeText:
 			content = v.Text
-		case conetentTypeThinking:
+		case relaymodel.ClaudeContentTypeThinking:
 			thinking = v.Thinking
 			signature = v.Signature
-		case toolUseType:
+		case relaymodel.ClaudeContentTypeToolUse:
 			args, _ := sonic.MarshalString(v.Input)
 			tools = append(tools, relaymodel.ToolCall{
 				Index: len(tools),
@@ -500,7 +496,7 @@ func Response2OpenAI(
 	choice := relaymodel.TextResponseChoice{
 		Index: 0,
 		Message: relaymodel.Message{
-			Role:             "assistant",
+			Role:             relaymodel.RoleAssistant,
 			Content:          content,
 			ReasoningContent: thinking,
 			Signature:        signature,

+ 17 - 0
core/relay/adaptor/azure/main.go

@@ -109,6 +109,23 @@ func GetRequestURL(meta *meta.Meta, replaceDot bool) (adaptor.RequestURL, error)
 			URL:    fmt.Sprintf("%s?api-version=%s", url, apiVersion),
 		}, nil
 	case mode.ChatCompletions, mode.Anthropic, mode.Gemini:
+		// Check if model requires Responses API
+		if openai.IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			// Azure Responses API format
+			url, err := url.JoinPath(
+				meta.Channel.BaseURL,
+				"/openai/v1/responses",
+			)
+			if err != nil {
+				return adaptor.RequestURL{}, err
+			}
+
+			return adaptor.RequestURL{
+				Method: http.MethodPost,
+				URL:    fmt.Sprintf("%s?api-version=%s", url, "preview"),
+			}, nil
+		}
+
 		// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
 		url, err := url.JoinPath(
 			meta.Channel.BaseURL,

+ 273 - 0
core/relay/adaptor/azure/main_test.go

@@ -0,0 +1,273 @@
+package azure_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/relay/adaptor/azure"
+	"github.com/labring/aiproxy/core/relay/adaptor/openai"
+	"github.com/labring/aiproxy/core/relay/meta"
+	"github.com/labring/aiproxy/core/relay/mode"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGetRequestURL(t *testing.T) {
+	adaptor := &azure.Adaptor{}
+
+	tests := []struct {
+		name            string
+		model           string
+		mode            mode.Mode
+		apiVersion      string
+		expectedURL     string
+		expectedContain string
+	}{
+		{
+			name:            "gpt-5-codex with ChatCompletions should use Responses API",
+			model:           "gpt-5-codex",
+			mode:            mode.ChatCompletions,
+			apiVersion:      "2024-02-01",
+			expectedContain: "/openai/v1/responses",
+		},
+		{
+			name:            "gpt-4o with ChatCompletions should use standard API",
+			model:           "gpt-4o",
+			mode:            mode.ChatCompletions,
+			apiVersion:      "2024-02-01",
+			expectedContain: "/openai/deployments/gpt-4o/chat/completions",
+		},
+		{
+			name:            "gpt-35-turbo with ChatCompletions should use standard API",
+			model:           "gpt-35-turbo",
+			mode:            mode.ChatCompletions,
+			apiVersion:      "2024-02-01",
+			expectedContain: "/openai/deployments/gpt-35-turbo/chat/completions",
+		},
+		{
+			name:            "gpt-5-codex with Anthropic mode should use Responses API",
+			model:           "gpt-5-codex",
+			mode:            mode.Anthropic,
+			apiVersion:      "2024-02-01",
+			expectedContain: "/openai/v1/responses",
+		},
+		{
+			name:            "gpt-5-codex with Gemini mode should use Responses API",
+			model:           "gpt-5-codex",
+			mode:            mode.Gemini,
+			apiVersion:      "2024-02-01",
+			expectedContain: "/openai/v1/responses",
+		},
+		{
+			name:            "gpt-5-codex Responses API should use preview version",
+			model:           "gpt-5-codex",
+			mode:            mode.ChatCompletions,
+			apiVersion:      "2024-02-01",
+			expectedContain: "api-version=preview",
+		},
+		{
+			name:            "gpt-4o should use provided api-version",
+			model:           "gpt-4o",
+			mode:            mode.ChatCompletions,
+			apiVersion:      "2024-02-01",
+			expectedContain: "api-version=2024-02-01",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := &meta.Meta{
+				ActualModel: tt.model,
+				Mode:        tt.mode,
+			}
+			m.Channel.BaseURL = "https://test.openai.azure.com"
+			m.Channel.Key = "test-key|" + tt.apiVersion
+
+			result, err := adaptor.GetRequestURL(m, nil, nil)
+			require.NoError(t, err)
+
+			assert.Contains(t, result.URL, tt.expectedContain,
+				"URL should contain expected pattern for model %s with mode %s", tt.model, tt.mode)
+
+			// Verify it's a POST request for all these modes
+			assert.Equal(t, "POST", result.Method)
+		})
+	}
+}
+
+func TestGetRequestURL_ResponsesOnlyModels(t *testing.T) {
+	adaptor := &azure.Adaptor{}
+
+	// Test that responses-only models always use the Responses API endpoint
+	responsesOnlyModels := []string{"gpt-5-codex", "gpt-5-pro"}
+	modes := []mode.Mode{mode.ChatCompletions, mode.Anthropic, mode.Gemini}
+
+	for _, model := range responsesOnlyModels {
+		for _, m := range modes {
+			testName := model + "_mode_" + m.String()
+			t.Run(testName, func(t *testing.T) {
+				meta := &meta.Meta{
+					ActualModel: model,
+					Mode:        m,
+				}
+				meta.Channel.BaseURL = "https://test.openai.azure.com"
+				meta.Channel.Key = "test-key|2024-02-01"
+
+				result, err := adaptor.GetRequestURL(meta, nil, nil)
+				require.NoError(t, err)
+
+				// Should use Responses API endpoint
+				assert.Contains(t, result.URL, "/openai/v1/responses")
+				// Should use preview API version
+				assert.Contains(t, result.URL, "api-version=preview")
+				// Should be POST
+				assert.Equal(t, "POST", result.Method)
+			})
+		}
+	}
+}
+
+func TestGetRequestURL_StandardModels(t *testing.T) {
+	adaptor := &azure.Adaptor{}
+
+	// Test that standard models use the regular deployment endpoint
+	standardModels := []string{"gpt-4o", "gpt-35-turbo", "gpt-4"}
+
+	for _, model := range standardModels {
+		t.Run(model, func(t *testing.T) {
+			meta := &meta.Meta{
+				ActualModel: model,
+				Mode:        mode.ChatCompletions,
+			}
+			meta.Channel.BaseURL = "https://test.openai.azure.com"
+			meta.Channel.Key = "test-key|2024-02-01"
+
+			result, err := adaptor.GetRequestURL(meta, nil, nil)
+			require.NoError(t, err)
+
+			// Should use deployment endpoint
+			assert.Contains(t, result.URL, "/openai/deployments/"+model)
+			// Should NOT use Responses API
+			assert.NotContains(t, result.URL, "/openai/v1/responses")
+			// Should use provided API version
+			assert.Contains(t, result.URL, "api-version=2024-02-01")
+		})
+	}
+}
+
+func TestGetRequestURL_DotReplacement(t *testing.T) {
+	adaptor := &azure.Adaptor{}
+
+	tests := []struct {
+		name          string
+		model         string
+		expectedModel string
+	}{
+		{
+			name:          "gpt-3.5-turbo should have dots removed",
+			model:         "gpt-3.5-turbo",
+			expectedModel: "gpt-35-turbo",
+		},
+		{
+			name:          "gpt-4.0 should have dots removed",
+			model:         "gpt-4.0",
+			expectedModel: "gpt-40",
+		},
+		{
+			name:          "model without dots should remain unchanged",
+			model:         "gpt-4o",
+			expectedModel: "gpt-4o",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			meta := &meta.Meta{
+				ActualModel: tt.model,
+				Mode:        mode.ChatCompletions,
+			}
+			meta.Channel.BaseURL = "https://test.openai.azure.com"
+			meta.Channel.Key = "test-key|2024-02-01"
+
+			result, err := adaptor.GetRequestURL(meta, nil, nil)
+			require.NoError(t, err)
+
+			// For standard models (not responses-only), check dot replacement
+			if !openai.IsResponsesOnlyModel(&meta.ModelConfig, tt.model) {
+				assert.Contains(t, result.URL, "/openai/deployments/"+tt.expectedModel)
+			}
+		})
+	}
+}
+
+func TestGetRequestURL_OtherModes(t *testing.T) {
+	adaptor := &azure.Adaptor{}
+
+	tests := []struct {
+		name            string
+		mode            mode.Mode
+		expectedContain string
+	}{
+		{
+			name:            "Completions mode",
+			mode:            mode.Completions,
+			expectedContain: "/completions",
+		},
+		{
+			name:            "Embeddings mode",
+			mode:            mode.Embeddings,
+			expectedContain: "/embeddings",
+		},
+		{
+			name:            "ImagesGenerations mode",
+			mode:            mode.ImagesGenerations,
+			expectedContain: "/images/generations",
+		},
+		{
+			name:            "AudioTranscription mode",
+			mode:            mode.AudioTranscription,
+			expectedContain: "/audio/transcriptions",
+		},
+		{
+			name:            "AudioSpeech mode",
+			mode:            mode.AudioSpeech,
+			expectedContain: "/audio/speech",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			meta := &meta.Meta{
+				ActualModel: "test-model",
+				Mode:        tt.mode,
+			}
+			meta.Channel.BaseURL = "https://test.openai.azure.com"
+			meta.Channel.Key = "test-key|2024-02-01"
+
+			result, err := adaptor.GetRequestURL(meta, nil, nil)
+			require.NoError(t, err)
+
+			assert.Contains(t, result.URL, tt.expectedContain)
+		})
+	}
+}
+
+func TestGetRequestURL_ResponsesModeDirect(t *testing.T) {
+	adaptor := &azure.Adaptor{}
+
+	// Test direct Responses mode (not converted from another mode)
+	meta := &meta.Meta{
+		ActualModel: "gpt-4o",
+		Mode:        mode.Responses,
+	}
+	meta.Channel.BaseURL = "https://test.openai.azure.com"
+	meta.Channel.Key = "test-key|2024-02-01"
+
+	result, err := adaptor.GetRequestURL(meta, nil, nil)
+	require.NoError(t, err)
+
+	// Should use Responses API endpoint
+	assert.Contains(t, result.URL, "/openai/v1/responses")
+	// Should use preview API version for Responses mode
+	assert.Contains(t, result.URL, "api-version=preview")
+	assert.Equal(t, "POST", result.Method)
+}

+ 3 - 3
core/relay/adaptor/coze/main.go

@@ -28,11 +28,11 @@ func stopReasonCoze2OpenAI(reason *string) relaymodel.FinishReason {
 	}
 
 	switch *reason {
-	case "end_turn":
+	case relaymodel.ClaudeStopReasonEndTurn:
 		return relaymodel.FinishReasonLength
-	case "stop_sequence":
+	case relaymodel.ClaudeStopReasonStopSequence:
 		return relaymodel.FinishReasonStop
-	case "max_tokens":
+	case relaymodel.ClaudeStopReasonMaxTokens:
 		return relaymodel.FinishReasonLength
 	default:
 		return *reason

+ 34 - 34
core/relay/adaptor/gemini/claude.go

@@ -157,7 +157,7 @@ func ClaudeStreamHandler(
 	closeCurrentBlock := func() {
 		if currentContentIndex >= 0 {
 			_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-				Type:  "content_block_stop",
+				Type:  relaymodel.ClaudeStreamTypeContentBlockStop,
 				Index: currentContentIndex,
 			})
 		}
@@ -187,11 +187,11 @@ func ClaudeStreamHandler(
 			sentMessageStart = true
 
 			messageStartResp := relaymodel.ClaudeStreamResponse{
-				Type: "message_start",
+				Type: relaymodel.ClaudeStreamTypeMessageStart,
 				Message: &relaymodel.ClaudeResponse{
 					ID:      messageID,
 					Type:    "message",
-					Role:    "assistant",
+					Role:    relaymodel.RoleAssistant,
 					Model:   meta.ActualModel,
 					Content: []relaymodel.ClaudeContent{},
 				},
@@ -226,24 +226,24 @@ func ClaudeStreamHandler(
 				switch {
 				case part.Thought:
 					// Handle thinking content
-					if currentContentType != "thinking" {
+					if currentContentType != relaymodel.ClaudeContentTypeThinking {
 						closeCurrentBlock()
 
 						currentContentIndex++
-						currentContentType = "thinking"
+						currentContentType = relaymodel.ClaudeContentTypeThinking
 
 						_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-							Type:  "content_block_start",
+							Type:  relaymodel.ClaudeStreamTypeContentBlockStart,
 							Index: currentContentIndex,
 							ContentBlock: &relaymodel.ClaudeContent{
-								Type:     "thinking",
+								Type:     relaymodel.ClaudeContentTypeThinking,
 								Thinking: "",
 							},
 						})
 
 						if part.ThoughtSignature != "" {
 							_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-								Type:  "content_block_delta",
+								Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
 								Index: currentContentIndex,
 								ContentBlock: &relaymodel.ClaudeContent{
 									Type:      "signature_delta",
@@ -256,7 +256,7 @@ func ClaudeStreamHandler(
 					thinkingText.WriteString(part.Text)
 
 					_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-						Type:  "content_block_delta",
+						Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
 						Index: currentContentIndex,
 						Delta: &relaymodel.ClaudeDelta{
 							Type:     "thinking_delta",
@@ -265,17 +265,17 @@ func ClaudeStreamHandler(
 					})
 				case part.Text != "":
 					// Handle text content
-					if currentContentType != "text" {
+					if currentContentType != relaymodel.ClaudeContentTypeText {
 						closeCurrentBlock()
 
 						currentContentIndex++
-						currentContentType = "text"
+						currentContentType = relaymodel.ClaudeContentTypeText
 
 						_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-							Type:  "content_block_start",
+							Type:  relaymodel.ClaudeStreamTypeContentBlockStart,
 							Index: currentContentIndex,
 							ContentBlock: &relaymodel.ClaudeContent{
-								Type: "text",
+								Type: relaymodel.ClaudeContentTypeText,
 								Text: "",
 							},
 						})
@@ -284,7 +284,7 @@ func ClaudeStreamHandler(
 					contentText.WriteString(part.Text)
 
 					_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-						Type:  "content_block_delta",
+						Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
 						Index: currentContentIndex,
 						Delta: &relaymodel.ClaudeDelta{
 							Type: "text_delta",
@@ -296,10 +296,10 @@ func ClaudeStreamHandler(
 					closeCurrentBlock()
 
 					currentContentIndex++
-					currentContentType = "tool_use"
+					currentContentType = relaymodel.ClaudeContentTypeToolUse
 
 					toolContent := &relaymodel.ClaudeContent{
-						Type:      "tool_use",
+						Type:      relaymodel.ClaudeContentTypeToolUse,
 						ID:        openai.CallID(),
 						Name:      part.FunctionCall.Name,
 						Input:     part.FunctionCall.Args,
@@ -309,7 +309,7 @@ func ClaudeStreamHandler(
 
 					// Send content_block_start for tool use
 					_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-						Type:         "content_block_start",
+						Type:         relaymodel.ClaudeStreamTypeContentBlockStart,
 						Index:        currentContentIndex,
 						ContentBlock: toolContent,
 					})
@@ -317,7 +317,7 @@ func ClaudeStreamHandler(
 					// Send tool arguments as delta
 					args, _ := sonic.MarshalString(part.FunctionCall.Args)
 					_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-						Type:  "content_block_delta",
+						Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
 						Index: currentContentIndex,
 						Delta: &relaymodel.ClaudeDelta{
 							Type:        "input_json_delta",
@@ -353,12 +353,12 @@ func ClaudeStreamHandler(
 	claudeUsage := usage.ToClaudeUsage()
 
 	if stopReason == "" {
-		stopReason = "end_turn"
+		stopReason = relaymodel.ClaudeStopReasonEndTurn
 	}
 
 	// Send message_delta with final usage
 	_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-		Type: "message_delta",
+		Type: relaymodel.ClaudeStreamTypeMessageDelta,
 		Delta: &relaymodel.ClaudeDelta{
 			StopReason: &stopReason,
 		},
@@ -381,7 +381,7 @@ func geminiResponse2Claude(
 	claudeResponse := relaymodel.ClaudeResponse{
 		ID:           "msg_" + common.ShortUUID(),
 		Type:         "message",
-		Role:         "assistant",
+		Role:         relaymodel.RoleAssistant,
 		Model:        meta.OriginModel,
 		Content:      []relaymodel.ClaudeContent{},
 		StopReason:   "",
@@ -404,7 +404,7 @@ func geminiResponse2Claude(
 			if part.FunctionCall != nil {
 				// Convert function call to tool use
 				claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-					Type:      "tool_use",
+					Type:      relaymodel.ClaudeContentTypeToolUse,
 					ID:        openai.CallID(),
 					Name:      part.FunctionCall.Name,
 					Input:     part.FunctionCall.Args,
@@ -414,14 +414,14 @@ func geminiResponse2Claude(
 				if part.Thought {
 					// Add thinking content
 					claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-						Type:      "thinking",
+						Type:      relaymodel.ClaudeContentTypeThinking,
 						Thinking:  part.Text,
 						Signature: part.ThoughtSignature,
 					})
 				} else {
 					// Add text content
 					claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-						Type: "text",
+						Type: relaymodel.ClaudeContentTypeText,
 						Text: part.Text,
 					})
 				}
@@ -434,7 +434,7 @@ func geminiResponse2Claude(
 	// indicating it has nothing more to add beyond the tool's response
 	if len(claudeResponse.Content) == 0 {
 		claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-			Type: "text",
+			Type: relaymodel.ClaudeContentTypeText,
 			Text: "",
 		})
 	}
@@ -445,15 +445,15 @@ func geminiResponse2Claude(
 // geminiFinishReason2Claude converts Gemini finish reason to Claude stop reason
 func geminiFinishReason2Claude(reason string) string {
 	switch reason {
-	case "STOP":
-		return "end_turn"
-	case "MAX_TOKENS":
-		return "max_tokens"
-	case "TOOL_CALLS", "FUNCTION_CALL":
-		return "tool_use"
-	case "CONTENT_FILTER":
-		return "stop_sequence"
+	case relaymodel.GeminiFinishReasonStop:
+		return relaymodel.ClaudeStopReasonEndTurn
+	case relaymodel.GeminiFinishReasonMaxTokens:
+		return relaymodel.ClaudeStopReasonMaxTokens
+	case relaymodel.GeminiFinishReasonToolCalls, relaymodel.GeminiFinishReasonFunctionCall:
+		return relaymodel.ClaudeStopReasonToolUse
+	case relaymodel.GeminiFinishReasonSafety:
+		return relaymodel.ClaudeStopReasonStopSequence
 	default:
-		return "end_turn"
+		return relaymodel.ClaudeStopReasonEndTurn
 	}
 }

+ 5 - 1
core/relay/adaptor/gemini/claude_test.go

@@ -119,7 +119,11 @@ func TestClaudeHandler(t *testing.T) {
 			convey.So(len(claudeResponse.Content), convey.ShouldEqual, 1)
 
 			// Check tool use block
-			convey.So(claudeResponse.Content[0].Type, convey.ShouldEqual, "tool_use")
+			convey.So(
+				claudeResponse.Content[0].Type,
+				convey.ShouldEqual,
+				relaymodel.ClaudeContentTypeToolUse,
+			)
 			convey.So(claudeResponse.Content[0].Name, convey.ShouldEqual, "get_weather")
 			convey.So(claudeResponse.Content[0].Signature, convey.ShouldEqual, "tool_signature_456")
 		})

+ 20 - 20
core/relay/adaptor/gemini/openai.go

@@ -29,9 +29,9 @@ import (
 )
 
 var toolChoiceTypeMap = map[string]string{
-	"none":     "NONE",
-	"auto":     "AUTO",
-	"required": "ANY",
+	relaymodel.ToolChoiceNone:     relaymodel.GeminiFunctionCallingModeNone,
+	relaymodel.ToolChoiceAuto:     relaymodel.GeminiFunctionCallingModeAuto,
+	relaymodel.ToolChoiceRequired: relaymodel.GeminiFunctionCallingModeAny,
 }
 
 var mimeTypeMap = map[string]string{
@@ -46,15 +46,15 @@ type CountTokensResponse struct {
 
 func buildSafetySettings(safetySetting string) []relaymodel.GeminiChatSafetySettings {
 	if safetySetting == "" {
-		safetySetting = "BLOCK_NONE"
+		safetySetting = relaymodel.GeminiSafetyThresholdBlockNone
 	}
 
 	return []relaymodel.GeminiChatSafetySettings{
-		{Category: "HARM_CATEGORY_HARASSMENT", Threshold: safetySetting},
-		{Category: "HARM_CATEGORY_HATE_SPEECH", Threshold: safetySetting},
-		{Category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", Threshold: safetySetting},
-		{Category: "HARM_CATEGORY_DANGEROUS_CONTENT", Threshold: safetySetting},
-		{Category: "HARM_CATEGORY_CIVIC_INTEGRITY", Threshold: safetySetting},
+		{Category: relaymodel.GeminiSafetyCategoryHarassment, Threshold: safetySetting},
+		{Category: relaymodel.GeminiSafetyCategoryHateSpeech, Threshold: safetySetting},
+		{Category: relaymodel.GeminiSafetyCategorySexuallyExplicit, Threshold: safetySetting},
+		{Category: relaymodel.GeminiSafetyCategoryDangerousContent, Threshold: safetySetting},
+		{Category: relaymodel.GeminiSafetyCategoryCivicIntegrity, Threshold: safetySetting},
 	}
 }
 
@@ -208,7 +208,7 @@ func buildToolConfig(textRequest *relaymodel.GeneralOpenAIRequest) *relaymodel.G
 
 	toolConfig := relaymodel.GeminiToolConfig{
 		FunctionCallingConfig: relaymodel.GeminiFunctionCallingConfig{
-			Mode: "auto",
+			Mode: relaymodel.GeminiFunctionCallingModeAuto,
 		},
 	}
 	switch mode := textRequest.ToolChoice.(type) {
@@ -217,7 +217,7 @@ func buildToolConfig(textRequest *relaymodel.GeneralOpenAIRequest) *relaymodel.G
 			toolConfig.FunctionCallingConfig.Mode = toolChoiceType
 		}
 	case map[string]any:
-		toolConfig.FunctionCallingConfig.Mode = "ANY"
+		toolConfig.FunctionCallingConfig.Mode = relaymodel.GeminiFunctionCallingModeAny
 		if fn, ok := mode["function"].(map[string]any); ok {
 			if fnName, ok := fn["name"].(string); ok {
 				toolConfig.FunctionCallingConfig.AllowedFunctionNames = []string{fnName}
@@ -262,7 +262,7 @@ func buildContents(
 
 		// Track tool calls from assistant messages
 		switch {
-		case message.Role == "assistant" && len(message.ToolCalls) > 0:
+		case message.Role == relaymodel.RoleAssistant && len(message.ToolCalls) > 0:
 			for _, toolCall := range message.ToolCalls {
 				toolCallMap[toolCall.ID] = toolCall.Function.Name
 
@@ -332,9 +332,9 @@ func buildContents(
 					},
 				},
 			})
-		case message.Role == "system":
+		case message.Role == relaymodel.RoleSystem:
 			systemContent = &relaymodel.GeminiChatContent{
-				Role: "user", // Gemini uses "user" for system content
+				Role: relaymodel.RoleUser, // Gemini uses "user" for system content
 				Parts: []*relaymodel.GeminiPart{{
 					Text: message.StringContent(),
 				}},
@@ -364,10 +364,10 @@ func buildContents(
 
 		// Adjust role for Gemini
 		switch content.Role {
-		case "assistant":
-			content.Role = "model"
+		case relaymodel.RoleAssistant:
+			content.Role = relaymodel.GeminiRoleModel
 		case "tool":
-			content.Role = "user"
+			content.Role = relaymodel.GeminiRoleUser
 		}
 
 		if len(content.Parts) > 0 {
@@ -495,8 +495,8 @@ func ConvertRequest(meta *meta.Meta, req *http.Request) (adaptor.ConvertResult,
 
 // Type aliases for usage-related types to use unified definitions from relaymodel
 var finishReason2OpenAI = map[string]string{
-	"STOP":       relaymodel.FinishReasonStop,
-	"MAX_TOKENS": relaymodel.FinishReasonLength,
+	relaymodel.GeminiFinishReasonStop:      relaymodel.FinishReasonStop,
+	relaymodel.GeminiFinishReasonMaxTokens: relaymodel.FinishReasonLength,
 }
 
 func FinishReason2OpenAI(reason string) string {
@@ -556,7 +556,7 @@ func responseChat2OpenAI(
 		choice := relaymodel.TextResponseChoice{
 			Index: i,
 			Message: relaymodel.Message{
-				Role: "assistant",
+				Role: relaymodel.RoleAssistant,
 			},
 			FinishReason: FinishReason2OpenAI(candidate.FinishReason),
 		}

+ 64 - 10
core/relay/adaptor/openai/adaptor.go

@@ -49,7 +49,6 @@ func (a *Adaptor) SupportMode(m mode.Mode) bool {
 		m == mode.ResponsesInputItems
 }
 
-//
 //nolint:gocyclo
 func (a *Adaptor) GetRequestURL(
 	meta *meta.Meta,
@@ -110,6 +109,19 @@ func (a *Adaptor) GetRequestURL(
 			URL:    url,
 		}, nil
 	case mode.ChatCompletions, mode.Anthropic, mode.Gemini:
+		// Check if model requires Responses API
+		if IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			url, err := url.JoinPath(u, "/responses")
+			if err != nil {
+				return adaptor.RequestURL{}, err
+			}
+
+			return adaptor.RequestURL{
+				Method: http.MethodPost,
+				URL:    url,
+			}, nil
+		}
+
 		url, err := url.JoinPath(u, "/chat/completions")
 		if err != nil {
 			return adaptor.RequestURL{}, err
@@ -284,8 +296,16 @@ func ConvertRequest(
 	case mode.Completions:
 		return ConvertCompletionsRequest(meta, req)
 	case mode.ChatCompletions:
+		// Check if model requires Responses API conversion
+		if IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			return ConvertChatCompletionToResponsesRequest(meta, req)
+		}
 		return ConvertChatCompletionsRequest(meta, req, false)
 	case mode.Anthropic:
+		// Check if model requires Responses API conversion
+		if IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			return ConvertClaudeToResponsesRequest(meta, req)
+		}
 		return ConvertClaudeRequest(meta, req)
 	case mode.ImagesGenerations:
 		return ConvertImagesRequest(meta, req)
@@ -304,6 +324,10 @@ func ConvertRequest(
 	case mode.VideoGenerationsContent:
 		return ConvertVideoGetJobsContentRequest(meta, req)
 	case mode.Gemini:
+		// Check if model requires Responses API conversion
+		if IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			return ConvertGeminiToResponsesRequest(meta, req)
+		}
 		return ConvertGeminiRequest(meta, req)
 	default:
 		return adaptor.ConvertResult{}, fmt.Errorf("unsupported mode: %s", meta.Mode)
@@ -344,16 +368,36 @@ func DoResponse(
 	case mode.Embeddings:
 		usage, err = EmbeddingsHandler(meta, c, resp, nil)
 	case mode.Completions, mode.ChatCompletions:
-		if utils.IsStreamResponse(resp) {
-			usage, err = StreamHandler(meta, c, resp, nil)
+		// Check if model required Responses API conversion
+		if IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			// Convert Responses API response back to ChatCompletion format
+			if utils.IsStreamResponse(resp) {
+				usage, err = ConvertResponsesToChatCompletionStreamResponse(meta, c, resp)
+			} else {
+				usage, err = ConvertResponsesToChatCompletionResponse(meta, c, resp)
+			}
 		} else {
-			usage, err = Handler(meta, c, resp, nil)
+			if utils.IsStreamResponse(resp) {
+				usage, err = StreamHandler(meta, c, resp, nil)
+			} else {
+				usage, err = Handler(meta, c, resp, nil)
+			}
 		}
 	case mode.Anthropic:
-		if utils.IsStreamResponse(resp) {
-			usage, err = ClaudeStreamHandler(meta, c, resp)
+		// Check if model required Responses API conversion
+		if IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			// Convert Responses API response back to Claude format
+			if utils.IsStreamResponse(resp) {
+				usage, err = ConvertResponsesToClaudeStreamResponse(meta, c, resp)
+			} else {
+				usage, err = ConvertResponsesToClaudeResponse(meta, c, resp)
+			}
 		} else {
-			usage, err = ClaudeHandler(meta, c, resp)
+			if utils.IsStreamResponse(resp) {
+				usage, err = ClaudeStreamHandler(meta, c, resp)
+			} else {
+				usage, err = ClaudeHandler(meta, c, resp)
+			}
 		}
 	case mode.VideoGenerationsJobs:
 		usage, err = VideoHandler(meta, store, c, resp)
@@ -362,10 +406,20 @@ func DoResponse(
 	case mode.VideoGenerationsContent:
 		usage, err = VideoGetJobsContentHandler(meta, store, c, resp)
 	case mode.Gemini:
-		if utils.IsStreamResponse(resp) {
-			usage, err = GeminiStreamHandler(meta, c, resp)
+		// Check if model required Responses API conversion
+		if IsResponsesOnlyModel(&meta.ModelConfig, meta.ActualModel) {
+			// Convert Responses API response back to Gemini format
+			if utils.IsStreamResponse(resp) {
+				usage, err = ConvertResponsesToGeminiStreamResponse(meta, c, resp)
+			} else {
+				usage, err = ConvertResponsesToGeminiResponse(meta, c, resp)
+			}
 		} else {
-			usage, err = GeminiHandler(meta, c, resp)
+			if utils.IsStreamResponse(resp) {
+				usage, err = GeminiStreamHandler(meta, c, resp)
+			} else {
+				usage, err = GeminiHandler(meta, c, resp)
+			}
 		}
 	default:
 		return model.Usage{}, relaymodel.WrapperOpenAIErrorWithMessage(

+ 739 - 0
core/relay/adaptor/openai/chat.go

@@ -21,6 +21,244 @@ import (
 	"github.com/labring/aiproxy/core/relay/utils"
 )
 
+// chatCompletionStreamState manages state for ChatCompletion stream conversion
+type chatCompletionStreamState struct {
+	messageID         string
+	meta              *meta.Meta
+	c                 *gin.Context
+	currentToolCall   *relaymodel.ToolCall
+	currentToolCallID string
+	toolCallArgs      string
+}
+
+// handleResponseCreated handles response.created event for ChatCompletion
+func (s *chatCompletionStreamState) handleResponseCreated(
+	event *relaymodel.ResponseStreamEvent,
+) *relaymodel.ChatCompletionsStreamResponse {
+	if event.Response == nil {
+		return nil
+	}
+
+	s.messageID = event.Response.ID
+
+	return &relaymodel.ChatCompletionsStreamResponse{
+		ID:      s.messageID,
+		Object:  relaymodel.ChatCompletionChunkObject,
+		Created: event.Response.CreatedAt,
+		Model:   event.Response.Model,
+		Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
+			{
+				Index: 0,
+				Delta: relaymodel.Message{
+					Role: relaymodel.RoleAssistant,
+				},
+			},
+		},
+	}
+}
+
+// handleOutputTextDelta handles response.output_text.delta event for ChatCompletion
+func (s *chatCompletionStreamState) handleOutputTextDelta(
+	event *relaymodel.ResponseStreamEvent,
+) *relaymodel.ChatCompletionsStreamResponse {
+	if event.Delta == "" {
+		return nil
+	}
+
+	return &relaymodel.ChatCompletionsStreamResponse{
+		ID:      s.messageID,
+		Object:  relaymodel.ChatCompletionChunkObject,
+		Created: time.Now().Unix(),
+		Model:   s.meta.ActualModel,
+		Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
+			{
+				Index: 0,
+				Delta: relaymodel.Message{
+					Content: event.Delta,
+				},
+			},
+		},
+	}
+}
+
+// handleOutputItemAdded handles response.output_item.added event for ChatCompletion
+func (s *chatCompletionStreamState) handleOutputItemAdded(
+	event *relaymodel.ResponseStreamEvent,
+) *relaymodel.ChatCompletionsStreamResponse {
+	if event.Item == nil {
+		return nil
+	}
+
+	// Track function calls
+	if event.Item.Type == relaymodel.InputItemTypeFunctionCall {
+		s.currentToolCallID = event.Item.ID
+		s.currentToolCall = &relaymodel.ToolCall{
+			ID:   event.Item.CallID,
+			Type: relaymodel.ToolChoiceTypeFunction,
+			Function: relaymodel.Function{
+				Name:      event.Item.Name,
+				Arguments: "",
+			},
+		}
+		s.toolCallArgs = ""
+
+		// Send tool call start
+		return &relaymodel.ChatCompletionsStreamResponse{
+			ID:      s.messageID,
+			Object:  relaymodel.ChatCompletionChunkObject,
+			Created: time.Now().Unix(),
+			Model:   s.meta.ActualModel,
+			Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
+				{
+					Index: 0,
+					Delta: relaymodel.Message{
+						ToolCalls: []relaymodel.ToolCall{
+							{
+								Index: 0,
+								ID:    event.Item.CallID,
+								Type:  relaymodel.ToolChoiceTypeFunction,
+								Function: relaymodel.Function{
+									Name:      event.Item.Name,
+									Arguments: "",
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+	}
+
+	if event.Item.Type == relaymodel.InputItemTypeMessage {
+		return &relaymodel.ChatCompletionsStreamResponse{
+			ID:      s.messageID,
+			Object:  relaymodel.ChatCompletionChunkObject,
+			Created: time.Now().Unix(),
+			Model:   s.meta.ActualModel,
+			Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
+				{
+					Index: 0,
+					Delta: relaymodel.Message{
+						Role: relaymodel.RoleAssistant,
+					},
+				},
+			},
+		}
+	}
+
+	return nil
+}
+
+// handleFunctionCallArgumentsDelta handles response.function_call_arguments.delta event for ChatCompletion
+func (s *chatCompletionStreamState) handleFunctionCallArgumentsDelta(
+	event *relaymodel.ResponseStreamEvent,
+) *relaymodel.ChatCompletionsStreamResponse {
+	if event.Delta == "" || s.currentToolCall == nil {
+		return nil
+	}
+
+	// Accumulate arguments
+	s.toolCallArgs += event.Delta
+
+	// Send delta
+	return &relaymodel.ChatCompletionsStreamResponse{
+		ID:      s.messageID,
+		Object:  relaymodel.ChatCompletionChunkObject,
+		Created: time.Now().Unix(),
+		Model:   s.meta.ActualModel,
+		Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
+			{
+				Index: 0,
+				Delta: relaymodel.Message{
+					ToolCalls: []relaymodel.ToolCall{
+						{
+							Index: 0,
+							Function: relaymodel.Function{
+								Arguments: event.Delta,
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+// handleOutputItemDone handles response.output_item.done event for ChatCompletion
+func (s *chatCompletionStreamState) handleOutputItemDone(
+	event *relaymodel.ResponseStreamEvent,
+) *relaymodel.ChatCompletionsStreamResponse {
+	if event.Item == nil {
+		return nil
+	}
+
+	// Handle function call completion
+	if event.Item.Type == relaymodel.InputItemTypeFunctionCall && s.currentToolCall != nil &&
+		event.Item.ID == s.currentToolCallID {
+		// Update with final arguments
+		if s.toolCallArgs != "" {
+			s.currentToolCall.Function.Arguments = s.toolCallArgs
+		}
+
+		// Reset state
+		s.currentToolCall = nil
+		s.currentToolCallID = ""
+		s.toolCallArgs = ""
+
+		// No need to send another chunk - arguments already streamed
+		return nil
+	}
+
+	// Handle message content
+	if len(event.Item.Content) > 0 {
+		for _, content := range event.Item.Content {
+			if (content.Type == "text" || content.Type == "output_text") && content.Text != "" {
+				return &relaymodel.ChatCompletionsStreamResponse{
+					ID:      s.messageID,
+					Object:  relaymodel.ChatCompletionChunkObject,
+					Created: time.Now().Unix(),
+					Model:   s.meta.ActualModel,
+					Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
+						{
+							Index: 0,
+							Delta: relaymodel.Message{
+								Content: content.Text,
+							},
+						},
+					},
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+// handleResponseCompleted handles response.completed/done event for ChatCompletion
+func (s *chatCompletionStreamState) handleResponseCompleted(
+	event *relaymodel.ResponseStreamEvent,
+) *relaymodel.ChatCompletionsStreamResponse {
+	if event.Response == nil || event.Response.Usage == nil {
+		return nil
+	}
+
+	chatUsage := event.Response.Usage.ToChatUsage()
+
+	return &relaymodel.ChatCompletionsStreamResponse{
+		ID:      s.messageID,
+		Object:  relaymodel.ChatCompletionChunkObject,
+		Created: time.Now().Unix(),
+		Model:   s.meta.ActualModel,
+		Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{
+			{
+				Index:        0,
+				FinishReason: relaymodel.FinishReasonStop,
+			},
+		},
+		Usage: &chatUsage,
+	}
+}
+
 func ConvertCompletionsRequest(
 	meta *meta.Meta,
 	req *http.Request,
@@ -71,6 +309,12 @@ func ConvertChatCompletionsRequest(
 		return adaptor.ConvertResult{}, err
 	}
 
+	// Clean tool parameters (remove null/empty required fields)
+	// This should be done before other callbacks to ensure consistency
+	if err := CleanToolParametersFromNode(&node); err != nil {
+		return adaptor.ConvertResult{}, err
+	}
+
 	for _, callback := range callback {
 		if callback == nil {
 			continue
@@ -440,3 +684,498 @@ func Handler(
 
 	return usage.ToModelUsage(), nil
 }
+
+// CleanToolParameters removes null or empty required field from tool parameters
+// Responses API requires the 'required' field to be either:
+// - A non-empty array of strings
+// - Completely absent from the schema
+// It cannot be null or an empty array
+func CleanToolParameters(parameters any) any {
+	if params, ok := parameters.(map[string]any); ok {
+		if required, hasRequired := params["required"]; hasRequired {
+			// Remove if null or empty array
+			if required == nil {
+				delete(params, "required")
+			} else if reqArray, ok := required.([]any); ok && len(reqArray) == 0 {
+				delete(params, "required")
+			}
+		}
+
+		return params
+	}
+
+	return parameters
+}
+
+// CleanToolParametersFromNode cleans tool parameters in an AST node
+// It removes null or empty required fields from tool function parameters
+func CleanToolParametersFromNode(node *ast.Node) error {
+	toolsNode := node.Get("tools")
+	if !toolsNode.Exists() || toolsNode.TypeSafe() == ast.V_NULL {
+		return nil
+	}
+
+	if toolsNode.TypeSafe() != ast.V_ARRAY {
+		return nil
+	}
+
+	// Iterate through each tool using ForEach
+	err := toolsNode.ForEach(func(path ast.Sequence, toolNode *ast.Node) bool {
+		// Get function node
+		functionNode := toolNode.Get("function")
+		if !functionNode.Exists() {
+			return true // Continue to next tool
+		}
+
+		// Get parameters node
+		parametersNode := functionNode.Get("parameters")
+		if !parametersNode.Exists() || parametersNode.TypeSafe() == ast.V_NULL {
+			return true // Continue to next tool
+		}
+
+		// Get required node
+		requiredNode := parametersNode.Get("required")
+		if !requiredNode.Exists() {
+			return true // Continue to next tool
+		}
+
+		// Check if required is null or empty array
+		shouldRemove := false
+		if requiredNode.TypeSafe() == ast.V_NULL {
+			shouldRemove = true
+		} else if requiredNode.TypeSafe() == ast.V_ARRAY {
+			requiredArray, err := requiredNode.ArrayUseNode()
+			if err == nil && len(requiredArray) == 0 {
+				shouldRemove = true
+			}
+		}
+
+		// Remove required field if needed
+		if shouldRemove {
+			// Use Unset to directly remove the required field
+			_, _ = parametersNode.Unset("required")
+		}
+
+		return true // Continue to next tool
+	})
+
+	return err
+}
+
+// ConvertToolsToResponseTools converts OpenAI Tool format to Responses API format
+func ConvertToolsToResponseTools(tools []relaymodel.Tool) []relaymodel.ResponseTool {
+	responseTools := make([]relaymodel.ResponseTool, 0, len(tools))
+
+	for _, tool := range tools {
+		responseTool := relaymodel.ResponseTool{
+			Type:        tool.Type,
+			Name:        tool.Function.Name,
+			Description: tool.Function.Description,
+			Parameters:  CleanToolParameters(tool.Function.Parameters),
+		}
+		responseTools = append(responseTools, responseTool)
+	}
+
+	return responseTools
+}
+
+// ConvertMessagesToInputItems converts Message array to InputItem array for Responses API
+func ConvertMessagesToInputItems(messages []relaymodel.Message) []relaymodel.InputItem {
+	inputItems := make([]relaymodel.InputItem, 0, len(messages))
+
+	for _, msg := range messages {
+		// Handle tool responses (function results from tool role)
+		if msg.Role == relaymodel.RoleTool && msg.ToolCallID != "" {
+			// Extract the actual content from the tool message
+			var output string
+			switch content := msg.Content.(type) {
+			case string:
+				output = content
+			default:
+				// Try to marshal non-string content
+				if data, err := sonic.MarshalString(content); err == nil {
+					output = data
+				}
+			}
+
+			// Create separate InputItem for function call output
+			inputItems = append(inputItems, relaymodel.InputItem{
+				Type:   relaymodel.InputItemTypeFunctionCallOutput,
+				CallID: msg.ToolCallID,
+				Output: output,
+			})
+
+			continue
+		}
+
+		// Handle tool calls (function calls from assistant)
+		if len(msg.ToolCalls) > 0 {
+			// Create separate InputItems for each function call
+			for _, toolCall := range msg.ToolCalls {
+				inputItems = append(inputItems, relaymodel.InputItem{
+					Type:      relaymodel.InputItemTypeFunctionCall,
+					CallID:    toolCall.ID,
+					Name:      toolCall.Function.Name,
+					Arguments: toolCall.Function.Arguments,
+				})
+			}
+			// If there's also text content in the message, add it as a separate message item
+			var textContent string
+			if content, ok := msg.Content.(string); ok {
+				textContent = content
+			}
+
+			if textContent != "" {
+				inputItems = append(inputItems, relaymodel.InputItem{
+					Type: relaymodel.InputItemTypeMessage,
+					Role: msg.Role,
+					Content: []relaymodel.InputContent{
+						{
+							Type: relaymodel.InputContentTypeOutputText,
+							Text: textContent,
+						},
+					},
+				})
+			}
+
+			continue
+		}
+
+		// Handle regular messages
+		role := msg.Role
+		// Tool role without ToolCallID is treated as user role
+		if role == relaymodel.RoleTool {
+			role = relaymodel.RoleUser
+		}
+
+		inputItem := relaymodel.InputItem{
+			Type:    relaymodel.InputItemTypeMessage,
+			Role:    role,
+			Content: make([]relaymodel.InputContent, 0),
+		}
+
+		// Determine content type based on role
+		// assistant uses 'output_text', others use 'input_text'
+		contentType := relaymodel.InputContentTypeInputText
+		if role == relaymodel.RoleAssistant {
+			contentType = relaymodel.InputContentTypeOutputText
+		}
+
+		// Handle regular text content
+		switch content := msg.Content.(type) {
+		case string:
+			// Simple string content
+			if content != "" {
+				inputItem.Content = append(inputItem.Content, relaymodel.InputContent{
+					Type: contentType,
+					Text: content,
+				})
+			}
+		case []relaymodel.MessageContent:
+			// Array of MessageContent (from Claude conversion)
+			for _, part := range content {
+				if part.Type == relaymodel.ContentTypeText && part.Text != "" {
+					inputItem.Content = append(inputItem.Content, relaymodel.InputContent{
+						Type: contentType,
+						Text: part.Text,
+					})
+				}
+			}
+		case []any:
+			// Array of content parts (multimodal)
+			for _, part := range content {
+				if partMap, ok := part.(map[string]any); ok {
+					if partType, ok := partMap["type"].(string); ok && partType == "text" {
+						if text, ok := partMap["text"].(string); ok {
+							inputItem.Content = append(inputItem.Content, relaymodel.InputContent{
+								Type: contentType,
+								Text: text,
+							})
+						}
+					}
+				}
+			}
+		}
+
+		// Only append the message if it has content
+		if len(inputItem.Content) > 0 {
+			inputItems = append(inputItems, inputItem)
+		}
+	}
+
+	return inputItems
+}
+
+// ConvertChatCompletionToResponsesRequest converts a ChatCompletion request to Responses API format
+func ConvertChatCompletionToResponsesRequest(
+	meta *meta.Meta,
+	req *http.Request,
+) (adaptor.ConvertResult, error) {
+	// Parse ChatCompletion request
+	var chatReq relaymodel.GeneralOpenAIRequest
+
+	err := common.UnmarshalRequestReusable(req, &chatReq)
+	if err != nil {
+		return adaptor.ConvertResult{}, err
+	}
+
+	// Create Responses API request
+	responsesReq := relaymodel.CreateResponseRequest{
+		Model:  meta.ActualModel,
+		Input:  ConvertMessagesToInputItems(chatReq.Messages),
+		Stream: chatReq.Stream,
+	}
+
+	// Map common fields
+	if chatReq.Temperature != nil {
+		responsesReq.Temperature = chatReq.Temperature
+	}
+
+	if chatReq.TopP != nil {
+		responsesReq.TopP = chatReq.TopP
+	}
+
+	if chatReq.MaxTokens > 0 {
+		responsesReq.MaxOutputTokens = &chatReq.MaxTokens
+	} else if chatReq.MaxCompletionTokens > 0 {
+		responsesReq.MaxOutputTokens = &chatReq.MaxCompletionTokens
+	}
+
+	// Map tools
+	if len(chatReq.Tools) > 0 {
+		responsesReq.Tools = ConvertToolsToResponseTools(chatReq.Tools)
+	}
+
+	if chatReq.ToolChoice != nil {
+		responsesReq.ToolChoice = chatReq.ToolChoice
+	}
+
+	// Map user
+	if chatReq.User != "" {
+		responsesReq.User = &chatReq.User
+	}
+
+	// Map metadata
+	if chatReq.Metadata != nil {
+		if metadata, ok := chatReq.Metadata.(map[string]any); ok {
+			responsesReq.Metadata = metadata
+		}
+	}
+
+	// Force non-store mode
+	storeValue := false
+	responsesReq.Store = &storeValue
+
+	// Marshal to JSON
+	jsonData, err := sonic.Marshal(responsesReq)
+	if err != nil {
+		return adaptor.ConvertResult{}, err
+	}
+
+	return adaptor.ConvertResult{
+		Header: http.Header{
+			"Content-Type":   {"application/json"},
+			"Content-Length": {strconv.Itoa(len(jsonData))},
+		},
+		Body: bytes.NewReader(jsonData),
+	}, nil
+}
+
+// ConvertResponsesToChatCompletionResponse converts Responses API response to ChatCompletion format
+func ConvertResponsesToChatCompletionResponse(
+	meta *meta.Meta,
+	c *gin.Context,
+	resp *http.Response,
+) (model.Usage, adaptor.Error) {
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+		return model.Usage{}, ErrorHanlder(resp)
+	}
+
+	defer resp.Body.Close()
+
+	responseBody, err := common.GetResponseBody(resp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"read_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	var responsesResp relaymodel.Response
+
+	err = sonic.Unmarshal(responseBody, &responsesResp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"unmarshal_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	// Convert to ChatCompletion format
+	chatResp := relaymodel.TextResponse{
+		ID:      responsesResp.ID,
+		Object:  relaymodel.ChatCompletionObject,
+		Created: responsesResp.CreatedAt,
+		Model:   responsesResp.Model,
+		Choices: []*relaymodel.TextResponseChoice{},
+	}
+
+	// Convert output items to choices
+	for _, outputItem := range responsesResp.Output {
+		choice := relaymodel.TextResponseChoice{
+			Index: 0, // Responses API doesn't have index, default to 0
+			Message: relaymodel.Message{
+				Role:    outputItem.Role,
+				Content: "",
+			},
+		}
+
+		// Convert content
+		var (
+			contentParts []string
+			toolCalls    []relaymodel.ToolCall
+		)
+
+		for _, content := range outputItem.Content {
+			if (content.Type == "text" || content.Type == "output_text") && content.Text != "" {
+				contentParts = append(contentParts, content.Text)
+			}
+			// Add tool call conversion if needed in the future
+		}
+
+		if len(contentParts) > 0 {
+			choice.Message.Content = strings.Join(contentParts, "\n")
+		}
+
+		if len(toolCalls) > 0 {
+			choice.Message.ToolCalls = toolCalls
+		}
+
+		// Set finish reason based on status
+		switch responsesResp.Status {
+		case relaymodel.ResponseStatusCompleted:
+			choice.FinishReason = relaymodel.FinishReasonStop
+		case relaymodel.ResponseStatusIncomplete:
+			choice.FinishReason = relaymodel.FinishReasonLength
+		case relaymodel.ResponseStatusFailed:
+			choice.FinishReason = relaymodel.FinishReasonStop
+		}
+
+		chatResp.Choices = append(chatResp.Choices, &choice)
+	}
+
+	// Convert usage
+	if responsesResp.Usage != nil {
+		chatResp.Usage = responsesResp.Usage.ToChatUsage()
+	}
+
+	// Marshal and return
+	chatRespData, err := sonic.Marshal(chatResp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"marshal_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	c.Writer.Header().Set("Content-Type", "application/json")
+	c.Writer.Header().Set("Content-Length", strconv.Itoa(len(chatRespData)))
+	_, _ = c.Writer.Write(chatRespData)
+
+	if responsesResp.Usage != nil {
+		return responsesResp.Usage.ToModelUsage(), nil
+	}
+
+	return model.Usage{}, nil
+}
+
+// ConvertResponsesToChatCompletionStreamResponse converts Responses API stream to ChatCompletion stream
+func ConvertResponsesToChatCompletionStreamResponse(
+	meta *meta.Meta,
+	c *gin.Context,
+	resp *http.Response,
+) (model.Usage, adaptor.Error) {
+	if resp.StatusCode != http.StatusOK {
+		return model.Usage{}, ErrorHanlder(resp)
+	}
+
+	defer resp.Body.Close()
+
+	log := common.GetLogger(c)
+	scanner := bufio.NewScanner(resp.Body)
+
+	buf := utils.GetScannerBuffer()
+	defer utils.PutScannerBuffer(buf)
+
+	scanner.Buffer(*buf, cap(*buf))
+
+	var usage model.Usage
+
+	state := &chatCompletionStreamState{
+		meta: meta,
+		c:    c,
+	}
+
+	for scanner.Scan() {
+		data := scanner.Bytes()
+		if !render.IsValidSSEData(data) {
+			continue
+		}
+
+		data = render.ExtractSSEData(data)
+		if render.IsSSEDone(data) {
+			break
+		}
+
+		// Parse the stream event
+		var event relaymodel.ResponseStreamEvent
+
+		err := sonic.Unmarshal(data, &event)
+		if err != nil {
+			log.Error("error unmarshalling response stream: " + err.Error())
+			continue
+		}
+
+		// Handle event and get response
+		var chatStreamResp *relaymodel.ChatCompletionsStreamResponse
+
+		switch event.Type {
+		case relaymodel.EventResponseCreated:
+			chatStreamResp = state.handleResponseCreated(&event)
+		case relaymodel.EventOutputTextDelta:
+			chatStreamResp = state.handleOutputTextDelta(&event)
+		case relaymodel.EventOutputItemAdded:
+			chatStreamResp = state.handleOutputItemAdded(&event)
+		case relaymodel.EventFunctionCallArgumentsDelta:
+			chatStreamResp = state.handleFunctionCallArgumentsDelta(&event)
+		case relaymodel.EventOutputItemDone:
+			chatStreamResp = state.handleOutputItemDone(&event)
+		case relaymodel.EventResponseCompleted, relaymodel.EventResponseDone:
+			if event.Response != nil && event.Response.Usage != nil {
+				usage = event.Response.Usage.ToModelUsage()
+			}
+
+			chatStreamResp = state.handleResponseCompleted(&event)
+		}
+
+		// Send the converted chunk
+		if chatStreamResp != nil {
+			chunkData, err := sonic.Marshal(chatStreamResp)
+			if err != nil {
+				log.Error("error marshalling chat stream response: " + err.Error())
+				continue
+			}
+
+			render.OpenaiBytesData(c, chunkData)
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		log.Error("error reading response stream: " + err.Error())
+	}
+
+	return usage, nil
+}

+ 604 - 0
core/relay/adaptor/openai/chat_test.go

@@ -0,0 +1,604 @@
+package openai_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/relay/adaptor/openai"
+	"github.com/labring/aiproxy/core/relay/meta"
+	relaymodel "github.com/labring/aiproxy/core/relay/model"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestConvertChatCompletionWithToolsRequiredField(t *testing.T) {
+	tests := []struct {
+		name         string
+		inputRequest relaymodel.GeneralOpenAIRequest
+		checkFunc    func(*testing.T, relaymodel.CreateResponseRequest)
+	}{
+		{
+			name: "tool with null required field should be removed",
+			inputRequest: relaymodel.GeneralOpenAIRequest{
+				Model: "gpt-5-codex",
+				Messages: []relaymodel.Message{
+					{Role: "user", Content: "Test"},
+				},
+				Tools: []relaymodel.Tool{
+					{
+						Type: "function",
+						Function: relaymodel.Function{
+							Name:        "test_function",
+							Description: "A test function",
+							Parameters: map[string]any{
+								"type": "object",
+								"properties": map[string]any{
+									"param1": map[string]any{
+										"type": "string",
+									},
+								},
+								"required": nil,
+							},
+						},
+					},
+				},
+			},
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				require.Len(t, responsesReq.Tools, 1)
+				assert.Equal(t, "test_function", responsesReq.Tools[0].Name)
+
+				// Check that required field is removed
+				if params, ok := responsesReq.Tools[0].Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(t, hasRequired, "required field should be removed when it's null")
+				}
+			},
+		},
+		{
+			name: "tool with empty required array should be removed",
+			inputRequest: relaymodel.GeneralOpenAIRequest{
+				Model: "gpt-5-codex",
+				Messages: []relaymodel.Message{
+					{Role: "user", Content: "Test"},
+				},
+				Tools: []relaymodel.Tool{
+					{
+						Type: "function",
+						Function: relaymodel.Function{
+							Name:        "test_function",
+							Description: "A test function",
+							Parameters: map[string]any{
+								"type": "object",
+								"properties": map[string]any{
+									"param1": map[string]any{
+										"type": "string",
+									},
+								},
+								"required": []any{},
+							},
+						},
+					},
+				},
+			},
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				require.Len(t, responsesReq.Tools, 1)
+
+				// Check that required field is removed
+				if params, ok := responsesReq.Tools[0].Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(
+						t,
+						hasRequired,
+						"required field should be removed when it's empty array",
+					)
+				}
+			},
+		},
+		{
+			name: "tool with valid required array should be kept",
+			inputRequest: relaymodel.GeneralOpenAIRequest{
+				Model: "gpt-5-codex",
+				Messages: []relaymodel.Message{
+					{Role: "user", Content: "Test"},
+				},
+				Tools: []relaymodel.Tool{
+					{
+						Type: "function",
+						Function: relaymodel.Function{
+							Name:        "test_function",
+							Description: "A test function",
+							Parameters: map[string]any{
+								"type": "object",
+								"properties": map[string]any{
+									"param1": map[string]any{
+										"type": "string",
+									},
+								},
+								"required": []any{"param1"},
+							},
+						},
+					},
+				},
+			},
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				require.Len(t, responsesReq.Tools, 1)
+
+				// Check that required field is kept
+				if params, ok := responsesReq.Tools[0].Parameters.(map[string]any); ok {
+					required, hasRequired := params["required"]
+					assert.True(t, hasRequired, "required field should be kept when it has values")
+
+					requiredArray, ok := required.([]any)
+					assert.True(t, ok)
+					assert.Equal(t, []any{"param1"}, requiredArray)
+				}
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reqBody, err := json.Marshal(tt.inputRequest)
+			require.NoError(t, err)
+
+			req := httptest.NewRequest(
+				http.MethodPost,
+				"/v1/chat/completions",
+				bytes.NewReader(reqBody),
+			)
+			req.Header.Set("Content-Type", "application/json")
+
+			m := &meta.Meta{
+				ActualModel: tt.inputRequest.Model,
+			}
+
+			result, err := openai.ConvertChatCompletionToResponsesRequest(m, req)
+			require.NoError(t, err)
+
+			var responsesReq relaymodel.CreateResponseRequest
+
+			err = json.NewDecoder(result.Body).Decode(&responsesReq)
+			require.NoError(t, err)
+
+			tt.checkFunc(t, responsesReq)
+		})
+	}
+}
+
+func TestIsResponsesOnlyModel(t *testing.T) {
+	tests := []struct {
+		name     string
+		model    string
+		expected bool
+	}{
+		{
+			name:     "gpt-5-codex should be responses only",
+			model:    "gpt-5-codex",
+			expected: true,
+		},
+		{
+			name:     "gpt-5-pro should be responses only",
+			model:    "gpt-5-pro",
+			expected: true,
+		},
+		{
+			name:     "gpt-4o should not be responses only",
+			model:    "gpt-4o",
+			expected: false,
+		},
+		{
+			name:     "gpt-3.5-turbo should not be responses only",
+			model:    "gpt-3.5-turbo",
+			expected: false,
+		},
+		{
+			name:     "empty model should not be responses only",
+			model:    "",
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Test with nil config (fallback to model name check)
+			result := openai.IsResponsesOnlyModel(nil, tt.model)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestConvertChatCompletionToResponsesRequest(t *testing.T) {
+	tests := []struct {
+		name         string
+		inputRequest relaymodel.GeneralOpenAIRequest
+		checkFunc    func(*testing.T, relaymodel.CreateResponseRequest)
+	}{
+		{
+			name: "basic request conversion",
+			inputRequest: relaymodel.GeneralOpenAIRequest{
+				Model: "gpt-5-codex",
+				Messages: []relaymodel.Message{
+					{Role: "user", Content: "Hello"},
+				},
+			},
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				assert.Equal(t, "gpt-5-codex", responsesReq.Model)
+				assert.NotNil(t, responsesReq.Store)
+				assert.False(t, *responsesReq.Store)
+			},
+		},
+		{
+			name: "request with temperature and max_tokens",
+			inputRequest: relaymodel.GeneralOpenAIRequest{
+				Model: "gpt-5-codex",
+				Messages: []relaymodel.Message{
+					{Role: "user", Content: "Hello"},
+				},
+				Temperature: floatPtr(0.7),
+				MaxTokens:   100,
+			},
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				assert.NotNil(t, responsesReq.Temperature)
+				assert.Equal(t, 0.7, *responsesReq.Temperature)
+				assert.NotNil(t, responsesReq.MaxOutputTokens)
+				assert.Equal(t, 100, *responsesReq.MaxOutputTokens)
+			},
+		},
+		{
+			name: "request with max_completion_tokens",
+			inputRequest: relaymodel.GeneralOpenAIRequest{
+				Model: "gpt-5-codex",
+				Messages: []relaymodel.Message{
+					{Role: "user", Content: "Hello"},
+				},
+				MaxCompletionTokens: 200,
+			},
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				assert.NotNil(t, responsesReq.MaxOutputTokens)
+				assert.Equal(t, 200, *responsesReq.MaxOutputTokens)
+			},
+		},
+		{
+			name: "request with tools",
+			inputRequest: relaymodel.GeneralOpenAIRequest{
+				Model: "gpt-5-codex",
+				Messages: []relaymodel.Message{
+					{Role: "user", Content: "What's the weather?"},
+				},
+				Tools: []relaymodel.Tool{
+					{
+						Type: "function",
+						Function: relaymodel.Function{
+							Name:        "get_weather",
+							Description: "Get weather information",
+							Parameters: map[string]any{
+								"type": "object",
+								"properties": map[string]any{
+									"location": map[string]any{
+										"type": "string",
+									},
+								},
+							},
+						},
+					},
+				},
+				ToolChoice: "auto",
+			},
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				require.Len(t, responsesReq.Tools, 1)
+				assert.Equal(t, "get_weather", responsesReq.Tools[0].Name)
+				assert.Equal(t, "auto", responsesReq.ToolChoice)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reqBody, err := json.Marshal(tt.inputRequest)
+			require.NoError(t, err)
+
+			req := httptest.NewRequest(
+				http.MethodPost,
+				"/v1/chat/completions",
+				bytes.NewReader(reqBody),
+			)
+			req.Header.Set("Content-Type", "application/json")
+
+			m := &meta.Meta{
+				ActualModel: tt.inputRequest.Model,
+			}
+
+			result, err := openai.ConvertChatCompletionToResponsesRequest(m, req)
+			require.NoError(t, err)
+
+			var responsesReq relaymodel.CreateResponseRequest
+
+			err = json.NewDecoder(result.Body).Decode(&responsesReq)
+			require.NoError(t, err)
+
+			tt.checkFunc(t, responsesReq)
+		})
+	}
+}
+
+func TestConvertResponsesToChatCompletionResponse(t *testing.T) {
+	tests := []struct {
+		name           string
+		responsesResp  relaymodel.Response
+		checkFunc      func(*testing.T, relaymodel.TextResponse)
+		expectedStatus int
+	}{
+		{
+			name: "basic text response",
+			responsesResp: relaymodel.Response{
+				ID:        "resp_123",
+				Model:     "gpt-5-codex",
+				Status:    relaymodel.ResponseStatusCompleted,
+				CreatedAt: 1234567890,
+				Output: []relaymodel.OutputItem{
+					{
+						Type: "message",
+						Content: []relaymodel.OutputContent{
+							{Type: "text", Text: "Hello, world!"},
+						},
+					},
+				},
+				Usage: &relaymodel.ResponseUsage{
+					InputTokens:  10,
+					OutputTokens: 5,
+					TotalTokens:  15,
+				},
+			},
+			checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) {
+				t.Helper()
+				assert.Equal(t, "resp_123", chatResp.ID)
+				assert.Equal(t, "gpt-5-codex", chatResp.Model)
+				assert.Equal(t, "chat.completion", chatResp.Object)
+				require.Len(t, chatResp.Choices, 1)
+				assert.Contains(t, chatResp.Choices[0].Message.Content, "Hello, world!")
+				assert.Equal(t, relaymodel.FinishReasonStop, chatResp.Choices[0].FinishReason)
+				assert.Equal(t, int64(10), chatResp.Usage.PromptTokens)
+				assert.Equal(t, int64(5), chatResp.Usage.CompletionTokens)
+			},
+			expectedStatus: http.StatusOK,
+		},
+		{
+			name: "response with reasoning (o1 models)",
+			responsesResp: relaymodel.Response{
+				ID:        "resp_456",
+				Model:     "gpt-5-codex",
+				Status:    relaymodel.ResponseStatusCompleted,
+				CreatedAt: 1234567890,
+				Output: []relaymodel.OutputItem{
+					{
+						Type: "reasoning",
+						Content: []relaymodel.OutputContent{
+							{Type: "text", Text: "Let me think about this..."},
+						},
+					},
+					{
+						Type: "message",
+						Role: "assistant",
+						Content: []relaymodel.OutputContent{
+							{Type: "text", Text: "The answer is 42."},
+						},
+					},
+				},
+				Usage: &relaymodel.ResponseUsage{
+					InputTokens:  20,
+					OutputTokens: 15,
+					TotalTokens:  35,
+				},
+			},
+			checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) {
+				t.Helper()
+				// Current implementation creates one choice per output item
+				require.Len(t, chatResp.Choices, 2)
+				// First choice is reasoning
+				assert.Contains(
+					t,
+					chatResp.Choices[0].Message.Content,
+					"Let me think about this...",
+				)
+				// Second choice is the message
+				assert.Contains(t, chatResp.Choices[1].Message.Content, "The answer is 42.")
+			},
+			expectedStatus: http.StatusOK,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gin.SetMode(gin.TestMode)
+
+			respBody, err := json.Marshal(tt.responsesResp)
+			require.NoError(t, err)
+
+			httpResp := &http.Response{
+				StatusCode: tt.expectedStatus,
+				Body:       &mockReadCloser{Reader: bytes.NewReader(respBody)},
+				Header:     make(http.Header),
+			}
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+
+			m := &meta.Meta{
+				ActualModel: tt.responsesResp.Model,
+			}
+
+			_, err = openai.ConvertResponsesToChatCompletionResponse(m, c, httpResp)
+			require.Nil(t, err)
+
+			var chatResp relaymodel.TextResponse
+
+			err = json.Unmarshal(w.Body.Bytes(), &chatResp)
+			require.NoError(t, err)
+
+			tt.checkFunc(t, chatResp)
+		})
+	}
+}
+
+// Helper function
+func floatPtr(f float64) *float64 {
+	return &f
+}
+
+// mockReadCloser is a helper to create a ReadCloser from a Reader
+type mockReadCloser struct {
+	*bytes.Reader
+}
+
+func (m *mockReadCloser) Close() error {
+	return nil
+}
+
+func TestConvertChatCompletionsRequest_WithToolsRequiredField(t *testing.T) {
+	tests := []struct {
+		name      string
+		request   string
+		checkFunc func(*testing.T, relaymodel.GeneralOpenAIRequest)
+	}{
+		{
+			name: "null required field should be removed",
+			request: `{
+				"model": "gpt-4",
+				"messages": [{"role": "user", "content": "Hello"}],
+				"tools": [{
+					"type": "function",
+					"function": {
+						"name": "get_weather",
+						"description": "Get weather info",
+						"parameters": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": null
+						}
+					}
+				}]
+			}`,
+			checkFunc: func(t *testing.T, openAIReq relaymodel.GeneralOpenAIRequest) {
+				t.Helper()
+				require.Len(t, openAIReq.Tools, 1)
+
+				// Check that required field is removed
+				if params, ok := openAIReq.Tools[0].Function.Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(t, hasRequired, "required field should be removed when it's null")
+				} else {
+					t.Errorf("Parameters should be a map, got %T", openAIReq.Tools[0].Function.Parameters)
+				}
+			},
+		},
+		{
+			name: "empty required array should be removed",
+			request: `{
+				"model": "gpt-4",
+				"messages": [{"role": "user", "content": "Hello"}],
+				"tools": [{
+					"type": "function",
+					"function": {
+						"name": "get_weather",
+						"description": "Get weather info",
+						"parameters": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": []
+						}
+					}
+				}]
+			}`,
+			checkFunc: func(t *testing.T, openAIReq relaymodel.GeneralOpenAIRequest) {
+				t.Helper()
+				require.Len(t, openAIReq.Tools, 1)
+
+				// Check that required field is removed
+				if params, ok := openAIReq.Tools[0].Function.Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(
+						t,
+						hasRequired,
+						"required field should be removed when it's empty array",
+					)
+				}
+			},
+		},
+		{
+			name: "valid required array should be kept",
+			request: `{
+				"model": "gpt-4",
+				"messages": [{"role": "user", "content": "Hello"}],
+				"tools": [{
+					"type": "function",
+					"function": {
+						"name": "get_weather",
+						"description": "Get weather info",
+						"parameters": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": ["location"]
+						}
+					}
+				}]
+			}`,
+			checkFunc: func(t *testing.T, openAIReq relaymodel.GeneralOpenAIRequest) {
+				t.Helper()
+				require.Len(t, openAIReq.Tools, 1)
+
+				// Check that required field is kept
+				if params, ok := openAIReq.Tools[0].Function.Parameters.(map[string]any); ok {
+					required, hasRequired := params["required"]
+					assert.True(t, hasRequired, "required field should be kept when it has values")
+
+					if reqArray, ok := required.([]any); ok {
+						assert.Equal(t, 1, len(reqArray))
+						assert.Equal(t, "location", reqArray[0])
+					}
+				}
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpReq := httptest.NewRequest(
+				http.MethodPost,
+				"/v1/chat/completions",
+				bytes.NewReader([]byte(tt.request)),
+			)
+			httpReq.Header.Set("Content-Type", "application/json")
+
+			m := &meta.Meta{
+				ActualModel: "gpt-4",
+			}
+
+			result, err := openai.ConvertChatCompletionsRequest(m, httpReq, false)
+			require.NoError(t, err)
+
+			var openAIReq relaymodel.GeneralOpenAIRequest
+
+			err = json.NewDecoder(result.Body).Decode(&openAIReq)
+			require.NoError(t, err)
+
+			tt.checkFunc(t, openAIReq)
+		})
+	}
+}

+ 526 - 77
core/relay/adaptor/openai/claude.go

@@ -71,7 +71,7 @@ func ConvertClaudeRequestModel(
 
 	// Convert tools
 	if len(claudeRequest.Tools) > 0 {
-		openAIRequest.Tools = convertClaudeToolsToOpenAI(claudeRequest.Tools)
+		openAIRequest.Tools = ConvertClaudeToolsToOpenAI(claudeRequest.Tools)
 		openAIRequest.ToolChoice = convertClaudeToolChoice(claudeRequest.ToolChoice)
 	}
 
@@ -104,14 +104,14 @@ func convertClaudeMessagesToOpenAI(
 				systemContent.WriteString("\n")
 			}
 
-			if content.Type == "text" {
+			if content.Type == relaymodel.ClaudeContentTypeText {
 				systemContent.WriteString(content.Text)
 			}
 		}
 
 		if systemContent.Len() > 0 {
 			messages = append(messages, relaymodel.Message{
-				Role:    "system",
+				Role:    relaymodel.RoleSystem,
 				Content: systemContent.String(),
 			})
 		}
@@ -159,7 +159,7 @@ func convertClaudeContent(content any) convertClaudeContentResult {
 		var parts []relaymodel.MessageContent
 		for _, content := range contentArray {
 			switch content.Type {
-			case "text":
+			case relaymodel.ClaudeContentTypeText:
 				text := strings.TrimSpace(content.Text)
 				if text == "" {
 					continue
@@ -179,13 +179,13 @@ func convertClaudeContent(content any) convertClaudeContentResult {
 					Type: relaymodel.ContentTypeText,
 					Text: text,
 				})
-			case "image":
+			case relaymodel.ClaudeContentTypeImage:
 				if content.Source != nil {
 					imageURL := relaymodel.ImageURL{}
 					switch content.Source.Type {
-					case "url":
+					case relaymodel.ClaudeImageSourceTypeURL:
 						imageURL.URL = content.Source.URL
-					case "base64":
+					case relaymodel.ClaudeImageSourceTypeBase64:
 						imageURL.URL = fmt.Sprintf("data:%s;base64,%s",
 							content.Source.MediaType, content.Source.Data)
 					}
@@ -200,7 +200,7 @@ func convertClaudeContent(content any) convertClaudeContentResult {
 				args, _ := sonic.MarshalString(content.Input)
 				toolCall := relaymodel.ToolCall{
 					ID:   content.ID,
-					Type: "function",
+					Type: relaymodel.ToolChoiceTypeFunction,
 					Function: relaymodel.Function{
 						Name:      content.Name,
 						Arguments: args,
@@ -228,7 +228,7 @@ func convertClaudeContent(content any) convertClaudeContentResult {
 				}
 
 				toolMsg := relaymodel.Message{
-					Role:       "tool",
+					Role:       relaymodel.RoleTool,
 					Content:    newContent,
 					ToolCallID: content.ToolUseID,
 				}
@@ -249,13 +249,13 @@ func convertClaudeContent(content any) convertClaudeContentResult {
 	return result
 }
 
-// convertClaudeToolsToOpenAI converts Claude tools to OpenAI format
-func convertClaudeToolsToOpenAI(claudeTools []relaymodel.ClaudeTool) []relaymodel.Tool {
+// ConvertClaudeToolsToOpenAI converts Claude tools to OpenAI format
+func ConvertClaudeToolsToOpenAI(claudeTools []relaymodel.ClaudeTool) []relaymodel.Tool {
 	openAITools := make([]relaymodel.Tool, 0, len(claudeTools))
 
 	for _, tool := range claudeTools {
 		openAITool := relaymodel.Tool{
-			Type: "function",
+			Type: relaymodel.ToolChoiceTypeFunction,
 			Function: relaymodel.Function{
 				Name:        tool.Name,
 				Description: tool.Description,
@@ -264,11 +264,23 @@ func convertClaudeToolsToOpenAI(claudeTools []relaymodel.ClaudeTool) []relaymode
 
 		// Convert input schema
 		if tool.InputSchema != nil {
-			openAITool.Function.Parameters = map[string]any{
+			params := map[string]any{
 				"type":       tool.InputSchema.Type,
 				"properties": tool.InputSchema.Properties,
-				"required":   tool.InputSchema.Required,
 			}
+
+			// Only add required field if it's non-empty
+			// Some OpenAI-compatible APIs reject null or empty required arrays
+			if tool.InputSchema.Required != nil {
+				// Check if required is a non-empty array
+				if reqArray, ok := tool.InputSchema.Required.([]string); ok && len(reqArray) > 0 {
+					params["required"] = tool.InputSchema.Required
+				} else if reqAnyArray, ok := tool.InputSchema.Required.([]any); ok && len(reqAnyArray) > 0 {
+					params["required"] = tool.InputSchema.Required
+				}
+			}
+
+			openAITool.Function.Parameters = params
 		}
 
 		openAITools = append(openAITools, openAITool)
@@ -280,36 +292,36 @@ func convertClaudeToolsToOpenAI(claudeTools []relaymodel.ClaudeTool) []relaymode
 // convertClaudeToolChoice converts Claude tool choice to OpenAI format
 func convertClaudeToolChoice(toolChoice any) any {
 	if toolChoice == nil {
-		return "auto"
+		return relaymodel.ToolChoiceAuto
 	}
 
 	switch v := toolChoice.(type) {
 	case string:
-		if v == "any" {
-			return "required"
+		if v == relaymodel.ToolChoiceAny {
+			return relaymodel.ToolChoiceRequired
 		}
 		return v
 	case map[string]any:
 		if toolType, ok := v["type"].(string); ok {
 			switch toolType {
-			case "tool":
+			case relaymodel.RoleTool:
 				if name, ok := v["name"].(string); ok {
 					return map[string]any{
-						"type": "function",
+						"type": relaymodel.ToolChoiceTypeFunction,
 						"function": map[string]any{
 							"name": name,
 						},
 					}
 				}
-			case "any":
-				return "required"
-			case "auto":
-				return "auto"
+			case relaymodel.ToolChoiceAny:
+				return relaymodel.ToolChoiceRequired
+			case relaymodel.ToolChoiceAuto:
+				return relaymodel.ToolChoiceAuto
 			}
 		}
 	}
 
-	return "auto"
+	return relaymodel.ToolChoiceAuto
 }
 
 // ClaudeStreamHandler handles OpenAI streaming responses and converts them to Claude format
@@ -350,7 +362,7 @@ func ClaudeStreamHandler(
 	closeCurrentBlock := func() {
 		if currentContentIndex >= 0 {
 			_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-				Type:  "content_block_stop",
+				Type:  relaymodel.ClaudeStreamTypeContentBlockStop,
 				Index: currentContentIndex,
 			})
 		}
@@ -387,11 +399,11 @@ func ClaudeStreamHandler(
 
 			// Include initial usage if available
 			messageStartResp := relaymodel.ClaudeStreamResponse{
-				Type: "message_start",
+				Type: relaymodel.ClaudeStreamTypeMessageStart,
 				Message: &relaymodel.ClaudeResponse{
 					ID:      messageID,
-					Type:    "message",
-					Role:    "assistant",
+					Type:    relaymodel.ClaudeTypeMessage,
+					Role:    relaymodel.RoleAssistant,
 					Model:   meta.ActualModel,
 					Content: []relaymodel.ClaudeContent{},
 				},
@@ -406,7 +418,10 @@ func ClaudeStreamHandler(
 			_ = render.ClaudeObjectData(c, messageStartResp)
 
 			// Send ping event
-			_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{Type: "ping"})
+			_ = render.ClaudeObjectData(
+				c,
+				relaymodel.ClaudeStreamResponse{Type: relaymodel.ClaudeStreamTypePing},
+			)
 		}
 
 		// Process each choice
@@ -414,17 +429,17 @@ func ClaudeStreamHandler(
 			// Handle reasoning/thinking content
 			if choice.Delta.ReasoningContent != "" {
 				// If we're not in a thinking block, start one
-				if currentContentType != "thinking" {
+				if currentContentType != relaymodel.ClaudeContentTypeThinking {
 					closeCurrentBlock()
 
 					currentContentIndex++
-					currentContentType = "thinking"
+					currentContentType = relaymodel.ClaudeContentTypeThinking
 
 					_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-						Type:  "content_block_start",
+						Type:  relaymodel.ClaudeStreamTypeContentBlockStart,
 						Index: currentContentIndex,
 						ContentBlock: &relaymodel.ClaudeContent{
-							Type:     "thinking",
+							Type:     relaymodel.ClaudeContentTypeThinking,
 							Thinking: "",
 						},
 					})
@@ -433,10 +448,10 @@ func ClaudeStreamHandler(
 				thinkingText.WriteString(choice.Delta.ReasoningContent)
 
 				_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-					Type:  "content_block_delta",
+					Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
 					Index: currentContentIndex,
 					Delta: &relaymodel.ClaudeDelta{
-						Type:     "thinking_delta",
+						Type:     relaymodel.ClaudeDeltaTypeThinkingDelta,
 						Thinking: choice.Delta.ReasoningContent,
 					},
 				})
@@ -445,17 +460,17 @@ func ClaudeStreamHandler(
 			// Handle text content
 			if content, ok := choice.Delta.Content.(string); ok && content != "" {
 				// If we're not in a text block, start one
-				if currentContentType != "text" {
+				if currentContentType != relaymodel.ClaudeContentTypeText {
 					closeCurrentBlock()
 
 					currentContentIndex++
-					currentContentType = "text"
+					currentContentType = relaymodel.ClaudeContentTypeText
 
 					_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-						Type:  "content_block_start",
+						Type:  relaymodel.ClaudeStreamTypeContentBlockStart,
 						Index: currentContentIndex,
 						ContentBlock: &relaymodel.ClaudeContent{
-							Type: "text",
+							Type: relaymodel.ClaudeContentTypeText,
 							Text: "",
 						},
 					})
@@ -464,10 +479,10 @@ func ClaudeStreamHandler(
 				contentText.WriteString(content)
 
 				_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-					Type:  "content_block_delta",
+					Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
 					Index: currentContentIndex,
 					Delta: &relaymodel.ClaudeDelta{
-						Type: "text_delta",
+						Type: relaymodel.ClaudeDeltaTypeTextDelta,
 						Text: content,
 					},
 				})
@@ -484,10 +499,10 @@ func ClaudeStreamHandler(
 						closeCurrentBlock()
 
 						currentContentIndex++
-						currentContentType = "tool_use"
+						currentContentType = relaymodel.ClaudeContentTypeToolUse
 
 						toolCallsBuffer[idx] = &relaymodel.ClaudeContent{
-							Type:  "tool_use",
+							Type:  relaymodel.ClaudeContentTypeToolUse,
 							ID:    toolCall.ID,
 							Name:  toolCall.Function.Name,
 							Input: make(map[string]any),
@@ -495,7 +510,7 @@ func ClaudeStreamHandler(
 
 						// Send content_block_start for tool use
 						_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-							Type:         "content_block_start",
+							Type:         relaymodel.ClaudeStreamTypeContentBlockStart,
 							Index:        currentContentIndex,
 							ContentBlock: toolCallsBuffer[idx],
 						})
@@ -504,10 +519,10 @@ func ClaudeStreamHandler(
 					// Send tool arguments delta
 					if toolCall.Function.Arguments != "" {
 						_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-							Type:  "content_block_delta",
+							Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
 							Index: currentContentIndex,
 							Delta: &relaymodel.ClaudeDelta{
-								Type:        "input_json_delta",
+								Type:        relaymodel.ClaudeDeltaTypeInputJSONDelta,
 								PartialJSON: toolCall.Function.Arguments,
 							},
 						})
@@ -546,12 +561,12 @@ func ClaudeStreamHandler(
 	claudeUsage := usage.ToClaudeUsage()
 
 	if stopReason == "" {
-		stopReason = claudeStopReasonEndTurn
+		stopReason = relaymodel.ClaudeStopReasonEndTurn
 	}
 
 	// Send message_delta with final usage
 	_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-		Type: "message_delta",
+		Type: relaymodel.ClaudeStreamTypeMessageDelta,
 		Delta: &relaymodel.ClaudeDelta{
 			StopReason: &stopReason,
 		},
@@ -560,7 +575,7 @@ func ClaudeStreamHandler(
 
 	// Send message_stop
 	_ = render.ClaudeObjectData(c, relaymodel.ClaudeStreamResponse{
-		Type: "message_stop",
+		Type: relaymodel.ClaudeStreamTypeMessageStop,
 	})
 
 	return usage.ToModelUsage(), nil
@@ -603,8 +618,8 @@ func ClaudeHandler(
 	// Convert to Claude response
 	claudeResponse := relaymodel.ClaudeResponse{
 		ID:           "msg_" + common.ShortUUID(),
-		Type:         "message",
-		Role:         "assistant",
+		Type:         relaymodel.ClaudeTypeMessage,
+		Role:         relaymodel.RoleAssistant,
 		Model:        meta.ActualModel,
 		Content:      []relaymodel.ClaudeContent{},
 		StopReason:   "",
@@ -616,7 +631,7 @@ func ClaudeHandler(
 		// Handle text content
 		if content, ok := choice.Message.Content.(string); ok {
 			claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-				Type: "text",
+				Type: relaymodel.ClaudeContentTypeText,
 				Text: content,
 			})
 		}
@@ -624,7 +639,7 @@ func ClaudeHandler(
 		// Handle reasoning content (for o1 models)
 		if choice.Message.ReasoningContent != "" {
 			claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-				Type:     "thinking",
+				Type:     relaymodel.ClaudeContentTypeThinking,
 				Thinking: choice.Message.ReasoningContent,
 			})
 		}
@@ -637,7 +652,7 @@ func ClaudeHandler(
 			}
 
 			claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-				Type:  "tool_use",
+				Type:  relaymodel.ClaudeContentTypeToolUse,
 				ID:    toolCall.ID,
 				Name:  toolCall.Function.Name,
 				Input: input,
@@ -651,22 +666,13 @@ func ClaudeHandler(
 	// If no content was added, ensure at least an empty text block
 	if len(claudeResponse.Content) == 0 {
 		claudeResponse.Content = append(claudeResponse.Content, relaymodel.ClaudeContent{
-			Type: "text",
+			Type: relaymodel.ClaudeContentTypeText,
 			Text: "",
 		})
 	}
 
 	// Convert usage
-	claudeResponse.Usage = relaymodel.ClaudeUsage{
-		InputTokens:  openAIResponse.Usage.PromptTokens,
-		OutputTokens: openAIResponse.Usage.CompletionTokens,
-	}
-
-	// Add cache information if available
-	if openAIResponse.Usage.PromptTokensDetails != nil {
-		claudeResponse.Usage.CacheReadInputTokens = openAIResponse.Usage.PromptTokensDetails.CachedTokens
-		claudeResponse.Usage.CacheCreationInputTokens = openAIResponse.Usage.PromptTokensDetails.CacheCreationTokens
-	}
+	claudeResponse.Usage = openAIResponse.Usage.ToClaudeUsage()
 
 	// Add web search usage if available
 	if openAIResponse.Usage.WebSearchCount > 0 {
@@ -693,30 +699,23 @@ func ClaudeHandler(
 	return claudeResponse.Usage.ToOpenAIUsage().ToModelUsage(), nil
 }
 
-const (
-	claudeStopReasonEndTurn      = "end_turn"
-	claudeStopReasonMaxTokens    = "max_tokens"
-	claudeStopReasonToolUse      = "tool_use"
-	claudeStopReasonStopSequence = "stop_sequence"
-)
-
 // convertFinishReasonToClaude converts OpenAI finish reason to Claude stop reason
 func convertFinishReasonToClaude(finishReason string) *string {
 	switch finishReason {
 	case relaymodel.FinishReasonStop:
-		v := claudeStopReasonEndTurn
+		v := relaymodel.ClaudeStopReasonEndTurn
 		return &v
 	case relaymodel.FinishReasonLength:
-		v := claudeStopReasonMaxTokens
+		v := relaymodel.ClaudeStopReasonMaxTokens
 		return &v
 	case relaymodel.FinishReasonToolCalls:
-		v := claudeStopReasonToolUse
+		v := relaymodel.ClaudeStopReasonToolUse
 		return &v
 	case relaymodel.FinishReasonContentFilter:
-		v := claudeStopReasonStopSequence
+		v := relaymodel.ClaudeStopReasonStopSequence
 		return &v
 	case "":
-		v := claudeStopReasonEndTurn
+		v := relaymodel.ClaudeStopReasonEndTurn
 		return &v
 	default:
 		return &finishReason
@@ -771,3 +770,453 @@ func convertOpenAIErrorTypeToClaude(openAIType string) string {
 		return openAIType
 	}
 }
+
+// ConvertClaudeToResponsesRequest converts a Claude request to Responses API format
+func ConvertClaudeToResponsesRequest(
+	meta *meta.Meta,
+	req *http.Request,
+) (adaptor.ConvertResult, error) {
+	// First convert Claude to OpenAI format
+	openAIRequest, err := ConvertClaudeRequestModel(meta, req)
+	if err != nil {
+		return adaptor.ConvertResult{}, err
+	}
+
+	// Create Responses API request
+	responsesReq := relaymodel.CreateResponseRequest{
+		Model:  meta.ActualModel,
+		Input:  ConvertMessagesToInputItems(openAIRequest.Messages),
+		Stream: openAIRequest.Stream,
+	}
+
+	// Map fields from OpenAI request
+	if openAIRequest.Temperature != nil {
+		responsesReq.Temperature = openAIRequest.Temperature
+	}
+
+	if openAIRequest.TopP != nil {
+		responsesReq.TopP = openAIRequest.TopP
+	}
+
+	if openAIRequest.MaxTokens > 0 {
+		responsesReq.MaxOutputTokens = &openAIRequest.MaxTokens
+	} else if openAIRequest.MaxCompletionTokens > 0 {
+		responsesReq.MaxOutputTokens = &openAIRequest.MaxCompletionTokens
+	}
+
+	// Map tools
+	if len(openAIRequest.Tools) > 0 {
+		responsesReq.Tools = ConvertToolsToResponseTools(openAIRequest.Tools)
+	}
+
+	if openAIRequest.ToolChoice != nil {
+		responsesReq.ToolChoice = openAIRequest.ToolChoice
+	}
+
+	// Force non-store mode
+	storeValue := false
+	responsesReq.Store = &storeValue
+
+	// Marshal to JSON
+	jsonData, err := sonic.Marshal(responsesReq)
+	if err != nil {
+		return adaptor.ConvertResult{}, err
+	}
+
+	return adaptor.ConvertResult{
+		Header: http.Header{
+			"Content-Type":   {"application/json"},
+			"Content-Length": {strconv.Itoa(len(jsonData))},
+		},
+		Body: bytes.NewReader(jsonData),
+	}, nil
+}
+
+// ConvertResponsesToClaudeResponse converts Responses API response to Claude format
+func ConvertResponsesToClaudeResponse(
+	meta *meta.Meta,
+	c *gin.Context,
+	resp *http.Response,
+) (model.Usage, adaptor.Error) {
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+		return model.Usage{}, ErrorHanlder(resp)
+	}
+
+	defer resp.Body.Close()
+
+	responseBody, err := common.GetResponseBody(resp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"read_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	var responsesResp relaymodel.Response
+
+	err = sonic.Unmarshal(responseBody, &responsesResp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"unmarshal_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	// Convert to Claude format
+	claudeResp := relaymodel.ClaudeResponse{
+		ID:      responsesResp.ID,
+		Type:    relaymodel.ClaudeTypeMessage,
+		Role:    relaymodel.RoleAssistant,
+		Model:   responsesResp.Model,
+		Content: []relaymodel.ClaudeContent{},
+	}
+
+	// Convert output items to Claude content
+	for _, outputItem := range responsesResp.Output {
+		// Handle different output types
+		switch outputItem.Type {
+		case "reasoning":
+			// Convert reasoning to thinking content
+			for _, content := range outputItem.Content {
+				if (content.Type == relaymodel.ClaudeContentTypeText || content.Type == "output_text") &&
+					content.Text != "" {
+					claudeResp.Content = append(claudeResp.Content, relaymodel.ClaudeContent{
+						Type:     relaymodel.ClaudeContentTypeThinking,
+						Thinking: content.Text,
+					})
+				}
+			}
+		default:
+			// Handle regular message content
+			for _, content := range outputItem.Content {
+				if (content.Type == relaymodel.ClaudeContentTypeText || content.Type == "output_text") &&
+					content.Text != "" {
+					claudeResp.Content = append(claudeResp.Content, relaymodel.ClaudeContent{
+						Type: relaymodel.ClaudeContentTypeText,
+						Text: content.Text,
+					})
+				}
+			}
+		}
+	}
+
+	// Set stop reason based on status
+	switch responsesResp.Status {
+	case relaymodel.ResponseStatusCompleted:
+		claudeResp.StopReason = relaymodel.ClaudeStopReasonEndTurn
+	case relaymodel.ResponseStatusIncomplete:
+		claudeResp.StopReason = relaymodel.ClaudeStopReasonMaxTokens
+	default:
+		claudeResp.StopReason = relaymodel.ClaudeStopReasonEndTurn
+	}
+
+	// Convert usage
+	if responsesResp.Usage != nil {
+		claudeResp.Usage = responsesResp.Usage.ToClaudeUsage()
+	}
+
+	// Marshal and return
+	claudeRespData, err := sonic.Marshal(claudeResp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"marshal_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	c.Writer.Header().Set("Content-Type", "application/json")
+	c.Writer.Header().Set("Content-Length", strconv.Itoa(len(claudeRespData)))
+	_, _ = c.Writer.Write(claudeRespData)
+
+	if responsesResp.Usage != nil {
+		return responsesResp.Usage.ToModelUsage(), nil
+	}
+
+	return model.Usage{}, nil
+}
+
+// ConvertResponsesToClaudeStreamResponse converts Responses API stream to Claude stream
+func ConvertResponsesToClaudeStreamResponse(
+	meta *meta.Meta,
+	c *gin.Context,
+	resp *http.Response,
+) (model.Usage, adaptor.Error) {
+	if resp.StatusCode != http.StatusOK {
+		return model.Usage{}, ErrorHanlder(resp)
+	}
+
+	defer resp.Body.Close()
+
+	log := common.GetLogger(c)
+	scanner := bufio.NewScanner(resp.Body)
+
+	buf := utils.GetScannerBuffer()
+	defer utils.PutScannerBuffer(buf)
+
+	scanner.Buffer(*buf, cap(*buf))
+
+	var usage model.Usage
+
+	state := &claudeStreamState{
+		meta: meta,
+		c:    c,
+	}
+
+	for scanner.Scan() {
+		data := scanner.Bytes()
+		if !render.IsValidSSEData(data) {
+			continue
+		}
+
+		data = render.ExtractSSEData(data)
+		if render.IsSSEDone(data) {
+			break
+		}
+
+		// Parse the stream event
+		var event relaymodel.ResponseStreamEvent
+
+		err := sonic.Unmarshal(data, &event)
+		if err != nil {
+			log.Error("error unmarshalling response stream: " + err.Error())
+			continue
+		}
+
+		// Handle events
+		switch event.Type {
+		case relaymodel.EventResponseCreated:
+			state.handleResponseCreated(&event)
+		case relaymodel.EventOutputItemAdded:
+			state.handleOutputItemAdded(&event)
+		case relaymodel.EventContentPartAdded:
+			state.handleContentPartAdded(&event)
+		case relaymodel.EventReasoningTextDelta:
+			state.handleReasoningTextDelta(&event)
+		case relaymodel.EventOutputTextDelta:
+			state.handleOutputTextDelta(&event)
+		case relaymodel.EventFunctionCallArgumentsDelta:
+			state.handleFunctionCallArgumentsDelta(&event)
+		case relaymodel.EventOutputItemDone:
+			state.handleOutputItemDone(&event)
+		case relaymodel.EventResponseCompleted, relaymodel.EventResponseDone:
+			if event.Response != nil && event.Response.Usage != nil {
+				usage = event.Response.Usage.ToModelUsage()
+			}
+
+			state.handleResponseCompleted(&event)
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		log.Error("error reading response stream: " + err.Error())
+	}
+
+	return usage, nil
+}
+
+// claudeStreamState manages state for Claude stream conversion
+type claudeStreamState struct {
+	messageID            string
+	sentMessageStart     bool
+	contentIndex         int
+	currentContentType   string
+	currentToolUseID     string
+	currentToolUseName   string
+	currentToolUseCallID string
+	toolUseInput         string
+	meta                 *meta.Meta
+	c                    *gin.Context
+}
+
+// handleResponseCreated handles response.created event for Claude
+func (s *claudeStreamState) handleResponseCreated(event *relaymodel.ResponseStreamEvent) {
+	if event.Response == nil {
+		return
+	}
+
+	s.messageID = event.Response.ID
+	s.sentMessageStart = true
+
+	// Send message_start
+	_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+		Type: relaymodel.ClaudeStreamTypeMessageStart,
+		Message: &relaymodel.ClaudeResponse{
+			ID:      s.messageID,
+			Type:    relaymodel.ClaudeTypeMessage,
+			Role:    relaymodel.RoleAssistant,
+			Model:   event.Response.Model,
+			Content: []relaymodel.ClaudeContent{},
+		},
+	})
+}
+
+// handleOutputItemAdded handles response.output_item.added event for Claude
+func (s *claudeStreamState) handleOutputItemAdded(event *relaymodel.ResponseStreamEvent) {
+	if event.Item == nil || !s.sentMessageStart {
+		return
+	}
+
+	// Track if this is a reasoning item
+	switch event.Item.Type {
+	case "reasoning":
+		s.currentContentType = relaymodel.ClaudeContentTypeThinking
+		// Send content_block_start for thinking
+		_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+			Type:  relaymodel.ClaudeStreamTypeContentBlockStart,
+			Index: s.contentIndex,
+			ContentBlock: &relaymodel.ClaudeContent{
+				Type:     relaymodel.ClaudeContentTypeThinking,
+				Thinking: "",
+			},
+		})
+	case relaymodel.InputItemTypeFunctionCall:
+		s.currentContentType = relaymodel.ClaudeContentTypeToolUse
+		s.currentToolUseID = event.Item.ID
+		s.currentToolUseName = event.Item.Name
+		s.currentToolUseCallID = event.Item.CallID
+		s.toolUseInput = ""
+		// Send content_block_start for tool_use
+		_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+			Type:  relaymodel.ClaudeStreamTypeContentBlockStart,
+			Index: s.contentIndex,
+			ContentBlock: &relaymodel.ClaudeContent{
+				Type:  relaymodel.ClaudeContentTypeToolUse,
+				ID:    event.Item.CallID,
+				Name:  event.Item.Name,
+				Input: map[string]any{},
+			},
+		})
+	}
+}
+
+// handleFunctionCallArgumentsDelta handles response.function_call_arguments.delta event for Claude
+func (s *claudeStreamState) handleFunctionCallArgumentsDelta(
+	event *relaymodel.ResponseStreamEvent,
+) {
+	if event.Delta == "" || !s.sentMessageStart ||
+		s.currentContentType != relaymodel.ClaudeContentTypeToolUse {
+		return
+	}
+
+	// Accumulate input
+	s.toolUseInput += event.Delta
+
+	// Send input_json_delta
+	_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+		Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
+		Index: s.contentIndex,
+		Delta: &relaymodel.ClaudeDelta{
+			Type:        relaymodel.ClaudeDeltaTypeInputJSONDelta,
+			PartialJSON: event.Delta,
+		},
+	})
+}
+
+// handleContentPartAdded handles response.content_part.added event for Claude
+func (s *claudeStreamState) handleContentPartAdded(event *relaymodel.ResponseStreamEvent) {
+	if event.Part == nil || !s.sentMessageStart {
+		return
+	}
+
+	if event.Part.Type == "output_text" &&
+		s.currentContentType != relaymodel.ClaudeContentTypeThinking {
+		s.currentContentType = relaymodel.ClaudeContentTypeText
+		// Send content_block_start for new text content
+		_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+			Type:  relaymodel.ClaudeStreamTypeContentBlockStart,
+			Index: s.contentIndex,
+			ContentBlock: &relaymodel.ClaudeContent{
+				Type: relaymodel.ClaudeContentTypeText,
+				Text: "",
+			},
+		})
+	}
+}
+
+// handleReasoningTextDelta handles response.reasoning_text.delta event for Claude
+func (s *claudeStreamState) handleReasoningTextDelta(event *relaymodel.ResponseStreamEvent) {
+	if event.Delta == "" || !s.sentMessageStart {
+		return
+	}
+
+	_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+		Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
+		Index: s.contentIndex,
+		Delta: &relaymodel.ClaudeDelta{
+			Type:     relaymodel.ClaudeDeltaTypeThinkingDelta,
+			Thinking: event.Delta,
+		},
+	})
+}
+
+// handleOutputTextDelta handles response.output_text.delta event for Claude
+func (s *claudeStreamState) handleOutputTextDelta(event *relaymodel.ResponseStreamEvent) {
+	if event.Delta == "" || !s.sentMessageStart {
+		return
+	}
+
+	_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+		Type:  relaymodel.ClaudeStreamTypeContentBlockDelta,
+		Index: s.contentIndex,
+		Delta: &relaymodel.ClaudeDelta{
+			Type: relaymodel.ClaudeDeltaTypeTextDelta,
+			Text: event.Delta,
+		},
+	})
+}
+
+// handleOutputItemDone handles response.output_item.done event for Claude
+func (s *claudeStreamState) handleOutputItemDone(event *relaymodel.ResponseStreamEvent) {
+	if event.Item == nil || !s.sentMessageStart {
+		return
+	}
+
+	// For tool_use blocks, parse and finalize input
+	if event.Item.Type == relaymodel.InputItemTypeFunctionCall &&
+		s.currentContentType == relaymodel.ClaudeContentTypeToolUse {
+		if s.toolUseInput != "" {
+			var input map[string]any
+
+			_ = sonic.Unmarshal([]byte(s.toolUseInput), &input)
+		}
+		// Reset tool use state
+		s.currentToolUseID = ""
+		s.currentToolUseName = ""
+		s.currentToolUseCallID = ""
+		s.toolUseInput = ""
+	}
+
+	// Send content_block_stop for any type
+	_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+		Type:  relaymodel.ClaudeStreamTypeContentBlockStop,
+		Index: s.contentIndex,
+	})
+	s.contentIndex++
+	s.currentContentType = ""
+}
+
+// handleResponseCompleted handles response.completed/done event for Claude
+func (s *claudeStreamState) handleResponseCompleted(event *relaymodel.ResponseStreamEvent) {
+	if event.Response == nil || event.Response.Usage == nil {
+		return
+	}
+
+	// Send message_delta with stop reason
+	stopReason := relaymodel.ClaudeStopReasonEndTurn
+	claudeUsage := event.Response.Usage.ToClaudeUsage()
+	_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+		Type: relaymodel.ClaudeStreamTypeMessageDelta,
+		Delta: &relaymodel.ClaudeDelta{
+			StopReason: &stopReason,
+		},
+		Usage: &claudeUsage,
+	})
+
+	// Send message_stop
+	_ = render.ClaudeObjectData(s.c, relaymodel.ClaudeStreamResponse{
+		Type: relaymodel.ClaudeStreamTypeMessageStop,
+	})
+}

+ 547 - 0
core/relay/adaptor/openai/claude_test.go

@@ -0,0 +1,547 @@
+package openai_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/relay/adaptor/openai"
+	"github.com/labring/aiproxy/core/relay/meta"
+	relaymodel "github.com/labring/aiproxy/core/relay/model"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestConvertClaudeToResponsesRequest(t *testing.T) {
+	tests := []struct {
+		name             string
+		inputRequest     string
+		expectedModel    string
+		expectedMessages int
+		validateContent  bool
+	}{
+		{
+			name: "basic claude request",
+			inputRequest: `{
+				"model": "gpt-5-codex",
+				"messages": [
+					{"role": "user", "content": "Hello"}
+				],
+				"max_tokens": 1024
+			}`,
+			expectedModel:    "gpt-5-codex",
+			expectedMessages: 1,
+			validateContent:  true,
+		},
+		{
+			name: "claude request with multiple content blocks",
+			inputRequest: `{
+				"model": "gpt-5-codex",
+				"messages": [
+					{
+						"role": "user",
+						"content": [
+							{"type": "text", "text": "First part of message"},
+							{"type": "text", "text": "Second part of message"},
+							{"type": "text", "text": "Third part of message"}
+						]
+					}
+				],
+				"max_tokens": 1024
+			}`,
+			expectedModel:    "gpt-5-codex",
+			expectedMessages: 1,
+			validateContent:  true,
+		},
+		{
+			name: "claude request with system and user messages",
+			inputRequest: `{
+				"model": "gpt-5-codex",
+				"system": [
+					{"type": "text", "text": "You are a helpful assistant."}
+				],
+				"messages": [
+					{
+						"role": "user",
+						"content": [
+							{"type": "text", "text": "Hello, how are you?"}
+						]
+					}
+				],
+				"max_tokens": 1024
+			}`,
+			expectedModel:    "gpt-5-codex",
+			expectedMessages: 2,
+			validateContent:  true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpReq := httptest.NewRequest(
+				http.MethodPost,
+				"/v1/messages",
+				bytes.NewReader([]byte(tt.inputRequest)),
+			)
+			httpReq.Header.Set("Content-Type", "application/json")
+
+			m := &meta.Meta{
+				ActualModel: tt.expectedModel,
+			}
+
+			result, err := openai.ConvertClaudeToResponsesRequest(m, httpReq)
+			require.NoError(t, err)
+
+			var responsesReq relaymodel.CreateResponseRequest
+
+			err = json.NewDecoder(result.Body).Decode(&responsesReq)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.expectedModel, responsesReq.Model)
+			assert.NotNil(t, responsesReq.Store)
+			assert.False(t, *responsesReq.Store)
+
+			// Verify input structure
+			if inputArray, ok := responsesReq.Input.([]any); ok {
+				assert.Equal(
+					t,
+					tt.expectedMessages,
+					len(inputArray),
+					"Should have expected number of messages",
+				)
+
+				// Validate that all messages have content
+				if tt.validateContent {
+					for i, item := range inputArray {
+						inputItem, ok := item.(map[string]any)
+						require.True(t, ok, "Input item %d should be a map", i)
+
+						// Every message should have a content field
+						content, hasContent := inputItem["content"]
+						assert.True(t, hasContent, "Message %d should have content field", i)
+
+						// Content should be a non-empty array
+						if contentArray, ok := content.([]any); ok {
+							assert.NotEmpty(
+								t,
+								contentArray,
+								"Message %d content should not be empty",
+								i,
+							)
+
+							// Each content item should have text
+							for j, contentItem := range contentArray {
+								if contentMap, ok := contentItem.(map[string]any); ok {
+									text, hasText := contentMap["text"]
+									assert.True(
+										t,
+										hasText,
+										"Message %d content item %d should have text",
+										i,
+										j,
+									)
+									assert.NotEmpty(
+										t,
+										text,
+										"Message %d content item %d text should not be empty",
+										i,
+										j,
+									)
+								}
+							}
+						} else {
+							t.Errorf(
+								"Message %d content is not an array, got type %T",
+								i,
+								content,
+							)
+						}
+					}
+				}
+			} else {
+				t.Errorf("Input is not an array, got type %T", responsesReq.Input)
+			}
+		})
+	}
+}
+
+func TestConvertClaudeToResponsesRequest_WithToolsRequiredField(t *testing.T) {
+	tests := []struct {
+		name      string
+		request   string
+		checkFunc func(*testing.T, relaymodel.CreateResponseRequest)
+	}{
+		{
+			name: "claude request with null required field should be removed",
+			request: `{
+				"model": "gpt-5-codex",
+				"messages": [{"role": "user", "content": "Test"}],
+				"max_tokens": 1024,
+				"tools": [
+					{
+						"name": "get_weather",
+						"description": "Get weather info",
+						"input_schema": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": null
+						}
+					}
+				]
+			}`,
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				require.Len(t, responsesReq.Tools, 1)
+				assert.Equal(t, "get_weather", responsesReq.Tools[0].Name)
+
+				// Check that required field is removed
+				if params, ok := responsesReq.Tools[0].Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(t, hasRequired, "required field should be removed when it's null")
+				} else {
+					t.Errorf("Parameters should be a map, got %T", responsesReq.Tools[0].Parameters)
+				}
+			},
+		},
+		{
+			name: "claude request with empty required array should be removed",
+			request: `{
+				"model": "gpt-5-codex",
+				"messages": [{"role": "user", "content": "Test"}],
+				"max_tokens": 1024,
+				"tools": [
+					{
+						"name": "get_weather",
+						"description": "Get weather info",
+						"input_schema": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": []
+						}
+					}
+				]
+			}`,
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				require.Len(t, responsesReq.Tools, 1)
+
+				// Check that required field is removed
+				if params, ok := responsesReq.Tools[0].Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(
+						t,
+						hasRequired,
+						"required field should be removed when it's empty array",
+					)
+				}
+			},
+		},
+		{
+			name: "claude request with valid required array should be kept",
+			request: `{
+				"model": "gpt-5-codex",
+				"messages": [{"role": "user", "content": "Test"}],
+				"max_tokens": 1024,
+				"tools": [
+					{
+						"name": "get_weather",
+						"description": "Get weather info",
+						"input_schema": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": ["location"]
+						}
+					}
+				]
+			}`,
+			checkFunc: func(t *testing.T, responsesReq relaymodel.CreateResponseRequest) {
+				t.Helper()
+				require.Len(t, responsesReq.Tools, 1)
+
+				// Check that required field is kept
+				if params, ok := responsesReq.Tools[0].Parameters.(map[string]any); ok {
+					required, hasRequired := params["required"]
+					assert.True(t, hasRequired, "required field should be kept when it has values")
+
+					if reqArray, ok := required.([]any); ok {
+						assert.Equal(t, 1, len(reqArray))
+						assert.Equal(t, "location", reqArray[0])
+					}
+				}
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpReq := httptest.NewRequest(
+				http.MethodPost,
+				"/v1/messages",
+				bytes.NewReader([]byte(tt.request)),
+			)
+			httpReq.Header.Set("Content-Type", "application/json")
+
+			m := &meta.Meta{
+				ActualModel: "gpt-5-codex",
+			}
+
+			result, err := openai.ConvertClaudeToResponsesRequest(m, httpReq)
+			require.NoError(t, err)
+
+			var responsesReq relaymodel.CreateResponseRequest
+
+			err = json.NewDecoder(result.Body).Decode(&responsesReq)
+			require.NoError(t, err)
+
+			tt.checkFunc(t, responsesReq)
+		})
+	}
+}
+
+func TestConvertResponsesToClaudeResponse(t *testing.T) {
+	tests := []struct {
+		name             string
+		responsesResp    relaymodel.Response
+		expectedType     string
+		expectedRole     string
+		expectedContent  string
+		hasReasoning     bool
+		expectedThinking string
+	}{
+		{
+			name: "basic claude response",
+			responsesResp: relaymodel.Response{
+				ID:        "resp_789",
+				Model:     "gpt-5-codex",
+				CreatedAt: 1234567890,
+				Status:    relaymodel.ResponseStatusCompleted,
+				Output: []relaymodel.OutputItem{
+					{
+						Role: "assistant",
+						Content: []relaymodel.OutputContent{
+							{Type: "text", Text: "I'm Claude, how can I help?"},
+						},
+					},
+				},
+				Usage: &relaymodel.ResponseUsage{
+					InputTokens:  15,
+					OutputTokens: 25,
+					TotalTokens:  40,
+				},
+			},
+			expectedType:    "message",
+			expectedRole:    "assistant",
+			expectedContent: "I'm Claude, how can I help?",
+			hasReasoning:    false,
+		},
+		{
+			name: "claude response with reasoning",
+			responsesResp: relaymodel.Response{
+				ID:        "resp_reasoning_123",
+				Model:     "gpt-5-codex",
+				CreatedAt: 1234567890,
+				Status:    relaymodel.ResponseStatusCompleted,
+				Output: []relaymodel.OutputItem{
+					{
+						Type: "reasoning",
+						Content: []relaymodel.OutputContent{
+							{Type: "output_text", Text: "Let me think about this carefully..."},
+						},
+					},
+					{
+						Role: "assistant",
+						Content: []relaymodel.OutputContent{
+							{Type: "text", Text: "Here's my answer!"},
+						},
+					},
+				},
+				Usage: &relaymodel.ResponseUsage{
+					InputTokens:  20,
+					OutputTokens: 35,
+					TotalTokens:  55,
+				},
+			},
+			expectedType:     "message",
+			expectedRole:     "assistant",
+			expectedContent:  "Here's my answer!",
+			hasReasoning:     true,
+			expectedThinking: "Let me think about this carefully...",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create mock response
+			respBody, err := json.Marshal(tt.responsesResp)
+			require.NoError(t, err)
+
+			httpResp := &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       http.NoBody,
+				Header:     make(http.Header),
+			}
+			httpResp.Body = &mockReadCloser{Reader: bytes.NewReader(respBody)}
+
+			// Create gin context
+			gin.SetMode(gin.TestMode)
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+
+			m := &meta.Meta{
+				ActualModel: tt.responsesResp.Model,
+			}
+
+			// Convert
+			usage, err := openai.ConvertResponsesToClaudeResponse(m, c, httpResp)
+			require.Nil(t, err)
+
+			// Parse response
+			var claudeResp relaymodel.ClaudeResponse
+
+			err = json.Unmarshal(w.Body.Bytes(), &claudeResp)
+			require.NoError(t, err)
+
+			// Verify
+			assert.Equal(t, tt.expectedType, claudeResp.Type)
+			assert.Equal(t, tt.expectedRole, claudeResp.Role)
+			assert.NotEmpty(t, claudeResp.Content)
+
+			if tt.hasReasoning {
+				// Should have at least 2 content blocks (thinking + text)
+				assert.GreaterOrEqual(t, len(claudeResp.Content), 2)
+
+				// First block should be thinking
+				thinkingBlock := claudeResp.Content[0]
+				assert.Equal(t, "thinking", thinkingBlock.Type)
+				assert.Equal(t, tt.expectedThinking, thinkingBlock.Thinking)
+
+				// Second block should be text
+				textBlock := claudeResp.Content[1]
+				assert.Equal(t, "text", textBlock.Type)
+				assert.Equal(t, tt.expectedContent, textBlock.Text)
+			} else {
+				assert.Equal(t, "text", claudeResp.Content[0].Type)
+				assert.Equal(t, tt.expectedContent, claudeResp.Content[0].Text)
+			}
+
+			assert.NotNil(t, usage)
+			assert.Equal(t, tt.responsesResp.Usage.InputTokens, int64(usage.InputTokens))
+		})
+	}
+}
+
+func TestConvertClaudeToolsToOpenAI_WithRequiredField(t *testing.T) {
+	tests := []struct {
+		name      string
+		tools     []relaymodel.ClaudeTool
+		checkFunc func(*testing.T, []relaymodel.Tool)
+	}{
+		{
+			name: "null required field should be removed",
+			tools: []relaymodel.ClaudeTool{
+				{
+					Name:        "get_weather",
+					Description: "Get weather info",
+					InputSchema: &relaymodel.ClaudeInputSchema{
+						Type: "object",
+						Properties: map[string]any{
+							"location": map[string]any{"type": "string"},
+						},
+						Required: nil,
+					},
+				},
+			},
+			checkFunc: func(t *testing.T, tools []relaymodel.Tool) {
+				t.Helper()
+				require.Len(t, tools, 1)
+				assert.Equal(t, "get_weather", tools[0].Function.Name)
+
+				// Check that required field is removed
+				if params, ok := tools[0].Function.Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(t, hasRequired, "required field should be removed when it's null")
+				} else {
+					t.Errorf("Parameters should be a map, got %T", tools[0].Function.Parameters)
+				}
+			},
+		},
+		{
+			name: "empty required array should be removed",
+			tools: []relaymodel.ClaudeTool{
+				{
+					Name:        "get_weather",
+					Description: "Get weather info",
+					InputSchema: &relaymodel.ClaudeInputSchema{
+						Type: "object",
+						Properties: map[string]any{
+							"location": map[string]any{"type": "string"},
+						},
+						Required: []string{},
+					},
+				},
+			},
+			checkFunc: func(t *testing.T, tools []relaymodel.Tool) {
+				t.Helper()
+				require.Len(t, tools, 1)
+
+				// Check that required field is removed
+				if params, ok := tools[0].Function.Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					assert.False(
+						t,
+						hasRequired,
+						"required field should be removed when it's empty array",
+					)
+				}
+			},
+		},
+		{
+			name: "valid required array should be kept",
+			tools: []relaymodel.ClaudeTool{
+				{
+					Name:        "get_weather",
+					Description: "Get weather info",
+					InputSchema: &relaymodel.ClaudeInputSchema{
+						Type: "object",
+						Properties: map[string]any{
+							"location": map[string]any{"type": "string"},
+						},
+						Required: []string{"location"},
+					},
+				},
+			},
+			checkFunc: func(t *testing.T, tools []relaymodel.Tool) {
+				t.Helper()
+				require.Len(t, tools, 1)
+
+				// Check that required field is kept
+				if params, ok := tools[0].Function.Parameters.(map[string]any); ok {
+					required, hasRequired := params["required"]
+					assert.True(t, hasRequired, "required field should be kept when it has values")
+
+					if reqArray, ok := required.([]string); ok {
+						assert.Equal(t, 1, len(reqArray))
+						assert.Equal(t, "location", reqArray[0])
+					}
+				}
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := openai.ConvertClaudeToolsToOpenAI(tt.tools)
+			tt.checkFunc(t, result)
+		})
+	}
+}

+ 47 - 0
core/relay/adaptor/openai/constants.go

@@ -214,4 +214,51 @@ var ModelList = []model.ModelConfig{
 		Type:  mode.AudioSpeech,
 		Owner: model.ModelOwnerOpenAI,
 	},
+	{
+		Model: "gpt-5-codex",
+		Type:  mode.Responses,
+		Owner: model.ModelOwnerOpenAI,
+		Config: model.NewModelConfig(
+			model.WithModelConfigMaxContextTokens(200000),
+			model.WithModelConfigToolChoice(true),
+		),
+	},
+	{
+		Model: "gpt-5-pro",
+		Type:  mode.Responses,
+		Owner: model.ModelOwnerOpenAI,
+		Config: model.NewModelConfig(
+			model.WithModelConfigMaxContextTokens(200000),
+			model.WithModelConfigToolChoice(true),
+		),
+	},
+}
+
+var responsesOnlyModels = map[string]struct{}{
+	"gpt-5-codex": {},
+	"gpt-5-pro":   {},
+}
+
+// IsResponsesOnlyModel checks if a model only supports the Responses API
+// First parameter is the model config, used to check Type field if model name check fails
+// Second parameter is the model name, checked first for quick lookup
+func IsResponsesOnlyModel(modelConfig *model.ModelConfig, modelName string) bool {
+	// First, check model name for quick lookup
+	if _, ok := responsesOnlyModels[modelName]; ok {
+		return true
+	}
+
+	// If model config is provided, check if Type is any Responses-related mode
+	if modelConfig != nil {
+		switch modelConfig.Type {
+		case mode.Responses,
+			mode.ResponsesGet,
+			mode.ResponsesDelete,
+			mode.ResponsesCancel,
+			mode.ResponsesInputItems:
+			return true
+		}
+	}
+
+	return false
 }

+ 493 - 38
core/relay/adaptor/openai/gemini.go

@@ -11,6 +11,7 @@ import (
 
 	"github.com/bytedance/sonic"
 	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/common"
 	"github.com/labring/aiproxy/core/model"
 	"github.com/labring/aiproxy/core/relay/adaptor"
 	"github.com/labring/aiproxy/core/relay/meta"
@@ -96,18 +97,15 @@ func ConvertOpenAIToGeminiResponse(
 	}
 
 	if openaiResp.Usage.TotalTokens > 0 {
-		geminiResp.UsageMetadata = &relaymodel.GeminiUsageMetadata{
-			PromptTokenCount:     openaiResp.Usage.PromptTokens,
-			CandidatesTokenCount: openaiResp.Usage.CompletionTokens,
-			TotalTokenCount:      openaiResp.Usage.TotalTokens,
-		}
+		geminiUsage := openaiResp.Usage.ToGeminiUsage()
+		geminiResp.UsageMetadata = &geminiUsage
 	}
 
 	for _, choice := range openaiResp.Choices {
 		candidate := &relaymodel.GeminiChatCandidate{
 			Index: int64(choice.Index),
 			Content: relaymodel.GeminiChatContent{
-				Role:  "model",
+				Role:  relaymodel.GeminiRoleModel,
 				Parts: []*relaymodel.GeminiPart{},
 			},
 		}
@@ -115,13 +113,13 @@ func ConvertOpenAIToGeminiResponse(
 		// Convert finish reason
 		switch choice.FinishReason {
 		case relaymodel.FinishReasonStop:
-			candidate.FinishReason = "STOP"
+			candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 		case relaymodel.FinishReasonLength:
-			candidate.FinishReason = "MAX_TOKENS"
+			candidate.FinishReason = relaymodel.GeminiFinishReasonMaxTokens
 		case relaymodel.FinishReasonToolCalls:
-			candidate.FinishReason = "STOP"
+			candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 		default:
-			candidate.FinishReason = "STOP"
+			candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 		}
 
 		// Convert content
@@ -245,11 +243,8 @@ func (s *GeminiStreamState) ConvertOpenAIStreamToGemini(
 	}
 
 	if openaiResp.Usage != nil {
-		geminiResp.UsageMetadata = &relaymodel.GeminiUsageMetadata{
-			PromptTokenCount:     openaiResp.Usage.PromptTokens,
-			CandidatesTokenCount: openaiResp.Usage.CompletionTokens,
-			TotalTokenCount:      openaiResp.Usage.TotalTokens,
-		}
+		geminiUsage := openaiResp.Usage.ToGeminiUsage()
+		geminiResp.UsageMetadata = &geminiUsage
 	}
 
 	hasContent := geminiResp.UsageMetadata != nil
@@ -258,7 +253,7 @@ func (s *GeminiStreamState) ConvertOpenAIStreamToGemini(
 		candidate := &relaymodel.GeminiChatCandidate{
 			Index: int64(choice.Index),
 			Content: relaymodel.GeminiChatContent{
-				Role:  "model",
+				Role:  relaymodel.GeminiRoleModel,
 				Parts: []*relaymodel.GeminiPart{},
 			},
 		}
@@ -296,13 +291,13 @@ func (s *GeminiStreamState) ConvertOpenAIStreamToGemini(
 		if choice.FinishReason != "" {
 			switch choice.FinishReason {
 			case relaymodel.FinishReasonStop:
-				candidate.FinishReason = "STOP"
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 			case relaymodel.FinishReasonLength:
-				candidate.FinishReason = "MAX_TOKENS"
+				candidate.FinishReason = relaymodel.GeminiFinishReasonMaxTokens
 			case relaymodel.FinishReasonToolCalls:
-				candidate.FinishReason = "STOP"
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 			default:
-				candidate.FinishReason = "STOP"
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
 			}
 
 			// Flush buffered tool calls for this choice
@@ -421,7 +416,7 @@ func convertGeminiSystemToOpenAI(geminiReq *relaymodel.GeminiChatRequest) []rela
 
 	if systemText != "" {
 		return []relaymodel.Message{{
-			Role:    "system",
+			Role:    relaymodel.RoleSystem,
 			Content: systemText,
 		}}
 	}
@@ -447,13 +442,17 @@ func convertGeminiToolsToOpenAI(geminiReq *relaymodel.GeminiChatRequest) []relay
 						parameters = fn["parametersJsonSchema"]
 					}
 
+					// Clean parameters to remove null or empty required field
+					// Some OpenAI-compatible APIs reject null or empty required arrays
+					parameters = CleanToolParameters(parameters)
+
 					function := relaymodel.Function{
 						Name:        name,
 						Description: description,
 						Parameters:  parameters,
 					}
 					tools = append(tools, relaymodel.Tool{
-						Type:     "function",
+						Type:     relaymodel.ToolChoiceTypeFunction,
 						Function: function,
 					})
 				}
@@ -470,21 +469,21 @@ func convertGeminiToolConfigToOpenAI(geminiReq *relaymodel.GeminiChatRequest) an
 	}
 
 	switch geminiReq.ToolConfig.FunctionCallingConfig.Mode {
-	case "AUTO":
-		return "auto"
-	case "NONE":
-		return "none"
-	case "ANY":
+	case relaymodel.GeminiFunctionCallingModeAuto:
+		return relaymodel.ToolChoiceAuto
+	case relaymodel.GeminiFunctionCallingModeNone:
+		return relaymodel.ToolChoiceNone
+	case relaymodel.GeminiFunctionCallingModeAny:
 		if len(geminiReq.ToolConfig.FunctionCallingConfig.AllowedFunctionNames) > 0 {
 			return map[string]any{
-				"type": "function",
+				"type": relaymodel.ToolChoiceTypeFunction,
 				"function": map[string]any{
 					"name": geminiReq.ToolConfig.FunctionCallingConfig.AllowedFunctionNames[0],
 				},
 			}
 		}
 
-		return "required"
+		return relaymodel.ToolChoiceRequired
 	}
 
 	return nil
@@ -534,14 +533,14 @@ func convertGeminiContentToOpenAI(
 	// Map role
 	role := content.Role
 	if role == "" {
-		role = "user"
+		role = relaymodel.RoleUser
 	}
 
 	switch role {
-	case "model":
-		role = "assistant"
-	case "user":
-		role = "user"
+	case relaymodel.GeminiRoleModel:
+		role = relaymodel.RoleAssistant
+	case relaymodel.GeminiRoleUser:
+		role = relaymodel.RoleUser
 	}
 
 	// Current message builder
@@ -565,7 +564,7 @@ func convertGeminiContentToOpenAI(
 			args, _ := sonic.MarshalString(part.FunctionCall.Args)
 			toolCall := relaymodel.ToolCall{
 				ID:   CallID(),
-				Type: "function",
+				Type: relaymodel.ToolChoiceTypeFunction,
 				Function: relaymodel.Function{
 					Name:      part.FunctionCall.Name,
 					Arguments: args,
@@ -628,7 +627,7 @@ func convertGeminiContentToOpenAI(
 				// This handles cases where the client omits the model's function call message
 				syntheticCall := relaymodel.ToolCall{
 					ID:   id,
-					Type: "function",
+					Type: relaymodel.ToolChoiceTypeFunction,
 					Function: relaymodel.Function{
 						Name:      name,
 						Arguments: "{}", // Assume empty args as we can't reconstruct them
@@ -636,7 +635,7 @@ func convertGeminiContentToOpenAI(
 				}
 
 				syntheticMsg := relaymodel.Message{
-					Role:      "assistant",
+					Role:      relaymodel.RoleAssistant,
 					ToolCalls: []relaymodel.ToolCall{syntheticCall},
 				}
 
@@ -646,7 +645,7 @@ func convertGeminiContentToOpenAI(
 			responseContent, _ := sonic.MarshalString(part.FunctionResponse.Response)
 
 			toolMsg := relaymodel.Message{
-				Role:       "tool",
+				Role:       relaymodel.RoleTool,
 				Content:    responseContent,
 				ToolCallID: id,
 				Name:       &name,
@@ -695,3 +694,459 @@ func convertGeminiContentToOpenAI(
 
 	return messages
 }
+
+// ConvertGeminiToResponsesRequest converts a Gemini request to Responses API format
+func ConvertGeminiToResponsesRequest(
+	meta *meta.Meta,
+	req *http.Request,
+) (adaptor.ConvertResult, error) {
+	// Parse Gemini request
+	geminiReq, err := utils.UnmarshalGeminiChatRequest(req)
+	if err != nil {
+		return adaptor.ConvertResult{}, err
+	}
+
+	// Convert to OpenAI messages format first
+	var messages []relaymodel.Message
+
+	// Convert system instruction
+	if geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 {
+		var systemText strings.Builder
+		for _, part := range geminiReq.SystemInstruction.Parts {
+			if part.Text != "" {
+				systemText.WriteString(part.Text)
+			}
+		}
+
+		if systemText.Len() > 0 {
+			messages = append(messages, relaymodel.Message{
+				Role:    relaymodel.RoleSystem,
+				Content: systemText.String(),
+			})
+		}
+	}
+
+	// Convert contents
+	var pendingTools []relaymodel.ToolCall
+	for _, content := range geminiReq.Contents {
+		msgs := convertGeminiContentToOpenAI(content, &pendingTools)
+		messages = append(messages, msgs...)
+	}
+
+	// Create Responses API request
+	responsesReq := relaymodel.CreateResponseRequest{
+		Model:  meta.ActualModel,
+		Input:  ConvertMessagesToInputItems(messages),
+		Stream: utils.IsGeminiStreamRequest(req.URL.Path),
+	}
+
+	// Map generation config
+	if geminiReq.GenerationConfig != nil {
+		if geminiReq.GenerationConfig.Temperature != nil {
+			responsesReq.Temperature = geminiReq.GenerationConfig.Temperature
+		}
+
+		if geminiReq.GenerationConfig.TopP != nil {
+			responsesReq.TopP = geminiReq.GenerationConfig.TopP
+		}
+
+		if geminiReq.GenerationConfig.MaxOutputTokens != nil {
+			responsesReq.MaxOutputTokens = geminiReq.GenerationConfig.MaxOutputTokens
+		}
+	}
+
+	// Convert tools
+	if len(geminiReq.Tools) > 0 {
+		var tools []relaymodel.ResponseTool
+		for _, geminiTool := range geminiReq.Tools {
+			if fnDecls, ok := geminiTool.FunctionDeclarations.([]any); ok {
+				for _, fnDecl := range fnDecls {
+					if fn, ok := fnDecl.(map[string]any); ok {
+						name, _ := fn["name"].(string)
+						description, _ := fn["description"].(string)
+
+						parameters := fn["parameters"]
+						if parameters == nil {
+							parameters = fn["parametersJsonSchema"]
+						}
+
+						// Clean parameters to remove null/empty required field
+						parameters = CleanToolParameters(parameters)
+
+						tools = append(tools, relaymodel.ResponseTool{
+							Type:        relaymodel.ToolChoiceTypeFunction,
+							Name:        name,
+							Description: description,
+							Parameters:  parameters,
+						})
+					}
+				}
+			}
+		}
+
+		responsesReq.Tools = tools
+	}
+
+	// Convert tool config
+	if geminiReq.ToolConfig != nil {
+		switch geminiReq.ToolConfig.FunctionCallingConfig.Mode {
+		case relaymodel.GeminiFunctionCallingModeAuto:
+			responsesReq.ToolChoice = relaymodel.ToolChoiceAuto
+		case relaymodel.GeminiFunctionCallingModeNone:
+			responsesReq.ToolChoice = relaymodel.ToolChoiceNone
+		case relaymodel.GeminiFunctionCallingModeAny:
+			responsesReq.ToolChoice = relaymodel.ToolChoiceRequired
+		}
+	}
+
+	// Force non-store mode
+	storeValue := false
+	responsesReq.Store = &storeValue
+
+	// Marshal to JSON
+	jsonData, err := sonic.Marshal(responsesReq)
+	if err != nil {
+		return adaptor.ConvertResult{}, err
+	}
+
+	fmt.Println(string(jsonData))
+
+	return adaptor.ConvertResult{
+		Header: http.Header{
+			"Content-Type":   {"application/json"},
+			"Content-Length": {strconv.Itoa(len(jsonData))},
+		},
+		Body: bytes.NewReader(jsonData),
+	}, nil
+}
+
+// ConvertResponsesToGeminiResponse converts Responses API response to Gemini format
+func ConvertResponsesToGeminiResponse(
+	meta *meta.Meta,
+	c *gin.Context,
+	resp *http.Response,
+) (model.Usage, adaptor.Error) {
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+		return model.Usage{}, ErrorHanlder(resp)
+	}
+
+	defer resp.Body.Close()
+
+	responseBody, err := common.GetResponseBody(resp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"read_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	var responsesResp relaymodel.Response
+
+	err = sonic.Unmarshal(responseBody, &responsesResp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"unmarshal_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	// Convert to Gemini format
+	geminiResp := relaymodel.GeminiChatResponse{
+		ModelVersion: responsesResp.Model,
+		Candidates:   []*relaymodel.GeminiChatCandidate{},
+	}
+
+	// Convert output items to Gemini candidates
+	for _, outputItem := range responsesResp.Output {
+		candidate := &relaymodel.GeminiChatCandidate{
+			Index: 0,
+			Content: relaymodel.GeminiChatContent{
+				Role:  relaymodel.GeminiRoleModel,
+				Parts: []*relaymodel.GeminiPart{},
+			},
+		}
+
+		// Handle different output types
+		switch outputItem.Type {
+		case "reasoning":
+			// Convert reasoning to thought parts
+			for _, content := range outputItem.Content {
+				if (content.Type == "text" || content.Type == "output_text") && content.Text != "" {
+					candidate.Content.Parts = append(
+						candidate.Content.Parts,
+						&relaymodel.GeminiPart{
+							Text:    content.Text,
+							Thought: true,
+						},
+					)
+				}
+			}
+
+		case "function_call":
+			// Handle function_call type
+			if outputItem.Name != "" {
+				var args map[string]any
+				if outputItem.Arguments != "" {
+					err := sonic.Unmarshal([]byte(outputItem.Arguments), &args)
+					if err == nil {
+						candidate.Content.Parts = append(
+							candidate.Content.Parts,
+							&relaymodel.GeminiPart{
+								FunctionCall: &relaymodel.GeminiFunctionCall{
+									Name: outputItem.Name,
+									Args: args,
+								},
+							},
+						)
+					}
+				}
+			}
+
+		default:
+			// Handle message type with text content
+			for _, content := range outputItem.Content {
+				if (content.Type == "text" || content.Type == "output_text") && content.Text != "" {
+					candidate.Content.Parts = append(
+						candidate.Content.Parts,
+						&relaymodel.GeminiPart{
+							Text: content.Text,
+						},
+					)
+				}
+			}
+		}
+
+		// Only add candidate if it has content
+		if len(candidate.Content.Parts) > 0 {
+			// Set finish reason
+			switch responsesResp.Status {
+			case relaymodel.ResponseStatusCompleted:
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
+			case relaymodel.ResponseStatusIncomplete:
+				candidate.FinishReason = relaymodel.GeminiFinishReasonMaxTokens
+			default:
+				candidate.FinishReason = relaymodel.GeminiFinishReasonStop
+			}
+
+			geminiResp.Candidates = append(geminiResp.Candidates, candidate)
+		}
+	}
+
+	usage := model.Usage{}
+
+	// Convert usage
+	if responsesResp.Usage != nil {
+		usage = responsesResp.Usage.ToModelUsage()
+		geminiUsage := responsesResp.Usage.ToGeminiUsage()
+		geminiResp.UsageMetadata = &geminiUsage
+	}
+
+	// Marshal and return
+	geminiRespData, err := sonic.Marshal(geminiResp)
+	if err != nil {
+		return model.Usage{}, relaymodel.WrapperOpenAIError(
+			err,
+			"marshal_response_body_failed",
+			http.StatusInternalServerError,
+		)
+	}
+
+	c.Writer.Header().Set("Content-Type", "application/json")
+	c.Writer.Header().Set("Content-Length", strconv.Itoa(len(geminiRespData)))
+	_, _ = c.Writer.Write(geminiRespData)
+
+	return usage, nil
+}
+
+// ConvertResponsesToGeminiStreamResponse converts Responses API stream to Gemini stream
+func ConvertResponsesToGeminiStreamResponse(
+	meta *meta.Meta,
+	c *gin.Context,
+	resp *http.Response,
+) (model.Usage, adaptor.Error) {
+	if resp.StatusCode != http.StatusOK {
+		return model.Usage{}, ErrorHanlder(resp)
+	}
+
+	defer resp.Body.Close()
+
+	log := common.GetLogger(c)
+	scanner := bufio.NewScanner(resp.Body)
+
+	buf := utils.GetScannerBuffer()
+	defer utils.PutScannerBuffer(buf)
+
+	scanner.Buffer(*buf, cap(*buf))
+
+	var usage model.Usage
+
+	state := &geminiStreamState{
+		meta: meta,
+		c:    c,
+	}
+
+	for scanner.Scan() {
+		data := scanner.Bytes()
+		if !render.IsValidSSEData(data) {
+			continue
+		}
+
+		data = render.ExtractSSEData(data)
+		if render.IsSSEDone(data) {
+			break
+		}
+
+		// Parse the stream event
+		var event relaymodel.ResponseStreamEvent
+
+		err := sonic.Unmarshal(data, &event)
+		if err != nil {
+			log.Error("error unmarshalling response stream: " + err.Error())
+			continue
+		}
+
+		// Handle events
+		// Note: Gemini format requires complete JSON for function calls,
+		// so we handle function_call_arguments.done (complete), not function_call_arguments.delta (streaming)
+		switch event.Type {
+		case relaymodel.EventOutputItemAdded:
+			state.handleOutputItemAdded(&event)
+		case relaymodel.EventOutputTextDelta:
+			state.handleOutputTextDelta(&event)
+		case relaymodel.EventFunctionCallArgumentsDone:
+			state.handleFunctionCallArgumentsDone(&event)
+		case relaymodel.EventResponseCompleted, relaymodel.EventResponseDone:
+			if event.Response != nil && event.Response.Usage != nil {
+				usage = event.Response.Usage.ToModelUsage()
+			}
+
+			state.handleResponseCompleted(&event)
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		log.Error("error reading response stream: " + err.Error())
+	}
+
+	return usage, nil
+}
+
+// geminiStreamState manages state for Gemini stream conversion
+type geminiStreamState struct {
+	meta              *meta.Meta
+	c                 *gin.Context
+	functionCallNames map[string]string // item_id -> function name
+}
+
+// handleOutputItemAdded handles response.output_item.added event for Gemini
+func (s *geminiStreamState) handleOutputItemAdded(event *relaymodel.ResponseStreamEvent) {
+	if event.Item == nil {
+		return
+	}
+
+	// Track function call names for later use in done event
+	if event.Item.Type == relaymodel.InputItemTypeFunctionCall && event.Item.Name != "" {
+		if s.functionCallNames == nil {
+			s.functionCallNames = make(map[string]string)
+		}
+
+		s.functionCallNames[event.Item.ID] = event.Item.Name
+	}
+}
+
+// handleOutputTextDelta handles response.output_text.delta event for Gemini
+func (s *geminiStreamState) handleOutputTextDelta(event *relaymodel.ResponseStreamEvent) {
+	if event.Delta == "" {
+		return
+	}
+
+	// Send text delta
+	geminiResp := relaymodel.GeminiChatResponse{
+		ModelVersion: s.meta.ActualModel,
+		Candidates: []*relaymodel.GeminiChatCandidate{
+			{
+				Index: 0,
+				Content: relaymodel.GeminiChatContent{
+					Role: relaymodel.GeminiRoleModel,
+					Parts: []*relaymodel.GeminiPart{
+						{
+							Text: event.Delta,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	_ = render.GeminiObjectData(s.c, geminiResp)
+}
+
+// handleFunctionCallArgumentsDone handles response.function_call_arguments.done event for Gemini
+func (s *geminiStreamState) handleFunctionCallArgumentsDone(event *relaymodel.ResponseStreamEvent) {
+	if event.Arguments == "" || event.ItemID == "" {
+		return
+	}
+
+	// Get function name from tracked state
+	functionName := s.functionCallNames[event.ItemID]
+	if functionName == "" {
+		return
+	}
+
+	// Parse arguments
+	var args map[string]any
+	if err := sonic.UnmarshalString(event.Arguments, &args); err != nil {
+		return
+	}
+
+	// Send complete function call
+	geminiResp := relaymodel.GeminiChatResponse{
+		ModelVersion: s.meta.ActualModel,
+		Candidates: []*relaymodel.GeminiChatCandidate{
+			{
+				Index: 0,
+				Content: relaymodel.GeminiChatContent{
+					Role: relaymodel.GeminiRoleModel,
+					Parts: []*relaymodel.GeminiPart{
+						{
+							FunctionCall: &relaymodel.GeminiFunctionCall{
+								Name: functionName,
+								Args: args,
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	_ = render.GeminiObjectData(s.c, geminiResp)
+}
+
+// handleResponseCompleted handles response.completed/done event for Gemini
+func (s *geminiStreamState) handleResponseCompleted(event *relaymodel.ResponseStreamEvent) {
+	if event.Response == nil || event.Response.Usage == nil {
+		return
+	}
+
+	// Send final response with usage
+	geminiUsage := event.Response.Usage.ToGeminiUsage()
+	geminiResp := relaymodel.GeminiChatResponse{
+		ModelVersion:  s.meta.ActualModel,
+		UsageMetadata: &geminiUsage,
+		Candidates: []*relaymodel.GeminiChatCandidate{
+			{
+				Index:        0,
+				FinishReason: relaymodel.GeminiFinishReasonStop,
+				Content: relaymodel.GeminiChatContent{
+					Role:  relaymodel.GeminiRoleModel,
+					Parts: []*relaymodel.GeminiPart{},
+				},
+			},
+		},
+	}
+
+	_ = render.GeminiObjectData(s.c, geminiResp)
+}

+ 157 - 0
core/relay/adaptor/openai/gemini_test.go

@@ -650,3 +650,160 @@ func TestConvertOpenAIStreamToGemini_MultipleToolCalls_Order(t *testing.T) {
 		t.Errorf("Expected order [func_zero, func_one], got [%s, %s]", name0, name1)
 	}
 }
+
+func TestConvertGeminiRequest_ToolsWithRequiredField(t *testing.T) {
+	tests := []struct {
+		name      string
+		request   string
+		checkFunc func(*testing.T, relaymodel.GeneralOpenAIRequest)
+	}{
+		{
+			name: "null required field should be removed",
+			request: `{
+				"tools": [{
+					"functionDeclarations": [{
+						"name": "get_weather",
+						"description": "Get weather info",
+						"parameters": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": null
+						}
+					}]
+				}],
+				"contents": [{"parts": [{"text": "Hello"}], "role": "user"}]
+			}`,
+			checkFunc: func(t *testing.T, openAIReq relaymodel.GeneralOpenAIRequest) {
+				t.Helper()
+
+				if len(openAIReq.Tools) != 1 {
+					t.Fatalf("Expected 1 tool, got %d", len(openAIReq.Tools))
+				}
+
+				// Check that required field is removed
+				if params, ok := openAIReq.Tools[0].Function.Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					if hasRequired {
+						t.Errorf("required field should be removed when it's null")
+					}
+				} else {
+					t.Errorf("Parameters should be a map, got %T", openAIReq.Tools[0].Function.Parameters)
+				}
+			},
+		},
+		{
+			name: "empty required array should be removed",
+			request: `{
+				"tools": [{
+					"functionDeclarations": [{
+						"name": "get_weather",
+						"description": "Get weather info",
+						"parameters": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": []
+						}
+					}]
+				}],
+				"contents": [{"parts": [{"text": "Hello"}], "role": "user"}]
+			}`,
+			checkFunc: func(t *testing.T, openAIReq relaymodel.GeneralOpenAIRequest) {
+				t.Helper()
+
+				if len(openAIReq.Tools) != 1 {
+					t.Fatalf("Expected 1 tool, got %d", len(openAIReq.Tools))
+				}
+
+				// Check that required field is removed
+				if params, ok := openAIReq.Tools[0].Function.Parameters.(map[string]any); ok {
+					_, hasRequired := params["required"]
+					if hasRequired {
+						t.Errorf("required field should be removed when it's empty array")
+					}
+				}
+			},
+		},
+		{
+			name: "valid required array should be kept",
+			request: `{
+				"tools": [{
+					"functionDeclarations": [{
+						"name": "get_weather",
+						"description": "Get weather info",
+						"parameters": {
+							"type": "object",
+							"properties": {
+								"location": {"type": "string"}
+							},
+							"required": ["location"]
+						}
+					}]
+				}],
+				"contents": [{"parts": [{"text": "Hello"}], "role": "user"}]
+			}`,
+			checkFunc: func(t *testing.T, openAIReq relaymodel.GeneralOpenAIRequest) {
+				t.Helper()
+
+				if len(openAIReq.Tools) != 1 {
+					t.Fatalf("Expected 1 tool, got %d", len(openAIReq.Tools))
+				}
+
+				// Check that required field is kept
+				if params, ok := openAIReq.Tools[0].Function.Parameters.(map[string]any); ok {
+					required, hasRequired := params["required"]
+					if !hasRequired {
+						t.Errorf("required field should be kept when it has values")
+					}
+
+					if reqArray, ok := required.([]any); ok {
+						if len(reqArray) != 1 {
+							t.Errorf("Expected 1 required field, got %d", len(reqArray))
+						}
+
+						if reqArray[0] != "location" {
+							t.Errorf("Expected required field 'location', got %v", reqArray[0])
+						}
+					}
+				}
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req, err := http.NewRequestWithContext(
+				context.Background(),
+				http.MethodPost,
+				"/v1beta/models/gemini-pro:generateContent",
+				strings.NewReader(tt.request),
+			)
+			if err != nil {
+				t.Fatalf("failed to create request: %v", err)
+			}
+
+			req.Header.Set("Content-Type", "application/json")
+
+			meta := &meta.Meta{
+				ActualModel: "gpt-4o",
+			}
+
+			result, err := openai.ConvertGeminiRequest(meta, req)
+			if err != nil {
+				t.Fatalf("ConvertGeminiRequest failed: %v", err)
+			}
+
+			bodyBytes, _ := io.ReadAll(result.Body)
+
+			var openAIReq relaymodel.GeneralOpenAIRequest
+			if err := json.Unmarshal(bodyBytes, &openAIReq); err != nil {
+				t.Fatalf("failed to unmarshal result body: %v", err)
+			}
+
+			tt.checkFunc(t, openAIReq)
+		})
+	}
+}

+ 486 - 0
core/relay/adaptor/openai/gemini_to_responses_test.go

@@ -0,0 +1,486 @@
+package openai_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/relay/adaptor/openai"
+	"github.com/labring/aiproxy/core/relay/meta"
+	relaymodel "github.com/labring/aiproxy/core/relay/model"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestConvertGeminiToResponsesRequest_WithFunctionCalls(t *testing.T) {
+	// Create a Gemini request with function call and response
+	geminiReq := map[string]any{
+		"contents": []map[string]any{
+			{
+				"role": "user",
+				"parts": []map[string]any{
+					{"text": "使用axum实现一个简单的http api"},
+				},
+			},
+			{
+				"role": "model",
+				"parts": []map[string]any{
+					{
+						"functionCall": map[string]any{
+							"name": "read_file",
+							"args": map[string]any{
+								"file_path": "Cargo.toml",
+								"offset":    0,
+								"limit":     400,
+							},
+						},
+					},
+				},
+			},
+			{
+				"role": "user",
+				"parts": []map[string]any{
+					{
+						"functionResponse": map[string]any{
+							"id":   "read_file-123",
+							"name": "read_file",
+							"response": map[string]any{
+								"output": "[package]\nname = \"test-axum\"\nversion = \"0.1.0\"\n",
+							},
+						},
+					},
+				},
+			},
+		},
+		"systemInstruction": map[string]any{
+			"parts": []map[string]any{
+				{"text": "You are a helpful assistant."},
+			},
+		},
+	}
+
+	reqBody, err := json.Marshal(geminiReq)
+	require.NoError(t, err)
+
+	httpReq := httptest.NewRequest(
+		http.MethodPost,
+		"/v1beta/models/gpt-5-codex:streamGenerateContent",
+		bytes.NewReader(reqBody),
+	)
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	m := &meta.Meta{
+		ActualModel: "gpt-5-codex",
+	}
+
+	// Convert
+	result, err := openai.ConvertGeminiToResponsesRequest(m, httpReq)
+	require.NoError(t, err)
+
+	// Parse result
+	var responsesReq relaymodel.CreateResponseRequest
+
+	err = json.NewDecoder(result.Body).Decode(&responsesReq)
+	require.NoError(t, err)
+
+	// Verify basic fields
+	assert.Equal(t, "gpt-5-codex", responsesReq.Model)
+	assert.True(t, responsesReq.Stream)
+
+	// Verify input structure
+	inputArray, ok := responsesReq.Input.([]any)
+	require.True(t, ok, "Input should be an array")
+	require.Equal(
+		t,
+		4,
+		len(inputArray),
+		"Should have 4 items: system message, user message, function call, function result",
+	)
+
+	// Verify system message
+	systemMsg, ok := inputArray[0].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "message", systemMsg["type"])
+	assert.Equal(t, "system", systemMsg["role"])
+
+	// Verify user message
+	userMsg, ok := inputArray[1].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "message", userMsg["type"])
+	assert.Equal(t, "user", userMsg["role"])
+	userContent, ok := userMsg["content"].([]any)
+	require.True(t, ok)
+	require.NotEmpty(t, userContent)
+	userContentItem, ok := userContent[0].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "input_text", userContentItem["type"])
+	assert.Contains(t, userContentItem["text"], "axum")
+
+	// Verify function call item (separate item, not content within a message)
+	functionCallItem, ok := inputArray[2].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "function_call", functionCallItem["type"], "Item type should be function_call")
+	assert.Equal(t, "read_file", functionCallItem["name"], "Function name should be read_file")
+	assert.NotEmpty(t, functionCallItem["call_id"], "Function call should have a call_id")
+	assert.NotEmpty(t, functionCallItem["arguments"], "Function call should have arguments")
+
+	// Verify arguments contain expected data
+	var args map[string]any
+
+	argsStr, ok := functionCallItem["arguments"].(string)
+	require.True(t, ok)
+
+	err = json.Unmarshal([]byte(argsStr), &args)
+	require.NoError(t, err)
+	assert.Equal(t, "Cargo.toml", args["file_path"])
+	assert.Equal(t, float64(0), args["offset"])
+	assert.Equal(t, float64(400), args["limit"])
+
+	// Verify function call output item (separate item, not content within a message)
+	functionResultItem, ok := inputArray[3].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(
+		t,
+		"function_call_output",
+		functionResultItem["type"],
+		"Item type should be function_call_output",
+	)
+	assert.NotEmpty(t, functionResultItem["call_id"], "Function call output should have call_id")
+	assert.Contains(
+		t,
+		functionResultItem["output"],
+		"test-axum",
+		"Function call output should contain output",
+	)
+	// Verify that function_call_output does NOT have a name field
+	_, hasName := functionResultItem["name"]
+	assert.False(t, hasName, "Function call output should NOT have a name field")
+
+	// Verify that function_call and function_call_output have matching call_id
+	assert.Equal(
+		t,
+		functionCallItem["call_id"],
+		functionResultItem["call_id"],
+		"Function call and output should have matching call_id",
+	)
+}
+
+func TestConvertGeminiToResponsesRequest_WithToolsRequiredField(t *testing.T) {
+	// Create a Gemini request with tools that have null required field
+	geminiReq := map[string]any{
+		"contents": []map[string]any{
+			{
+				"role": "user",
+				"parts": []map[string]any{
+					{"text": "Get diagnostics"},
+				},
+			},
+		},
+		"tools": []map[string]any{
+			{
+				"functionDeclarations": []map[string]any{
+					{
+						"name":        "getDiagnostics",
+						"description": "Get diagnostics",
+						"parameters": map[string]any{
+							"type": "object",
+							"properties": map[string]any{
+								"uri": map[string]any{
+									"type": "string",
+								},
+							},
+							"required": nil, // This should be removed
+						},
+					},
+					{
+						"name":        "getFile",
+						"description": "Get file content",
+						"parameters": map[string]any{
+							"type": "object",
+							"properties": map[string]any{
+								"path": map[string]any{
+									"type": "string",
+								},
+							},
+							"required": []any{}, // This should be removed
+						},
+					},
+					{
+						"name":        "writeFile",
+						"description": "Write to file",
+						"parameters": map[string]any{
+							"type": "object",
+							"properties": map[string]any{
+								"path": map[string]any{
+									"type": "string",
+								},
+								"content": map[string]any{
+									"type": "string",
+								},
+							},
+							"required": []any{"path", "content"}, // This should be kept
+						},
+					},
+				},
+			},
+		},
+	}
+
+	reqBody, err := json.Marshal(geminiReq)
+	require.NoError(t, err)
+
+	httpReq := httptest.NewRequest(
+		http.MethodPost,
+		"/v1beta/models/gpt-5-codex:streamGenerateContent",
+		bytes.NewReader(reqBody),
+	)
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	m := &meta.Meta{
+		ActualModel: "gpt-5-codex",
+	}
+
+	// Convert
+	result, err := openai.ConvertGeminiToResponsesRequest(m, httpReq)
+	require.NoError(t, err)
+
+	// Parse result
+	var responsesReq relaymodel.CreateResponseRequest
+
+	err = json.NewDecoder(result.Body).Decode(&responsesReq)
+	require.NoError(t, err)
+
+	// Verify we have 3 tools
+	require.Len(t, responsesReq.Tools, 3)
+
+	// Verify first tool: required field should be removed (was null)
+	tool1 := responsesReq.Tools[0]
+	assert.Equal(t, "getDiagnostics", tool1.Name)
+
+	if params, ok := tool1.Parameters.(map[string]any); ok {
+		_, hasRequired := params["required"]
+		assert.False(t, hasRequired, "Tool 1: required field should be removed when it's null")
+	}
+
+	// Verify second tool: required field should be removed (was empty array)
+	tool2 := responsesReq.Tools[1]
+	assert.Equal(t, "getFile", tool2.Name)
+
+	if params, ok := tool2.Parameters.(map[string]any); ok {
+		_, hasRequired := params["required"]
+		assert.False(
+			t,
+			hasRequired,
+			"Tool 2: required field should be removed when it's empty array",
+		)
+	}
+
+	// Verify third tool: required field should be kept (has values)
+	tool3 := responsesReq.Tools[2]
+	assert.Equal(t, "writeFile", tool3.Name)
+
+	if params, ok := tool3.Parameters.(map[string]any); ok {
+		required, hasRequired := params["required"]
+		assert.True(t, hasRequired, "Tool 3: required field should be kept when it has values")
+
+		if reqArray, ok := required.([]any); ok {
+			assert.Len(t, reqArray, 2)
+			assert.Contains(t, reqArray, "path")
+			assert.Contains(t, reqArray, "content")
+		}
+	}
+}
+
+func TestConvertGeminiToResponsesRequest_WithoutFunctionCalls(t *testing.T) {
+	// Create a simple Gemini request without function calls
+	geminiReq := map[string]any{
+		"contents": []map[string]any{
+			{
+				"role": "user",
+				"parts": []map[string]any{
+					{"text": "Hello, how are you?"},
+				},
+			},
+		},
+	}
+
+	reqBody, err := json.Marshal(geminiReq)
+	require.NoError(t, err)
+
+	httpReq := httptest.NewRequest(
+		http.MethodPost,
+		"/v1beta/models/gpt-5-codex:streamGenerateContent",
+		bytes.NewReader(reqBody),
+	)
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	m := &meta.Meta{
+		ActualModel: "gpt-5-codex",
+	}
+
+	// Convert
+	result, err := openai.ConvertGeminiToResponsesRequest(m, httpReq)
+	require.NoError(t, err)
+
+	// Parse result
+	var responsesReq relaymodel.CreateResponseRequest
+
+	err = json.NewDecoder(result.Body).Decode(&responsesReq)
+	require.NoError(t, err)
+
+	// Verify input structure
+	inputArray, ok := responsesReq.Input.([]any)
+	require.True(t, ok)
+	require.Len(t, inputArray, 1)
+
+	// Verify user message
+	userMsg, ok := inputArray[0].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "user", userMsg["role"])
+	userContent, ok := userMsg["content"].([]any)
+	require.True(t, ok)
+	require.NotEmpty(t, userContent)
+
+	contentItem, ok := userContent[0].(map[string]any)
+	require.True(t, ok)
+	assert.Equal(t, "input_text", contentItem["type"])
+	assert.Equal(t, "Hello, how are you?", contentItem["text"])
+}
+
+func TestConvertResponsesToGeminiResponse(t *testing.T) {
+	tests := []struct {
+		name            string
+		responsesResp   relaymodel.Response
+		expectedContent string
+		expectedFinish  string
+		hasReasoning    bool
+	}{
+		{
+			name: "basic gemini response",
+			responsesResp: relaymodel.Response{
+				ID:        "resp_gemini_123",
+				Model:     "gpt-5-codex",
+				CreatedAt: 1234567890,
+				Status:    relaymodel.ResponseStatusCompleted,
+				Output: []relaymodel.OutputItem{
+					{
+						Role: "assistant",
+						Content: []relaymodel.OutputContent{
+							{Type: "text", Text: "Hello from Gemini!"},
+						},
+					},
+				},
+				Usage: &relaymodel.ResponseUsage{
+					InputTokens:  12,
+					OutputTokens: 18,
+					TotalTokens:  30,
+				},
+			},
+			expectedContent: "Hello from Gemini!",
+			expectedFinish:  "STOP",
+			hasReasoning:    false,
+		},
+		{
+			name: "gemini response with reasoning",
+			responsesResp: relaymodel.Response{
+				ID:        "resp_gemini_reasoning",
+				Model:     "gpt-5-codex",
+				CreatedAt: 1234567890,
+				Status:    relaymodel.ResponseStatusCompleted,
+				Output: []relaymodel.OutputItem{
+					{
+						Type: "reasoning",
+						Content: []relaymodel.OutputContent{
+							{Type: "output_text", Text: "Let me think about this..."},
+						},
+					},
+					{
+						Role: "assistant",
+						Content: []relaymodel.OutputContent{
+							{Type: "text", Text: "Here's my answer!"},
+						},
+					},
+				},
+				Usage: &relaymodel.ResponseUsage{
+					InputTokens:  12,
+					OutputTokens: 18,
+					TotalTokens:  30,
+				},
+			},
+			expectedContent: "Here's my answer!",
+			expectedFinish:  "STOP",
+			hasReasoning:    true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create mock response
+			respBody, err := json.Marshal(tt.responsesResp)
+			require.NoError(t, err)
+
+			httpResp := &http.Response{
+				StatusCode: http.StatusOK,
+				Body:       http.NoBody,
+				Header:     make(http.Header),
+			}
+			httpResp.Body = &mockReadCloser{Reader: bytes.NewReader(respBody)}
+
+			// Create gin context
+			gin.SetMode(gin.TestMode)
+
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+
+			m := &meta.Meta{
+				ActualModel: tt.responsesResp.Model,
+			}
+
+			// Convert
+			usage, err := openai.ConvertResponsesToGeminiResponse(m, c, httpResp)
+			require.Nil(t, err)
+
+			// Parse response
+			var geminiResp relaymodel.GeminiChatResponse
+
+			err = json.Unmarshal(w.Body.Bytes(), &geminiResp)
+			require.NoError(t, err)
+
+			// Verify
+			assert.Equal(t, tt.responsesResp.Model, geminiResp.ModelVersion)
+			assert.NotEmpty(t, geminiResp.Candidates)
+
+			if tt.hasReasoning {
+				// Should have multiple candidates
+				assert.GreaterOrEqual(t, len(geminiResp.Candidates), 2)
+
+				// First candidate should be reasoning (thought)
+				reasoningCandidate := geminiResp.Candidates[0]
+				assert.NotEmpty(t, reasoningCandidate.Content.Parts)
+				assert.True(t, reasoningCandidate.Content.Parts[0].Thought)
+				assert.Equal(
+					t,
+					"Let me think about this...",
+					reasoningCandidate.Content.Parts[0].Text,
+				)
+
+				// Second candidate should be the actual response
+				answerCandidate := geminiResp.Candidates[1]
+				assert.Equal(t, tt.expectedFinish, answerCandidate.FinishReason)
+				assert.NotEmpty(t, answerCandidate.Content.Parts)
+				assert.Equal(t, tt.expectedContent, answerCandidate.Content.Parts[0].Text)
+			} else {
+				candidate := geminiResp.Candidates[0]
+				assert.Equal(t, tt.expectedFinish, candidate.FinishReason)
+				assert.NotEmpty(t, candidate.Content.Parts)
+				assert.Equal(t, tt.expectedContent, candidate.Content.Parts[0].Text)
+			}
+
+			assert.NotNil(t, usage)
+			assert.Equal(t, tt.responsesResp.Usage.TotalTokens, int64(usage.TotalTokens))
+		})
+	}
+}

+ 318 - 0
core/relay/adaptor/openai/integration_test.go

@@ -0,0 +1,318 @@
+package openai_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/relay/adaptor"
+	"github.com/labring/aiproxy/core/relay/adaptor/openai"
+	"github.com/labring/aiproxy/core/relay/meta"
+	"github.com/labring/aiproxy/core/relay/mode"
+	relaymodel "github.com/labring/aiproxy/core/relay/model"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestIntegrationChatCompletionToResponsesFlow tests the complete flow:
+// 1. ChatCompletion request -> Responses API request conversion
+// 2. Mock Responses API response
+// 3. Responses API response -> ChatCompletion response conversion
+func TestIntegrationChatCompletionToResponsesFlow(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	// Step 1: Create ChatCompletion request
+	chatReq := relaymodel.GeneralOpenAIRequest{
+		Model: "gpt-5-codex",
+		Messages: []relaymodel.Message{
+			{Role: "system", Content: "You are a helpful assistant."},
+			{Role: "user", Content: "What is 2+2?"},
+		},
+		Temperature: floatPtr(0.7),
+		MaxTokens:   100,
+	}
+
+	reqBody, err := json.Marshal(chatReq)
+	require.NoError(t, err)
+
+	httpReq := httptest.NewRequest(
+		http.MethodPost,
+		"/v1/chat/completions",
+		bytes.NewReader(reqBody),
+	)
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	m := &meta.Meta{
+		ActualModel: "gpt-5-codex",
+		Mode:        mode.ChatCompletions,
+	}
+
+	// Step 2: Convert to Responses API request
+	convertResult, err := openai.ConvertChatCompletionToResponsesRequest(m, httpReq)
+	require.NoError(t, err)
+
+	var responsesReq relaymodel.CreateResponseRequest
+
+	err = json.NewDecoder(convertResult.Body).Decode(&responsesReq)
+	require.NoError(t, err)
+
+	// Verify conversion
+	assert.Equal(t, "gpt-5-codex", responsesReq.Model)
+
+	// Verify input is an array with 2 messages
+	if inputArray, ok := responsesReq.Input.([]any); ok {
+		assert.Equal(t, 2, len(inputArray))
+	} else {
+		t.Errorf("Input is not an array")
+	}
+
+	assert.NotNil(t, responsesReq.Store)
+	assert.False(t, *responsesReq.Store, "Store should be false")
+	assert.NotNil(t, responsesReq.Temperature)
+	assert.Equal(t, 0.7, *responsesReq.Temperature)
+
+	// Step 3: Mock Responses API response
+	mockResponse := relaymodel.Response{
+		ID:        "resp_test_123",
+		Model:     "gpt-5-codex",
+		CreatedAt: 1234567890,
+		Status:    relaymodel.ResponseStatusCompleted,
+		Output: []relaymodel.OutputItem{
+			{
+				Role: "assistant",
+				Content: []relaymodel.OutputContent{
+					{Type: "text", Text: "2 + 2 equals 4."},
+				},
+			},
+		},
+		Usage: &relaymodel.ResponseUsage{
+			InputTokens:  25,
+			OutputTokens: 10,
+			TotalTokens:  35,
+		},
+	}
+
+	respBody, err := json.Marshal(mockResponse)
+	require.NoError(t, err)
+
+	httpResp := &http.Response{
+		StatusCode: http.StatusOK,
+		Body:       &mockReadCloser{Reader: bytes.NewReader(respBody)},
+		Header:     make(http.Header),
+	}
+
+	// Step 4: Convert back to ChatCompletion response
+	w := httptest.NewRecorder()
+	c, _ := gin.CreateTestContext(w)
+
+	usage, err := openai.ConvertResponsesToChatCompletionResponse(m, c, httpResp)
+	require.Nil(t, err)
+
+	// Step 5: Verify final ChatCompletion response
+	var finalResp relaymodel.TextResponse
+
+	err = json.Unmarshal(w.Body.Bytes(), &finalResp)
+	require.NoError(t, err)
+
+	assert.Equal(t, "chat.completion", finalResp.Object)
+	assert.Equal(t, "resp_test_123", finalResp.ID)
+	assert.Equal(t, "gpt-5-codex", finalResp.Model)
+	assert.NotEmpty(t, finalResp.Choices)
+	assert.Contains(t, finalResp.Choices[0].Message.Content, "2 + 2 equals 4")
+	assert.Equal(t, int64(25), int64(usage.InputTokens))
+	assert.Equal(t, int64(10), int64(usage.OutputTokens))
+}
+
+// TestIntegrationModelDetection tests that IsResponsesOnlyModel correctly
+// determines when to use conversion
+func TestIntegrationModelDetection(t *testing.T) {
+	tests := []struct {
+		name          string
+		model         string
+		shouldConvert bool
+	}{
+		{
+			name:          "gpt-5-codex should convert",
+			model:         "gpt-5-codex",
+			shouldConvert: true,
+		},
+		{
+			name:          "gpt-5-pro should convert",
+			model:         "gpt-5-pro",
+			shouldConvert: true,
+		},
+		{
+			name:          "gpt-4o should not convert",
+			model:         "gpt-4o",
+			shouldConvert: false,
+		},
+		{
+			name:          "gpt-3.5-turbo should not convert",
+			model:         "gpt-3.5-turbo",
+			shouldConvert: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := openai.IsResponsesOnlyModel(nil, tt.model)
+			assert.Equal(t, tt.shouldConvert, result)
+		})
+	}
+}
+
+// TestIntegrationGetRequestURL tests that GetRequestURL returns correct URL
+func TestIntegrationGetRequestURL(t *testing.T) {
+	adaptor := &openai.Adaptor{}
+
+	tests := []struct {
+		name        string
+		model       string
+		mode        mode.Mode
+		expectedURL string
+	}{
+		{
+			name:        "gpt-5-codex with ChatCompletions should use /responses",
+			model:       "gpt-5-codex",
+			mode:        mode.ChatCompletions,
+			expectedURL: "/responses",
+		},
+		{
+			name:        "gpt-5-pro with ChatCompletions should use /responses",
+			model:       "gpt-5-pro",
+			mode:        mode.ChatCompletions,
+			expectedURL: "/responses",
+		},
+		{
+			name:        "gpt-4o with ChatCompletions should use /chat/completions",
+			model:       "gpt-4o",
+			mode:        mode.ChatCompletions,
+			expectedURL: "/chat/completions",
+		},
+		{
+			name:        "gpt-5-codex with Anthropic mode should use /responses",
+			model:       "gpt-5-codex",
+			mode:        mode.Anthropic,
+			expectedURL: "/responses",
+		},
+		{
+			name:        "gpt-5-codex with Gemini mode should use /responses",
+			model:       "gpt-5-codex",
+			mode:        mode.Gemini,
+			expectedURL: "/responses",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := &meta.Meta{
+				ActualModel: tt.model,
+				Mode:        tt.mode,
+			}
+			m.Channel.BaseURL = "https://api.openai.com"
+
+			result, err := adaptor.GetRequestURL(m, nil, nil)
+			require.NoError(t, err)
+			assert.Contains(t, result.URL, tt.expectedURL)
+			assert.Equal(t, http.MethodPost, result.Method)
+		})
+	}
+}
+
+// TestIntegrationConvertRequestWithDifferentModes tests conversion across different modes
+func TestIntegrationConvertRequestWithDifferentModes(t *testing.T) {
+	tests := []struct {
+		name          string
+		mode          mode.Mode
+		model         string
+		requestBody   string
+		shouldConvert bool
+	}{
+		{
+			name:  "ChatCompletions mode with gpt-5-codex",
+			mode:  mode.ChatCompletions,
+			model: "gpt-5-codex",
+			requestBody: `{
+				"model": "gpt-5-codex",
+				"messages": [{"role": "user", "content": "Hello"}]
+			}`,
+			shouldConvert: true,
+		},
+		{
+			name:  "ChatCompletions mode with gpt-4o",
+			mode:  mode.ChatCompletions,
+			model: "gpt-4o",
+			requestBody: `{
+				"model": "gpt-4o",
+				"messages": [{"role": "user", "content": "Hello"}]
+			}`,
+			shouldConvert: false,
+		},
+		{
+			name:  "Anthropic mode with gpt-5-codex",
+			mode:  mode.Anthropic,
+			model: "gpt-5-codex",
+			requestBody: `{
+				"model": "gpt-5-codex",
+				"messages": [{"role": "user", "content": "Hello"}],
+				"max_tokens": 1024
+			}`,
+			shouldConvert: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpReq := httptest.NewRequest(
+				http.MethodPost,
+				"/test",
+				bytes.NewReader([]byte(tt.requestBody)),
+			)
+			httpReq.Header.Set("Content-Type", "application/json")
+
+			m := &meta.Meta{
+				ActualModel: tt.model,
+				Mode:        tt.mode,
+			}
+
+			var (
+				result adaptor.ConvertResult
+				err    error
+			)
+
+			// Call the appropriate conversion based on mode
+
+			switch tt.mode {
+			case mode.ChatCompletions:
+				if openai.IsResponsesOnlyModel(&m.ModelConfig, tt.model) {
+					result, err = openai.ConvertChatCompletionToResponsesRequest(m, httpReq)
+				} else {
+					result, err = openai.ConvertChatCompletionsRequest(m, httpReq, false)
+				}
+			case mode.Anthropic:
+				if openai.IsResponsesOnlyModel(&m.ModelConfig, tt.model) {
+					result, err = openai.ConvertClaudeToResponsesRequest(m, httpReq)
+				} else {
+					result, err = openai.ConvertClaudeRequest(m, httpReq)
+				}
+			}
+
+			require.NoError(t, err)
+
+			// If should convert to Responses API, verify the result contains expected fields
+			if tt.shouldConvert {
+				var responsesReq relaymodel.CreateResponseRequest
+
+				err = json.NewDecoder(result.Body).Decode(&responsesReq)
+				require.NoError(t, err)
+
+				assert.Equal(t, tt.model, responsesReq.Model)
+				assert.NotNil(t, responsesReq.Store)
+				assert.False(t, *responsesReq.Store)
+			}
+		})
+	}
+}

+ 43 - 0
core/relay/model/chat.go

@@ -67,6 +67,49 @@ func (u ChatUsage) ToClaudeUsage() ClaudeUsage {
 	return cu
 }
 
+// ToResponseUsage converts ChatUsage to ResponseUsage (OpenAI Responses API format)
+func (u ChatUsage) ToResponseUsage() ResponseUsage {
+	usage := ResponseUsage{
+		InputTokens:  u.PromptTokens,
+		OutputTokens: u.CompletionTokens,
+		TotalTokens:  u.TotalTokens,
+	}
+
+	if u.PromptTokensDetails != nil &&
+		(u.PromptTokensDetails.CachedTokens > 0 || u.PromptTokensDetails.CacheCreationTokens > 0) {
+		usage.InputTokensDetails = &ResponseUsageDetails{
+			CachedTokens: u.PromptTokensDetails.CachedTokens,
+		}
+	}
+
+	if u.CompletionTokensDetails != nil && u.CompletionTokensDetails.ReasoningTokens > 0 {
+		usage.OutputTokensDetails = &ResponseUsageDetails{
+			ReasoningTokens: u.CompletionTokensDetails.ReasoningTokens,
+		}
+	}
+
+	return usage
+}
+
+// ToGeminiUsage converts ChatUsage to GeminiUsageMetadata (Google Gemini format)
+func (u ChatUsage) ToGeminiUsage() GeminiUsageMetadata {
+	usage := GeminiUsageMetadata{
+		PromptTokenCount:     u.PromptTokens,
+		CandidatesTokenCount: u.CompletionTokens,
+		TotalTokenCount:      u.TotalTokens,
+	}
+
+	if u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens > 0 {
+		usage.CachedContentTokenCount = u.PromptTokensDetails.CachedTokens
+	}
+
+	if u.CompletionTokensDetails != nil && u.CompletionTokensDetails.ReasoningTokens > 0 {
+		usage.ThoughtsTokenCount = u.CompletionTokensDetails.ReasoningTokens
+	}
+
+	return usage
+}
+
 type PromptTokensDetails struct {
 	CachedTokens        int64 `json:"cached_tokens"`
 	AudioTokens         int64 `json:"audio_tokens"`

+ 79 - 0
core/relay/model/claude.go

@@ -197,6 +197,39 @@ func (u *ClaudeUsage) ToOpenAIUsage() ChatUsage {
 	return usage
 }
 
+// ToResponseUsage converts ClaudeUsage to ResponseUsage (OpenAI Responses API format)
+func (u *ClaudeUsage) ToResponseUsage() ResponseUsage {
+	usage := ResponseUsage{
+		InputTokens:  u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens,
+		OutputTokens: u.OutputTokens,
+	}
+	usage.TotalTokens = usage.InputTokens + usage.OutputTokens
+
+	if u.CacheReadInputTokens > 0 {
+		usage.InputTokensDetails = &ResponseUsageDetails{
+			CachedTokens: u.CacheReadInputTokens,
+		}
+	}
+
+	return usage
+}
+
+// ToGeminiUsage converts ClaudeUsage to GeminiUsageMetadata (Google Gemini format)
+func (u *ClaudeUsage) ToGeminiUsage() GeminiUsageMetadata {
+	totalInput := u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens
+	usage := GeminiUsageMetadata{
+		PromptTokenCount:     totalInput,
+		CandidatesTokenCount: u.OutputTokens,
+		TotalTokenCount:      totalInput + u.OutputTokens,
+	}
+
+	if u.CacheReadInputTokens > 0 {
+		usage.CachedContentTokenCount = u.CacheReadInputTokens
+	}
+
+	return usage
+}
+
 // https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration-beta
 type ClaudeCacheCreation struct {
 	Ephemeral5mInputTokens int64 `json:"ephemeral_5m_input_tokens,omitempty"`
@@ -232,3 +265,49 @@ type ClaudeStreamResponse struct {
 	Type         string          `json:"type"`
 	Index        int             `json:"index"`
 }
+
+// Claude StopReason constants
+const (
+	ClaudeStopReasonEndTurn      = "end_turn"
+	ClaudeStopReasonMaxTokens    = "max_tokens"
+	ClaudeStopReasonToolUse      = "tool_use"
+	ClaudeStopReasonStopSequence = "stop_sequence"
+)
+
+// Claude Type constants
+const (
+	ClaudeTypeMessage = "message"
+)
+
+// Claude Content Type constants
+const (
+	ClaudeContentTypeText       = "text"
+	ClaudeContentTypeThinking   = "thinking"
+	ClaudeContentTypeToolUse    = "tool_use"
+	ClaudeContentTypeToolResult = "tool_result"
+	ClaudeContentTypeImage      = "image"
+)
+
+// Claude Stream Event Type constants
+const (
+	ClaudeStreamTypeMessageStart      = "message_start"
+	ClaudeStreamTypeMessageDelta      = "message_delta"
+	ClaudeStreamTypeMessageStop       = "message_stop"
+	ClaudeStreamTypeContentBlockStart = "content_block_start"
+	ClaudeStreamTypeContentBlockDelta = "content_block_delta"
+	ClaudeStreamTypeContentBlockStop  = "content_block_stop"
+	ClaudeStreamTypePing              = "ping"
+)
+
+// Claude Delta Type constants
+const (
+	ClaudeDeltaTypeTextDelta      = "text_delta"
+	ClaudeDeltaTypeThinkingDelta  = "thinking_delta"
+	ClaudeDeltaTypeInputJSONDelta = "input_json_delta"
+)
+
+// Claude Image Source Type constants
+const (
+	ClaudeImageSourceTypeBase64 = "base64"
+	ClaudeImageSourceTypeURL    = "url"
+)

+ 22 - 0
core/relay/model/constant.go

@@ -1,5 +1,13 @@
 package model
 
+// Common Role constants (used across different API formats)
+const (
+	RoleSystem    = "system"
+	RoleUser      = "user"
+	RoleAssistant = "assistant"
+	RoleTool      = "tool"
+)
+
 const (
 	ContentTypeText       = "text"
 	ContentTypeImageURL   = "image_url"
@@ -22,3 +30,17 @@ const (
 	FinishReasonToolCalls     FinishReason = "tool_calls"
 	FinishReasonFunctionCall  FinishReason = "function_call"
 )
+
+// Tool Choice constants (used in OpenAI API)
+const (
+	ToolChoiceAuto     = "auto"
+	ToolChoiceNone     = "none"
+	ToolChoiceRequired = "required"
+	ToolChoiceAny      = "any"
+)
+
+// Tool Choice Type constants
+const (
+	ToolChoiceTypeFunction = "function"
+	ToolChoiceTypeTool     = "tool"
+)

+ 78 - 0
core/relay/model/gemini.go

@@ -143,6 +143,43 @@ func (u *GeminiUsageMetadata) ToUsage() ChatUsage {
 	return chatUsage
 }
 
+// ToResponseUsage converts GeminiUsageMetadata to ResponseUsage (OpenAI Responses API format)
+func (u *GeminiUsageMetadata) ToResponseUsage() ResponseUsage {
+	usage := ResponseUsage{
+		InputTokens:  u.PromptTokenCount,
+		OutputTokens: u.CandidatesTokenCount,
+		TotalTokens:  u.TotalTokenCount,
+	}
+
+	if u.CachedContentTokenCount > 0 {
+		usage.InputTokensDetails = &ResponseUsageDetails{
+			CachedTokens: u.CachedContentTokenCount,
+		}
+	}
+
+	if u.ThoughtsTokenCount > 0 {
+		usage.OutputTokensDetails = &ResponseUsageDetails{
+			ReasoningTokens: u.ThoughtsTokenCount,
+		}
+	}
+
+	return usage
+}
+
+// ToClaudeUsage converts GeminiUsageMetadata to ClaudeUsage (Anthropic Claude format)
+func (u *GeminiUsageMetadata) ToClaudeUsage() ClaudeUsage {
+	usage := ClaudeUsage{
+		InputTokens:  u.PromptTokenCount,
+		OutputTokens: u.CandidatesTokenCount,
+	}
+
+	if u.CachedContentTokenCount > 0 {
+		usage.CacheReadInputTokens = u.CachedContentTokenCount
+	}
+
+	return usage
+}
+
 type GeminiError struct {
 	Message string `json:"message,omitempty"`
 	Status  string `json:"status,omitempty"`
@@ -158,3 +195,44 @@ func NewGeminiError(statusCode int, err GeminiError) adaptor.Error {
 		Error: err,
 	})
 }
+
+// Gemini Role constants
+const (
+	GeminiRoleModel = "model"
+	GeminiRoleUser  = "user"
+)
+
+// Gemini Finish Reason constants
+const (
+	GeminiFinishReasonStop         = "STOP"
+	GeminiFinishReasonMaxTokens    = "MAX_TOKENS"
+	GeminiFinishReasonSafety       = "SAFETY"
+	GeminiFinishReasonRecitation   = "RECITATION"
+	GeminiFinishReasonOther        = "OTHER"
+	GeminiFinishReasonToolCalls    = "TOOL_CALLS"
+	GeminiFinishReasonFunctionCall = "FUNCTION_CALL"
+)
+
+// Gemini FunctionCallingConfig Mode constants
+const (
+	GeminiFunctionCallingModeAuto = "AUTO"
+	GeminiFunctionCallingModeAny  = "ANY"
+	GeminiFunctionCallingModeNone = "NONE"
+)
+
+// Gemini Safety Setting Category constants
+const (
+	GeminiSafetyCategoryHarassment       = "HARM_CATEGORY_HARASSMENT"
+	GeminiSafetyCategoryHateSpeech       = "HARM_CATEGORY_HATE_SPEECH"
+	GeminiSafetyCategorySexuallyExplicit = "HARM_CATEGORY_SEXUALLY_EXPLICIT"
+	GeminiSafetyCategoryDangerousContent = "HARM_CATEGORY_DANGEROUS_CONTENT"
+	GeminiSafetyCategoryCivicIntegrity   = "HARM_CATEGORY_CIVIC_INTEGRITY"
+)
+
+// Gemini Safety Setting Threshold constants
+const (
+	GeminiSafetyThresholdBlockNone           = "BLOCK_NONE"
+	GeminiSafetyThresholdBlockLowAndAbove    = "BLOCK_LOW_AND_ABOVE"
+	GeminiSafetyThresholdBlockMediumAndAbove = "BLOCK_MEDIUM_AND_ABOVE"
+	GeminiSafetyThresholdBlockOnlyHigh       = "BLOCK_ONLY_HIGH"
+)

+ 221 - 20
core/relay/model/response.go

@@ -4,8 +4,33 @@ import (
 	"github.com/labring/aiproxy/core/model"
 )
 
+// InputItemType represents the type of an input item
+type InputItemType = string
+
+const (
+	InputItemTypeMessage            InputItemType = "message"
+	InputItemTypeFunctionCall       InputItemType = "function_call"
+	InputItemTypeFunctionCallOutput InputItemType = "function_call_output"
+)
+
+// InputContentType represents the type of input content
+type InputContentType = string
+
+const (
+	InputContentTypeInputText  InputContentType = "input_text"
+	InputContentTypeOutputText InputContentType = "output_text"
+)
+
+// OutputContentType represents the type of output content
+type OutputContentType = string
+
+const (
+	OutputContentTypeText       OutputContentType = "text"
+	OutputContentTypeOutputText OutputContentType = "output_text"
+)
+
 // ResponseStatus represents the status of a response
-type ResponseStatus string
+type ResponseStatus = string
 
 const (
 	ResponseStatusInProgress ResponseStatus = "in_progress"
@@ -15,12 +40,100 @@ const (
 	ResponseStatusCancelled  ResponseStatus = "cancelled"
 )
 
+// ResponseStreamEventType represents the type of a response stream event
+type ResponseStreamEventType = string
+
+const (
+	// Response lifecycle events
+	EventResponseCreated    ResponseStreamEventType = "response.created"
+	EventResponseInProgress ResponseStreamEventType = "response.in_progress"
+	EventResponseCompleted  ResponseStreamEventType = "response.completed"
+	EventResponseFailed     ResponseStreamEventType = "response.failed"
+	EventResponseIncomplete ResponseStreamEventType = "response.incomplete"
+	EventResponseQueued     ResponseStreamEventType = "response.queued"
+	EventResponseDone       ResponseStreamEventType = "response.done" // Legacy/compatibility
+
+	// Output item events
+	EventOutputItemAdded ResponseStreamEventType = "response.output_item.added"
+	EventOutputItemDone  ResponseStreamEventType = "response.output_item.done"
+
+	// Content part events
+	EventContentPartAdded ResponseStreamEventType = "response.content_part.added"
+	EventContentPartDone  ResponseStreamEventType = "response.content_part.done"
+
+	// Text output events
+	EventOutputTextDelta ResponseStreamEventType = "response.output_text.delta"
+	EventOutputTextDone  ResponseStreamEventType = "response.output_text.done"
+
+	// Refusal events
+	EventRefusalDelta ResponseStreamEventType = "response.refusal.delta"
+	EventRefusalDone  ResponseStreamEventType = "response.refusal.done"
+
+	// Function call events
+	EventFunctionCallArgumentsDelta ResponseStreamEventType = "response.function_call_arguments.delta"
+	EventFunctionCallArgumentsDone  ResponseStreamEventType = "response.function_call_arguments.done"
+
+	// Reasoning events
+	EventReasoningSummaryPartAdded ResponseStreamEventType = "response.reasoning_summary_part.added"
+	EventReasoningSummaryPartDone  ResponseStreamEventType = "response.reasoning_summary_part.done"
+	EventReasoningSummaryTextDelta ResponseStreamEventType = "response.reasoning_summary_text.delta"
+	EventReasoningSummaryTextDone  ResponseStreamEventType = "response.reasoning_summary_text.done"
+	EventReasoningTextDelta        ResponseStreamEventType = "response.reasoning_text.delta"
+	EventReasoningTextDone         ResponseStreamEventType = "response.reasoning_text.done"
+
+	// Tool call events
+	EventFileSearchCallInProgress ResponseStreamEventType = "response.file_search_call.in_progress"
+	EventFileSearchCallSearching  ResponseStreamEventType = "response.file_search_call.searching"
+	EventFileSearchCallCompleted  ResponseStreamEventType = "response.file_search_call.completed"
+
+	EventWebSearchCallInProgress ResponseStreamEventType = "response.web_search_call.in_progress"
+	EventWebSearchCallSearching  ResponseStreamEventType = "response.web_search_call.searching"
+	EventWebSearchCallCompleted  ResponseStreamEventType = "response.web_search_call.completed"
+
+	EventCodeInterpreterCallInProgress   ResponseStreamEventType = "response.code_interpreter_call.in_progress"
+	EventCodeInterpreterCallInterpreting ResponseStreamEventType = "response.code_interpreter_call.interpreting"
+	EventCodeInterpreterCallCompleted    ResponseStreamEventType = "response.code_interpreter_call.completed"
+	EventCodeInterpreterCallCodeDelta    ResponseStreamEventType = "response.code_interpreter_call_code.delta"
+	EventCodeInterpreterCallCodeDone     ResponseStreamEventType = "response.code_interpreter_call_code.done"
+
+	EventImageGenerationCallInProgress   ResponseStreamEventType = "response.image_generation_call.in_progress"
+	EventImageGenerationCallGenerating   ResponseStreamEventType = "response.image_generation_call.generating"
+	EventImageGenerationCallCompleted    ResponseStreamEventType = "response.image_generation_call.completed"
+	EventImageGenerationCallPartialImage ResponseStreamEventType = "response.image_generation_call.partial_image"
+
+	EventMCPCallInProgress      ResponseStreamEventType = "response.mcp_call.in_progress"
+	EventMCPCallCompleted       ResponseStreamEventType = "response.mcp_call.completed"
+	EventMCPCallFailed          ResponseStreamEventType = "response.mcp_call.failed"
+	EventMCPCallArgumentsDelta  ResponseStreamEventType = "response.mcp_call_arguments.delta"
+	EventMCPCallArgumentsDone   ResponseStreamEventType = "response.mcp_call_arguments.done"
+	EventMCPListToolsInProgress ResponseStreamEventType = "response.mcp_list_tools.in_progress"
+	EventMCPListToolsCompleted  ResponseStreamEventType = "response.mcp_list_tools.completed"
+	EventMCPListToolsFailed     ResponseStreamEventType = "response.mcp_list_tools.failed"
+
+	EventCustomToolCallInputDelta ResponseStreamEventType = "response.custom_tool_call_input.delta"
+	EventCustomToolCallInputDone  ResponseStreamEventType = "response.custom_tool_call_input.done"
+
+	// Annotation events
+	EventOutputTextAnnotationAdded ResponseStreamEventType = "response.output_text.annotation.added"
+
+	// Error event
+	EventError ResponseStreamEventType = "error"
+)
+
 // ResponseError represents an error in a response
 type ResponseError struct {
 	Code    string `json:"code"`
 	Message string `json:"message"`
 }
 
+// ResponseTool represents a tool in the Responses API format (flattened structure)
+type ResponseTool struct {
+	Type        string `json:"type"`
+	Name        string `json:"name,omitempty"`
+	Description string `json:"description,omitempty"`
+	Parameters  any    `json:"parameters,omitempty"`
+}
+
 // IncompleteDetails represents details about why a response is incomplete
 type IncompleteDetails struct {
 	Reason string `json:"reason"`
@@ -51,25 +164,42 @@ type OutputContent struct {
 
 // OutputItem represents an output item in a response
 type OutputItem struct {
-	ID      string          `json:"id"`
-	Type    string          `json:"type"`
-	Status  ResponseStatus  `json:"status,omitempty"`
-	Role    string          `json:"role"`
-	Content []OutputContent `json:"content"`
+	ID        string          `json:"id"`
+	Type      string          `json:"type"`
+	Status    ResponseStatus  `json:"status,omitempty"`
+	Role      string          `json:"role,omitempty"`
+	Content   []OutputContent `json:"content,omitempty"`
+	Arguments string          `json:"arguments,omitempty"` // For function_call type
+	CallID    string          `json:"call_id,omitempty"`   // For function_call type
+	Name      string          `json:"name,omitempty"`      // For function_call type
+	Summary   []string        `json:"summary,omitempty"`   // For reasoning type
 }
 
 // InputContent represents content in an input item
 type InputContent struct {
 	Type string `json:"type"`
 	Text string `json:"text,omitempty"`
+	// Fields for function_call type
+	ID        string `json:"id,omitempty"`
+	Name      string `json:"name,omitempty"`
+	Arguments string `json:"arguments,omitempty"`
+	// Fields for function_result type
+	CallID string `json:"call_id,omitempty"`
+	Output string `json:"output,omitempty"`
 }
 
 // InputItem represents an input item
 type InputItem struct {
-	ID      string         `json:"id"`
+	ID      string         `json:"id,omitempty"`
 	Type    string         `json:"type"`
-	Role    string         `json:"role"`
-	Content []InputContent `json:"content"`
+	Role    string         `json:"role,omitempty"`
+	Content []InputContent `json:"content,omitempty"`
+	// Fields for function_call type
+	Name      string `json:"name,omitempty"`
+	Arguments string `json:"arguments,omitempty"`
+	// Fields for function_result type
+	CallID string `json:"call_id,omitempty"`
+	Output string `json:"output,omitempty"`
 }
 
 // ResponseUsageDetails represents detailed token usage information
@@ -106,7 +236,7 @@ type Response struct {
 	Temperature        float64            `json:"temperature"`
 	Text               ResponseText       `json:"text"`
 	ToolChoice         any                `json:"tool_choice"`
-	Tools              []Tool             `json:"tools"`
+	Tools              []ResponseTool     `json:"tools"`
 	TopP               float64            `json:"top_p"`
 	Truncation         string             `json:"truncation"`
 	Usage              *ResponseUsage     `json:"usage"`
@@ -117,20 +247,29 @@ type Response struct {
 // CreateResponseRequest represents a request to create a response
 type CreateResponseRequest struct {
 	Model              string         `json:"model"`
-	Messages           []Message      `json:"messages"`
+	Input              any            `json:"input"`
+	Background         *bool          `json:"background,omitempty"`
+	Conversation       any            `json:"conversation,omitempty"` // string or object
+	Include            []string       `json:"include,omitempty"`
 	Instructions       *string        `json:"instructions,omitempty"`
 	MaxOutputTokens    *int           `json:"max_output_tokens,omitempty"`
+	MaxToolCalls       *int           `json:"max_tool_calls,omitempty"`
+	Metadata           map[string]any `json:"metadata,omitempty"`
 	ParallelToolCalls  *bool          `json:"parallel_tool_calls,omitempty"`
 	PreviousResponseID *string        `json:"previous_response_id,omitempty"`
+	PromptCacheKey     *string        `json:"prompt_cache_key,omitempty"`
+	SafetyIdentifier   *string        `json:"safety_identifier,omitempty"`
+	ServiceTier        *string        `json:"service_tier,omitempty"`
 	Store              *bool          `json:"store,omitempty"`
+	Stream             bool           `json:"stream,omitempty"`
 	Temperature        *float64       `json:"temperature,omitempty"`
+	Text               *ResponseText  `json:"text,omitempty"`
 	ToolChoice         any            `json:"tool_choice,omitempty"`
-	Tools              []Tool         `json:"tools,omitempty"`
+	Tools              []ResponseTool `json:"tools,omitempty"`
+	TopLogprobs        *int           `json:"top_logprobs,omitempty"`
 	TopP               *float64       `json:"top_p,omitempty"`
 	Truncation         *string        `json:"truncation,omitempty"`
-	User               *string        `json:"user,omitempty"`
-	Metadata           map[string]any `json:"metadata,omitempty"`
-	Stream             bool           `json:"stream,omitempty"`
+	User               *string        `json:"user,omitempty"` // Deprecated, use prompt_cache_key
 }
 
 // InputItemList represents a list of input items
@@ -144,11 +283,17 @@ type InputItemList struct {
 
 // ResponseStreamEvent represents a server-sent event for response streaming
 type ResponseStreamEvent struct {
-	Type           string      `json:"type"`
-	Response       *Response   `json:"response,omitempty"`
-	OutputIndex    *int        `json:"output_index,omitempty"`
-	Item           *OutputItem `json:"item,omitempty"`
-	SequenceNumber int         `json:"sequence_number,omitempty"`
+	Type           string         `json:"type"`
+	Response       *Response      `json:"response,omitempty"`
+	OutputIndex    *int           `json:"output_index,omitempty"`
+	Item           *OutputItem    `json:"item,omitempty"`
+	ItemID         string         `json:"item_id,omitempty"`
+	ContentIndex   *int           `json:"content_index,omitempty"`
+	Part           *OutputContent `json:"part,omitempty"`      // For content_part events
+	Delta          string         `json:"delta,omitempty"`     // For text.delta, function_call_arguments.delta
+	Text           string         `json:"text,omitempty"`      // For text content
+	Arguments      string         `json:"arguments,omitempty"` // For function_call_arguments.done
+	SequenceNumber int            `json:"sequence_number,omitempty"`
 }
 
 func (u *ResponseUsage) ToModelUsage() model.Usage {
@@ -168,3 +313,59 @@ func (u *ResponseUsage) ToModelUsage() model.Usage {
 
 	return usage
 }
+
+// ToChatUsage converts ResponseUsage to ChatUsage (OpenAI Chat Completions format)
+func (u *ResponseUsage) ToChatUsage() ChatUsage {
+	usage := ChatUsage{
+		PromptTokens:     u.InputTokens,
+		CompletionTokens: u.OutputTokens,
+		TotalTokens:      u.TotalTokens,
+	}
+
+	if u.InputTokensDetails != nil && u.InputTokensDetails.CachedTokens > 0 {
+		usage.PromptTokensDetails = &PromptTokensDetails{
+			CachedTokens: u.InputTokensDetails.CachedTokens,
+		}
+	}
+
+	if u.OutputTokensDetails != nil && u.OutputTokensDetails.ReasoningTokens > 0 {
+		usage.CompletionTokensDetails = &CompletionTokensDetails{
+			ReasoningTokens: u.OutputTokensDetails.ReasoningTokens,
+		}
+	}
+
+	return usage
+}
+
+// ToClaudeUsage converts ResponseUsage to ClaudeUsage (Anthropic Claude format)
+func (u *ResponseUsage) ToClaudeUsage() ClaudeUsage {
+	usage := ClaudeUsage{
+		InputTokens:  u.InputTokens,
+		OutputTokens: u.OutputTokens,
+	}
+
+	if u.InputTokensDetails != nil && u.InputTokensDetails.CachedTokens > 0 {
+		usage.CacheReadInputTokens = u.InputTokensDetails.CachedTokens
+	}
+
+	return usage
+}
+
+// ToGeminiUsage converts ResponseUsage to GeminiUsageMetadata (Google Gemini format)
+func (u *ResponseUsage) ToGeminiUsage() GeminiUsageMetadata {
+	usage := GeminiUsageMetadata{
+		PromptTokenCount:     u.InputTokens,
+		CandidatesTokenCount: u.OutputTokens,
+		TotalTokenCount:      u.TotalTokens,
+	}
+
+	if u.InputTokensDetails != nil && u.InputTokensDetails.CachedTokens > 0 {
+		usage.CachedContentTokenCount = u.InputTokensDetails.CachedTokens
+	}
+
+	if u.OutputTokensDetails != nil && u.OutputTokensDetails.ReasoningTokens > 0 {
+		usage.ThoughtsTokenCount = u.OutputTokensDetails.ReasoningTokens
+	}
+
+	return usage
+}

+ 229 - 0
core/relay/model/usage_conversion_test.go

@@ -0,0 +1,229 @@
+package model_test
+
+import (
+	"testing"
+
+	"github.com/labring/aiproxy/core/relay/model"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestChatUsageConversions(t *testing.T) {
+	chatUsage := model.ChatUsage{
+		PromptTokens:     100,
+		CompletionTokens: 50,
+		TotalTokens:      150,
+		PromptTokensDetails: &model.PromptTokensDetails{
+			CachedTokens:        20,
+			CacheCreationTokens: 10,
+		},
+		CompletionTokensDetails: &model.CompletionTokensDetails{
+			ReasoningTokens: 30,
+		},
+	}
+
+	t.Run("ChatUsage to ResponseUsage", func(t *testing.T) {
+		responseUsage := chatUsage.ToResponseUsage()
+		assert.Equal(t, int64(100), responseUsage.InputTokens)
+		assert.Equal(t, int64(50), responseUsage.OutputTokens)
+		assert.Equal(t, int64(150), responseUsage.TotalTokens)
+		assert.NotNil(t, responseUsage.InputTokensDetails)
+		assert.Equal(t, int64(20), responseUsage.InputTokensDetails.CachedTokens)
+		assert.NotNil(t, responseUsage.OutputTokensDetails)
+		assert.Equal(t, int64(30), responseUsage.OutputTokensDetails.ReasoningTokens)
+	})
+
+	t.Run("ChatUsage to ClaudeUsage", func(t *testing.T) {
+		claudeUsage := chatUsage.ToClaudeUsage()
+		assert.Equal(t, int64(100), claudeUsage.InputTokens)
+		assert.Equal(t, int64(50), claudeUsage.OutputTokens)
+		assert.Equal(t, int64(10), claudeUsage.CacheCreationInputTokens)
+		assert.Equal(t, int64(20), claudeUsage.CacheReadInputTokens)
+	})
+
+	t.Run("ChatUsage to GeminiUsage", func(t *testing.T) {
+		geminiUsage := chatUsage.ToGeminiUsage()
+		assert.Equal(t, int64(100), geminiUsage.PromptTokenCount)
+		assert.Equal(t, int64(50), geminiUsage.CandidatesTokenCount)
+		assert.Equal(t, int64(150), geminiUsage.TotalTokenCount)
+		assert.Equal(t, int64(20), geminiUsage.CachedContentTokenCount)
+		assert.Equal(t, int64(30), geminiUsage.ThoughtsTokenCount)
+	})
+}
+
+func TestResponseUsageConversions(t *testing.T) {
+	responseUsage := model.ResponseUsage{
+		InputTokens:  100,
+		OutputTokens: 50,
+		TotalTokens:  150,
+		InputTokensDetails: &model.ResponseUsageDetails{
+			CachedTokens: 20,
+		},
+		OutputTokensDetails: &model.ResponseUsageDetails{
+			ReasoningTokens: 30,
+		},
+	}
+
+	t.Run("ResponseUsage to ChatUsage", func(t *testing.T) {
+		chatUsage := responseUsage.ToChatUsage()
+		assert.Equal(t, int64(100), chatUsage.PromptTokens)
+		assert.Equal(t, int64(50), chatUsage.CompletionTokens)
+		assert.Equal(t, int64(150), chatUsage.TotalTokens)
+		assert.NotNil(t, chatUsage.PromptTokensDetails)
+		assert.Equal(t, int64(20), chatUsage.PromptTokensDetails.CachedTokens)
+		assert.NotNil(t, chatUsage.CompletionTokensDetails)
+		assert.Equal(t, int64(30), chatUsage.CompletionTokensDetails.ReasoningTokens)
+	})
+
+	t.Run("ResponseUsage to ClaudeUsage", func(t *testing.T) {
+		claudeUsage := responseUsage.ToClaudeUsage()
+		assert.Equal(t, int64(100), claudeUsage.InputTokens)
+		assert.Equal(t, int64(50), claudeUsage.OutputTokens)
+		assert.Equal(t, int64(20), claudeUsage.CacheReadInputTokens)
+	})
+
+	t.Run("ResponseUsage to GeminiUsage", func(t *testing.T) {
+		geminiUsage := responseUsage.ToGeminiUsage()
+		assert.Equal(t, int64(100), geminiUsage.PromptTokenCount)
+		assert.Equal(t, int64(50), geminiUsage.CandidatesTokenCount)
+		assert.Equal(t, int64(150), geminiUsage.TotalTokenCount)
+		assert.Equal(t, int64(20), geminiUsage.CachedContentTokenCount)
+		assert.Equal(t, int64(30), geminiUsage.ThoughtsTokenCount)
+	})
+}
+
+func TestClaudeUsageConversions(t *testing.T) {
+	claudeUsage := model.ClaudeUsage{
+		InputTokens:              100,
+		OutputTokens:             50,
+		CacheCreationInputTokens: 10,
+		CacheReadInputTokens:     20,
+	}
+
+	t.Run("ClaudeUsage to ChatUsage", func(t *testing.T) {
+		chatUsage := claudeUsage.ToOpenAIUsage()
+		assert.Equal(t, int64(130), chatUsage.PromptTokens) // 100 + 10 + 20
+		assert.Equal(t, int64(50), chatUsage.CompletionTokens)
+		assert.Equal(t, int64(180), chatUsage.TotalTokens)
+		assert.NotNil(t, chatUsage.PromptTokensDetails)
+		assert.Equal(t, int64(20), chatUsage.PromptTokensDetails.CachedTokens)
+		assert.Equal(t, int64(10), chatUsage.PromptTokensDetails.CacheCreationTokens)
+	})
+
+	t.Run("ClaudeUsage to ResponseUsage", func(t *testing.T) {
+		responseUsage := claudeUsage.ToResponseUsage()
+		assert.Equal(t, int64(130), responseUsage.InputTokens) // 100 + 10 + 20
+		assert.Equal(t, int64(50), responseUsage.OutputTokens)
+		assert.Equal(t, int64(180), responseUsage.TotalTokens)
+		assert.NotNil(t, responseUsage.InputTokensDetails)
+		assert.Equal(t, int64(20), responseUsage.InputTokensDetails.CachedTokens)
+	})
+
+	t.Run("ClaudeUsage to GeminiUsage", func(t *testing.T) {
+		geminiUsage := claudeUsage.ToGeminiUsage()
+		assert.Equal(t, int64(130), geminiUsage.PromptTokenCount) // 100 + 10 + 20
+		assert.Equal(t, int64(50), geminiUsage.CandidatesTokenCount)
+		assert.Equal(t, int64(180), geminiUsage.TotalTokenCount)
+		assert.Equal(t, int64(20), geminiUsage.CachedContentTokenCount)
+	})
+}
+
+func TestGeminiUsageConversions(t *testing.T) {
+	geminiUsage := model.GeminiUsageMetadata{
+		PromptTokenCount:        100,
+		CandidatesTokenCount:    50,
+		TotalTokenCount:         150,
+		ThoughtsTokenCount:      30,
+		CachedContentTokenCount: 20,
+	}
+
+	t.Run("GeminiUsage to ChatUsage", func(t *testing.T) {
+		chatUsage := geminiUsage.ToUsage()
+		assert.Equal(t, int64(100), chatUsage.PromptTokens)
+		assert.Equal(t, int64(80), chatUsage.CompletionTokens) // 50 + 30
+		assert.Equal(t, int64(150), chatUsage.TotalTokens)
+		assert.NotNil(t, chatUsage.PromptTokensDetails)
+		assert.Equal(t, int64(20), chatUsage.PromptTokensDetails.CachedTokens)
+		assert.NotNil(t, chatUsage.CompletionTokensDetails)
+		assert.Equal(t, int64(30), chatUsage.CompletionTokensDetails.ReasoningTokens)
+	})
+
+	t.Run("GeminiUsage to ResponseUsage", func(t *testing.T) {
+		responseUsage := geminiUsage.ToResponseUsage()
+		assert.Equal(t, int64(100), responseUsage.InputTokens)
+		assert.Equal(t, int64(50), responseUsage.OutputTokens)
+		assert.Equal(t, int64(150), responseUsage.TotalTokens)
+		assert.NotNil(t, responseUsage.InputTokensDetails)
+		assert.Equal(t, int64(20), responseUsage.InputTokensDetails.CachedTokens)
+		assert.NotNil(t, responseUsage.OutputTokensDetails)
+		assert.Equal(t, int64(30), responseUsage.OutputTokensDetails.ReasoningTokens)
+	})
+
+	t.Run("GeminiUsage to ClaudeUsage", func(t *testing.T) {
+		claudeUsage := geminiUsage.ToClaudeUsage()
+		assert.Equal(t, int64(100), claudeUsage.InputTokens)
+		assert.Equal(t, int64(50), claudeUsage.OutputTokens)
+		assert.Equal(t, int64(20), claudeUsage.CacheReadInputTokens)
+	})
+}
+
+func TestRoundTripConversions(t *testing.T) {
+	t.Run("ChatUsage -> ResponseUsage -> ChatUsage", func(t *testing.T) {
+		original := model.ChatUsage{
+			PromptTokens:     100,
+			CompletionTokens: 50,
+			TotalTokens:      150,
+			PromptTokensDetails: &model.PromptTokensDetails{
+				CachedTokens: 20,
+			},
+			CompletionTokensDetails: &model.CompletionTokensDetails{
+				ReasoningTokens: 30,
+			},
+		}
+
+		responseUsage := original.ToResponseUsage()
+		converted := responseUsage.ToChatUsage()
+		assert.Equal(t, original.PromptTokens, converted.PromptTokens)
+		assert.Equal(t, original.CompletionTokens, converted.CompletionTokens)
+		assert.Equal(t, original.TotalTokens, converted.TotalTokens)
+		assert.Equal(
+			t,
+			original.PromptTokensDetails.CachedTokens,
+			converted.PromptTokensDetails.CachedTokens,
+		)
+		assert.Equal(
+			t,
+			original.CompletionTokensDetails.ReasoningTokens,
+			converted.CompletionTokensDetails.ReasoningTokens,
+		)
+	})
+
+	t.Run("ResponseUsage -> GeminiUsage -> ResponseUsage", func(t *testing.T) {
+		original := model.ResponseUsage{
+			InputTokens:  100,
+			OutputTokens: 50,
+			TotalTokens:  150,
+			InputTokensDetails: &model.ResponseUsageDetails{
+				CachedTokens: 20,
+			},
+			OutputTokensDetails: &model.ResponseUsageDetails{
+				ReasoningTokens: 30,
+			},
+		}
+
+		geminiUsage := original.ToGeminiUsage()
+		converted := geminiUsage.ToResponseUsage()
+		assert.Equal(t, original.InputTokens, converted.InputTokens)
+		assert.Equal(t, original.OutputTokens, converted.OutputTokens)
+		assert.Equal(t, original.TotalTokens, converted.TotalTokens)
+		assert.Equal(
+			t,
+			original.InputTokensDetails.CachedTokens,
+			converted.InputTokensDetails.CachedTokens,
+		)
+		assert.Equal(
+			t,
+			original.OutputTokensDetails.ReasoningTokens,
+			converted.OutputTokensDetails.ReasoningTokens,
+		)
+	})
+}

+ 6 - 6
core/relay/plugin/patch/config.go

@@ -206,13 +206,13 @@ var DefaultPredefinedPatches = []PatchRule{
 		},
 	},
 	{
-		Name:        "gpt5.1_remove_top_p",
-		Description: "Remove top_p field for GPT-5.1 models",
+		Name:        "gpt5_remove_top_p",
+		Description: "Remove top_p field for GPT-5 models",
 		Conditions: []PatchCondition{
 			{
 				Key:      "model",
 				Operator: OperatorContains,
-				Value:    "gpt-5.1",
+				Value:    "gpt-5",
 			},
 			{
 				Key:      "top_p",
@@ -227,13 +227,13 @@ var DefaultPredefinedPatches = []PatchRule{
 		},
 	},
 	{
-		Name:        "gemini_gpt5.1_remove_generation_config_top_p",
-		Description: "Remove generationConfig.topP for GPT-5.1 models in Gemini format",
+		Name:        "gemini_gpt5_remove_generation_config_top_p",
+		Description: "Remove generationConfig.topP for GPT-5 models in Gemini format",
 		Conditions: []PatchCondition{
 			{
 				Key:      "model",
 				Operator: OperatorContains,
-				Value:    "gpt-5.1",
+				Value:    "gpt-5",
 			},
 			{
 				Key:      "generationConfig.topP",