mirror of
https://mirror.skon.top/github.com/router-for-me/CLIProxyAPI
synced 2026-05-01 00:30:55 +08:00
fix: align claude codex translation
This commit is contained in:
@@ -40,6 +40,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
template := []byte(`{"model":"","instructions":"","input":[]}`)
|
||||
|
||||
rootResult := gjson.ParseBytes(rawJSON)
|
||||
toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
|
||||
template, _ = sjson.SetBytes(template, "model", modelName)
|
||||
|
||||
// Process system messages and convert them to input content format.
|
||||
@@ -174,8 +175,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", messageContentResult.Get("id").String())
|
||||
{
|
||||
name := messageContentResult.Get("name").String()
|
||||
toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
|
||||
if short, ok := toolMap[name]; ok {
|
||||
if short, ok := toolNameMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
@@ -249,23 +249,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
toolsResult := rootResult.Get("tools")
|
||||
if toolsResult.IsArray() {
|
||||
template, _ = sjson.SetRawBytes(template, "tools", []byte(`[]`))
|
||||
template, _ = sjson.SetBytes(template, "tool_choice", `auto`)
|
||||
webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult)
|
||||
template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames))
|
||||
toolResults := toolsResult.Array()
|
||||
// Build short name map from declared tools
|
||||
var names []string
|
||||
for i := 0; i < len(toolResults); i++ {
|
||||
n := toolResults[i].Get("name").String()
|
||||
if n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
shortMap := buildShortNameMap(names)
|
||||
for i := 0; i < len(toolResults); i++ {
|
||||
toolResult := toolResults[i]
|
||||
// Special handling: map Claude web search tool to Codex web_search
|
||||
if toolResult.Get("type").String() == "web_search_20250305" {
|
||||
// Replace the tool content entirely with {"type":"web_search"}
|
||||
template, _ = sjson.SetRawBytes(template, "tools.-1", []byte(`{"type":"web_search"}`))
|
||||
if isClaudeWebSearchToolType(toolResult.Get("type").String()) {
|
||||
template, _ = sjson.SetRawBytes(template, "tools.-1", convertClaudeWebSearchToolToCodex(toolResult))
|
||||
continue
|
||||
}
|
||||
tool := []byte(toolResult.Raw)
|
||||
@@ -273,7 +264,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
// Apply shortened name if needed
|
||||
if v := toolResult.Get("name"); v.Exists() {
|
||||
name := v.String()
|
||||
if short, ok := shortMap[name]; ok {
|
||||
if short, ok := toolNameMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
@@ -370,6 +361,83 @@ func isFernetLikeReasoningSignature(signature string) bool {
|
||||
return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0
|
||||
}
|
||||
|
||||
func isClaudeWebSearchToolType(toolType string) bool {
|
||||
return toolType == "web_search_20250305" || toolType == "web_search_20260209"
|
||||
}
|
||||
|
||||
func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} {
|
||||
names := map[string]struct{}{}
|
||||
if !tools.IsArray() {
|
||||
return names
|
||||
}
|
||||
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
toolType := tool.Get("type").String()
|
||||
if !isClaudeWebSearchToolType(toolType) {
|
||||
return true
|
||||
}
|
||||
|
||||
names["web_search"] = struct{}{}
|
||||
names[toolType] = struct{}{}
|
||||
if name := tool.Get("name").String(); name != "" {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte {
|
||||
if !toolChoice.Exists() || toolChoice.Type == gjson.Null {
|
||||
return []byte(`"auto"`)
|
||||
}
|
||||
|
||||
choiceType := toolChoice.Get("type").String()
|
||||
if choiceType == "" && toolChoice.Type == gjson.String {
|
||||
choiceType = toolChoice.String()
|
||||
}
|
||||
|
||||
switch choiceType {
|
||||
case "auto", "":
|
||||
return []byte(`"auto"`)
|
||||
case "any":
|
||||
return []byte(`"required"`)
|
||||
case "none":
|
||||
return []byte(`"none"`)
|
||||
case "tool":
|
||||
name := toolChoice.Get("name").String()
|
||||
if _, ok := webSearchToolNames[name]; ok {
|
||||
return []byte(`{"type":"web_search"}`)
|
||||
}
|
||||
if short, ok := toolNameMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
}
|
||||
if name == "" {
|
||||
return []byte(`"auto"`)
|
||||
}
|
||||
|
||||
choice := []byte(`{"type":"function","name":""}`)
|
||||
choice, _ = sjson.SetBytes(choice, "name", name)
|
||||
return choice
|
||||
default:
|
||||
return []byte(`"auto"`)
|
||||
}
|
||||
}
|
||||
|
||||
func convertClaudeWebSearchToolToCodex(tool gjson.Result) []byte {
|
||||
out := []byte(`{"type":"web_search"}`)
|
||||
if allowedDomains := tool.Get("allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
|
||||
out, _ = sjson.SetRawBytes(out, "filters.allowed_domains", []byte(allowedDomains.Raw))
|
||||
}
|
||||
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
|
||||
out, _ = sjson.SetRawBytes(out, "user_location", []byte(userLocation.Raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shortenNameIfNeeded applies a simple shortening rule for a single name.
|
||||
func shortenNameIfNeeded(name string) string {
|
||||
const limit = 64
|
||||
|
||||
@@ -136,6 +136,118 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToCodex_ToolChoiceModeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claudeToolChoice string
|
||||
wantCodexToolChoice string
|
||||
}{
|
||||
{
|
||||
name: "Any requires at least one tool",
|
||||
claudeToolChoice: `{"type":"any"}`,
|
||||
wantCodexToolChoice: "required",
|
||||
},
|
||||
{
|
||||
name: "None disables tools",
|
||||
claudeToolChoice: `{"type":"none"}`,
|
||||
wantCodexToolChoice: "none",
|
||||
},
|
||||
{
|
||||
name: "Auto stays auto",
|
||||
claudeToolChoice: `{"type":"auto"}`,
|
||||
wantCodexToolChoice: "auto",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"tools": [
|
||||
{"name": "lookup", "description": "Lookup", "input_schema": {"type":"object","properties":{}}}
|
||||
],
|
||||
"tool_choice": ` + tt.claudeToolChoice + `,
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
if got := resultJSON.Get("tool_choice").String(); got != tt.wantCodexToolChoice {
|
||||
t.Fatalf("tool_choice = %q, want %q. Output: %s", got, tt.wantCodexToolChoice, string(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToCodex_ToolChoiceSpecificFunctionUsesConvertedName(t *testing.T) {
|
||||
longName := "mcp__server_with_a_very_long_name_that_exceeds_sixty_four_characters__search"
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"tools": [
|
||||
{"name": "` + longName + `", "description": "Search", "input_schema": {"type":"object","properties":{}}}
|
||||
],
|
||||
"tool_choice": {"type":"tool","name":"` + longName + `"},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
|
||||
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
|
||||
}
|
||||
toolName := resultJSON.Get("tools.0.name").String()
|
||||
choiceName := resultJSON.Get("tool_choice.name").String()
|
||||
if choiceName != toolName {
|
||||
t.Fatalf("tool_choice.name = %q, want converted tool name %q. Output: %s", choiceName, toolName, string(result))
|
||||
}
|
||||
if choiceName == longName {
|
||||
t.Fatalf("tool_choice.name should use shortened Codex tool name. Output: %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToCodex_WebSearchToolMapping(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"tools": [
|
||||
{
|
||||
"type": "web_search_20260209",
|
||||
"name": "web_search",
|
||||
"allowed_domains": ["example.com"],
|
||||
"blocked_domains": ["blocked.example"],
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"city": "Beijing",
|
||||
"country": "CN",
|
||||
"timezone": "Asia/Shanghai"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type":"tool","name":"web_search"},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
if got := resultJSON.Get("tools.0.type").String(); got != "web_search" {
|
||||
t.Fatalf("tools.0.type = %q, want web_search. Output: %s", got, string(result))
|
||||
}
|
||||
if got := resultJSON.Get("tools.0.filters.allowed_domains.0").String(); got != "example.com" {
|
||||
t.Fatalf("tools.0.filters.allowed_domains.0 = %q, want example.com. Output: %s", got, string(result))
|
||||
}
|
||||
if resultJSON.Get("tools.0.blocked_domains").Exists() {
|
||||
t.Fatalf("tools.0.blocked_domains should not be forwarded to Codex. Output: %s", string(result))
|
||||
}
|
||||
if got := resultJSON.Get("tools.0.user_location.city").String(); got != "Beijing" {
|
||||
t.Fatalf("tools.0.user_location.city = %q, want Beijing. Output: %s", got, string(result))
|
||||
}
|
||||
if got := resultJSON.Get("tool_choice.type").String(); got != "web_search" {
|
||||
t.Fatalf("tool_choice.type = %q, want web_search. Output: %s", got, string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) {
|
||||
signature := validCodexReasoningSignature()
|
||||
inputJSON := `{
|
||||
|
||||
@@ -68,7 +68,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
params := (*param).(*ConvertCodexResponseToClaudeParams)
|
||||
if params.ThinkingBlockOpen && params.ThinkingStopPending {
|
||||
switch rootResult.Get("type").String() {
|
||||
case "response.content_part.added", "response.completed":
|
||||
case "response.content_part.added", "response.completed", "response.incomplete":
|
||||
output = append(output, finalizeCodexThinkingBlock(params)...)
|
||||
}
|
||||
}
|
||||
@@ -117,18 +117,12 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
params.BlockIndex++
|
||||
|
||||
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
|
||||
} else if typeStr == "response.completed" {
|
||||
} else if typeStr == "response.completed" || typeStr == "response.incomplete" {
|
||||
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
p := params.HasToolCall
|
||||
stopReason := rootResult.Get("response.stop_reason").String()
|
||||
if p {
|
||||
template, _ = sjson.SetBytes(template, "delta.stop_reason", "tool_use")
|
||||
} else if stopReason == "max_tokens" || stopReason == "stop" {
|
||||
template, _ = sjson.SetBytes(template, "delta.stop_reason", stopReason)
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "delta.stop_reason", "end_turn")
|
||||
}
|
||||
inputTokens, outputTokens, cachedTokens := extractResponsesUsage(rootResult.Get("response.usage"))
|
||||
responseData := rootResult.Get("response")
|
||||
template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasToolCall))
|
||||
template = setClaudeStopSequence(template, "delta.stop_sequence", responseData)
|
||||
inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage"))
|
||||
template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens)
|
||||
template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
@@ -259,7 +253,8 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
|
||||
revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
|
||||
|
||||
rootResult := gjson.ParseBytes(rawJSON)
|
||||
if rootResult.Get("type").String() != "response.completed" {
|
||||
typeStr := rootResult.Get("type").String()
|
||||
if typeStr != "response.completed" && typeStr != "response.incomplete" {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
@@ -371,18 +366,57 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
|
||||
})
|
||||
}
|
||||
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), hasToolCall))
|
||||
out = setClaudeStopSequence(out, "stop_sequence", responseData)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func codexStopReason(responseData gjson.Result) string {
|
||||
if stopReason := responseData.Get("stop_reason"); stopReason.Exists() && stopReason.String() != "" {
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", stopReason.String())
|
||||
} else if hasToolCall {
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", "tool_use")
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", "end_turn")
|
||||
if stopReason.String() == "stop" && codexStopSequence(responseData).String() != "" {
|
||||
return "stop_sequence"
|
||||
}
|
||||
return stopReason.String()
|
||||
}
|
||||
if reason := responseData.Get("incomplete_details.reason"); reason.Exists() && reason.String() != "" {
|
||||
return reason.String()
|
||||
}
|
||||
if codexStopSequence(responseData).String() != "" {
|
||||
return "stop_sequence"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapCodexStopReasonToClaude(stopReason string, hasToolCall bool) string {
|
||||
if hasToolCall {
|
||||
return "tool_use"
|
||||
}
|
||||
|
||||
if stopSequence := responseData.Get("stop_sequence"); stopSequence.Exists() && stopSequence.String() != "" {
|
||||
out, _ = sjson.SetRawBytes(out, "stop_sequence", []byte(stopSequence.Raw))
|
||||
switch stopReason {
|
||||
case "", "stop", "completed":
|
||||
return "end_turn"
|
||||
case "max_tokens", "max_output_tokens":
|
||||
return "max_tokens"
|
||||
case "tool_use", "tool_calls", "function_call":
|
||||
return "tool_use"
|
||||
case "end_turn", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded":
|
||||
return stopReason
|
||||
case "content_filter":
|
||||
return "refusal"
|
||||
default:
|
||||
return "end_turn"
|
||||
}
|
||||
}
|
||||
|
||||
func codexStopSequence(responseData gjson.Result) gjson.Result {
|
||||
return responseData.Get("stop_sequence")
|
||||
}
|
||||
|
||||
func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) []byte {
|
||||
if stopSequence := codexStopSequence(responseData); stopSequence.Exists() && stopSequence.String() != "" {
|
||||
out, _ = sjson.SetRawBytes(out, path, []byte(stopSequence.Raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -458,3 +458,207 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage
|
||||
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaude_StreamStopReasonMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chunks [][]byte
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "Stop maps to end_turn",
|
||||
chunks: [][]byte{
|
||||
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
|
||||
},
|
||||
wantReason: "end_turn",
|
||||
},
|
||||
{
|
||||
name: "Incomplete max output maps to max_tokens",
|
||||
chunks: [][]byte{
|
||||
[]byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
|
||||
},
|
||||
wantReason: "max_tokens",
|
||||
},
|
||||
{
|
||||
name: "Tool call wins over stop",
|
||||
chunks: [][]byte{
|
||||
[]byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}"),
|
||||
[]byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
|
||||
},
|
||||
wantReason: "tool_use",
|
||||
},
|
||||
{
|
||||
name: "Content filter maps to Claude refusal",
|
||||
chunks: [][]byte{
|
||||
[]byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"content_filter\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
|
||||
},
|
||||
wantReason: "refusal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`)
|
||||
var param any
|
||||
var outputs [][]byte
|
||||
|
||||
for _, chunk := range tt.chunks {
|
||||
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...)
|
||||
}
|
||||
|
||||
got, ok := findClaudeStreamStopReason(outputs)
|
||||
if !ok {
|
||||
t.Fatalf("did not find message_delta stop_reason; outputs=%q", outputs)
|
||||
}
|
||||
if got != tt.wantReason {
|
||||
t.Fatalf("stop_reason = %q, want %q. Outputs=%q", got, tt.wantReason, outputs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaude_StreamStopSequenceMapping(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"messages":[]}`)
|
||||
var param any
|
||||
|
||||
outputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"stop_sequence\":\"\\nEND\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), ¶m)
|
||||
messageDelta, ok := findClaudeStreamMessageDelta(outputs)
|
||||
if !ok {
|
||||
t.Fatalf("did not find message_delta; outputs=%q", outputs)
|
||||
}
|
||||
if got := messageDelta.Get("delta.stop_reason").String(); got != "stop_sequence" {
|
||||
t.Fatalf("stop_reason = %q, want stop_sequence. Outputs=%q", got, outputs)
|
||||
}
|
||||
if got := messageDelta.Get("delta.stop_sequence").String(); got != "\nEND" {
|
||||
t.Fatalf("stop_sequence = %q, want newline END. Outputs=%q", got, outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaudeNonStream_StopReasonMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response []byte
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "Stop maps to end_turn",
|
||||
response: []byte(`{
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"stop_reason":"stop",
|
||||
"usage":{"input_tokens":1,"output_tokens":1},
|
||||
"output":[]
|
||||
}
|
||||
}`),
|
||||
wantReason: "end_turn",
|
||||
},
|
||||
{
|
||||
name: "Incomplete max output maps to max_tokens",
|
||||
response: []byte(`{
|
||||
"type":"response.incomplete",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"incomplete_details":{"reason":"max_output_tokens"},
|
||||
"usage":{"input_tokens":1,"output_tokens":1},
|
||||
"output":[]
|
||||
}
|
||||
}`),
|
||||
wantReason: "max_tokens",
|
||||
},
|
||||
{
|
||||
name: "Tool call wins over stop",
|
||||
response: []byte(`{
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"stop_reason":"stop",
|
||||
"usage":{"input_tokens":1,"output_tokens":1},
|
||||
"output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"}]
|
||||
}
|
||||
}`),
|
||||
wantReason: "tool_use",
|
||||
},
|
||||
{
|
||||
name: "Content filter maps to Claude refusal",
|
||||
response: []byte(`{
|
||||
"type":"response.incomplete",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"incomplete_details":{"reason":"content_filter"},
|
||||
"usage":{"input_tokens":1,"output_tokens":1},
|
||||
"output":[]
|
||||
}
|
||||
}`),
|
||||
wantReason: "refusal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`)
|
||||
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, tt.response, nil)
|
||||
parsed := gjson.ParseBytes(out)
|
||||
|
||||
if got := parsed.Get("stop_reason").String(); got != tt.wantReason {
|
||||
t.Fatalf("stop_reason = %q, want %q. Output: %s", got, tt.wantReason, string(out))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCodexResponseToClaudeNonStream_StopSequenceMapping(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
originalRequest := []byte(`{"messages":[]}`)
|
||||
response := []byte(`{
|
||||
"type":"response.completed",
|
||||
"response":{
|
||||
"id":"resp_1",
|
||||
"model":"gpt-5",
|
||||
"stop_reason":"stop",
|
||||
"stop_sequence":"\nEND",
|
||||
"usage":{"input_tokens":1,"output_tokens":1},
|
||||
"output":[]
|
||||
}
|
||||
}`)
|
||||
|
||||
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
|
||||
parsed := gjson.ParseBytes(out)
|
||||
|
||||
if got := parsed.Get("stop_reason").String(); got != "stop_sequence" {
|
||||
t.Fatalf("stop_reason = %q, want stop_sequence. Output: %s", got, string(out))
|
||||
}
|
||||
if got := parsed.Get("stop_sequence").String(); got != "\nEND" {
|
||||
t.Fatalf("stop_sequence = %q, want newline END. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func findClaudeStreamStopReason(outputs [][]byte) (string, bool) {
|
||||
messageDelta, ok := findClaudeStreamMessageDelta(outputs)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return messageDelta.Get("delta.stop_reason").String(), true
|
||||
}
|
||||
|
||||
func findClaudeStreamMessageDelta(outputs [][]byte) (gjson.Result, bool) {
|
||||
for _, out := range outputs {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := gjson.Parse(strings.TrimPrefix(line, "data: "))
|
||||
if data.Get("type").String() == "message_delta" {
|
||||
return data, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return gjson.Result{}, false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user