mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-09 15:25:17 +00:00
Merge pull request #233 from ultraplan-bit/fix/copilot-codex-responses-translation
Fix Copilot codex model Responses API translation for Claude Code
This commit is contained in:
@@ -550,6 +550,17 @@ func flattenAssistantContent(body []byte) []byte {
|
||||
if !content.Exists() || !content.IsArray() {
|
||||
continue
|
||||
}
|
||||
// Skip flattening if the content contains non-text blocks (tool_use, thinking, etc.)
|
||||
hasNonText := false
|
||||
for _, part := range content.Array() {
|
||||
if t := part.Get("type").String(); t != "" && t != "text" {
|
||||
hasNonText = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasNonText {
|
||||
continue
|
||||
}
|
||||
var textParts []string
|
||||
for _, part := range content.Array() {
|
||||
if part.Get("type").String() == "text" {
|
||||
@@ -597,31 +608,173 @@ func normalizeGitHubCopilotChatTools(body []byte) []byte {
|
||||
func normalizeGitHubCopilotResponsesInput(body []byte) []byte {
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if input.Exists() {
|
||||
if input.Type == gjson.String {
|
||||
// If input is already a string or array, keep it as-is.
|
||||
if input.Type == gjson.String || input.IsArray() {
|
||||
return body
|
||||
}
|
||||
inputString := input.Raw
|
||||
if input.Type != gjson.JSON {
|
||||
inputString = input.String()
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "input", inputString)
|
||||
// Non-string/non-array input: stringify as fallback.
|
||||
body, _ = sjson.SetBytes(body, "input", input.Raw)
|
||||
return body
|
||||
}
|
||||
|
||||
var parts []string
|
||||
// Convert Claude messages format to OpenAI Responses API input array.
|
||||
// This preserves the conversation structure (roles, tool calls, tool results)
|
||||
// which is critical for multi-turn tool-use conversations.
|
||||
inputArr := "[]"
|
||||
|
||||
// System messages → developer role
|
||||
if system := gjson.GetBytes(body, "system"); system.Exists() {
|
||||
if text := strings.TrimSpace(collectTextFromNode(system)); text != "" {
|
||||
parts = append(parts, text)
|
||||
var systemParts []string
|
||||
if system.IsArray() {
|
||||
for _, part := range system.Array() {
|
||||
if txt := part.Get("text").String(); txt != "" {
|
||||
systemParts = append(systemParts, txt)
|
||||
}
|
||||
}
|
||||
} else if system.Type == gjson.String {
|
||||
systemParts = append(systemParts, system.String())
|
||||
}
|
||||
if len(systemParts) > 0 {
|
||||
msg := `{"type":"message","role":"developer","content":[]}`
|
||||
for _, txt := range systemParts {
|
||||
part := `{"type":"input_text","text":""}`
|
||||
part, _ = sjson.Set(part, "text", txt)
|
||||
msg, _ = sjson.SetRaw(msg, "content.-1", part)
|
||||
}
|
||||
inputArr, _ = sjson.SetRaw(inputArr, "-1", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Messages → structured input items
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
for _, msg := range messages.Array() {
|
||||
if text := strings.TrimSpace(collectTextFromNode(msg.Get("content"))); text != "" {
|
||||
parts = append(parts, text)
|
||||
role := msg.Get("role").String()
|
||||
content := msg.Get("content")
|
||||
|
||||
if !content.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Simple string content
|
||||
if content.Type == gjson.String {
|
||||
textType := "input_text"
|
||||
if role == "assistant" {
|
||||
textType = "output_text"
|
||||
}
|
||||
item := `{"type":"message","role":"","content":[]}`
|
||||
item, _ = sjson.Set(item, "role", role)
|
||||
part := fmt.Sprintf(`{"type":"%s","text":""}`, textType)
|
||||
part, _ = sjson.Set(part, "text", content.String())
|
||||
item, _ = sjson.SetRaw(item, "content.-1", part)
|
||||
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
||||
continue
|
||||
}
|
||||
|
||||
if !content.IsArray() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Array content: split into message parts vs tool items
|
||||
var msgParts []string
|
||||
for _, c := range content.Array() {
|
||||
cType := c.Get("type").String()
|
||||
switch cType {
|
||||
case "text":
|
||||
textType := "input_text"
|
||||
if role == "assistant" {
|
||||
textType = "output_text"
|
||||
}
|
||||
part := fmt.Sprintf(`{"type":"%s","text":""}`, textType)
|
||||
part, _ = sjson.Set(part, "text", c.Get("text").String())
|
||||
msgParts = append(msgParts, part)
|
||||
case "image":
|
||||
source := c.Get("source")
|
||||
if source.Exists() {
|
||||
data := source.Get("data").String()
|
||||
if data == "" {
|
||||
data = source.Get("base64").String()
|
||||
}
|
||||
mediaType := source.Get("media_type").String()
|
||||
if mediaType == "" {
|
||||
mediaType = source.Get("mime_type").String()
|
||||
}
|
||||
if mediaType == "" {
|
||||
mediaType = "application/octet-stream"
|
||||
}
|
||||
if data != "" {
|
||||
part := `{"type":"input_image","image_url":""}`
|
||||
part, _ = sjson.Set(part, "image_url", fmt.Sprintf("data:%s;base64,%s", mediaType, data))
|
||||
msgParts = append(msgParts, part)
|
||||
}
|
||||
}
|
||||
case "tool_use":
|
||||
// Flush any accumulated message parts first
|
||||
if len(msgParts) > 0 {
|
||||
item := `{"type":"message","role":"","content":[]}`
|
||||
item, _ = sjson.Set(item, "role", role)
|
||||
for _, p := range msgParts {
|
||||
item, _ = sjson.SetRaw(item, "content.-1", p)
|
||||
}
|
||||
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
||||
msgParts = nil
|
||||
}
|
||||
fc := `{"type":"function_call","call_id":"","name":"","arguments":""}`
|
||||
fc, _ = sjson.Set(fc, "call_id", c.Get("id").String())
|
||||
fc, _ = sjson.Set(fc, "name", c.Get("name").String())
|
||||
if inputRaw := c.Get("input"); inputRaw.Exists() {
|
||||
fc, _ = sjson.Set(fc, "arguments", inputRaw.Raw)
|
||||
}
|
||||
inputArr, _ = sjson.SetRaw(inputArr, "-1", fc)
|
||||
case "tool_result":
|
||||
// Flush any accumulated message parts first
|
||||
if len(msgParts) > 0 {
|
||||
item := `{"type":"message","role":"","content":[]}`
|
||||
item, _ = sjson.Set(item, "role", role)
|
||||
for _, p := range msgParts {
|
||||
item, _ = sjson.SetRaw(item, "content.-1", p)
|
||||
}
|
||||
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
||||
msgParts = nil
|
||||
}
|
||||
fco := `{"type":"function_call_output","call_id":"","output":""}`
|
||||
fco, _ = sjson.Set(fco, "call_id", c.Get("tool_use_id").String())
|
||||
// Extract output text
|
||||
resultContent := c.Get("content")
|
||||
if resultContent.Type == gjson.String {
|
||||
fco, _ = sjson.Set(fco, "output", resultContent.String())
|
||||
} else if resultContent.IsArray() {
|
||||
var resultParts []string
|
||||
for _, rc := range resultContent.Array() {
|
||||
if txt := rc.Get("text").String(); txt != "" {
|
||||
resultParts = append(resultParts, txt)
|
||||
}
|
||||
}
|
||||
fco, _ = sjson.Set(fco, "output", strings.Join(resultParts, "\n"))
|
||||
} else if resultContent.Exists() {
|
||||
fco, _ = sjson.Set(fco, "output", resultContent.String())
|
||||
}
|
||||
inputArr, _ = sjson.SetRaw(inputArr, "-1", fco)
|
||||
case "thinking":
|
||||
// Skip thinking blocks - not part of the API input
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining message parts
|
||||
if len(msgParts) > 0 {
|
||||
item := `{"type":"message","role":"","content":[]}`
|
||||
item, _ = sjson.Set(item, "role", role)
|
||||
for _, p := range msgParts {
|
||||
item, _ = sjson.SetRaw(item, "content.-1", p)
|
||||
}
|
||||
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "input", strings.Join(parts, "\n"))
|
||||
|
||||
body, _ = sjson.SetRawBytes(body, "input", []byte(inputArr))
|
||||
// Remove messages/system since we've converted them to input
|
||||
body, _ = sjson.DeleteBytes(body, "messages")
|
||||
body, _ = sjson.DeleteBytes(body, "system")
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -747,6 +900,8 @@ type githubCopilotResponsesStreamState struct {
|
||||
TextBlockIndex int
|
||||
NextContentIndex int
|
||||
HasToolUse bool
|
||||
ReasoningActive bool
|
||||
ReasoningIndex int
|
||||
OutputIndexToTool map[int]*githubCopilotResponsesStreamToolState
|
||||
ItemIDToTool map[string]*githubCopilotResponsesStreamToolState
|
||||
}
|
||||
@@ -761,6 +916,33 @@ func translateGitHubCopilotResponsesNonStreamToClaude(data []byte) string {
|
||||
if output := root.Get("output"); output.Exists() && output.IsArray() {
|
||||
for _, item := range output.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "reasoning":
|
||||
var thinkingText string
|
||||
if summary := item.Get("summary"); summary.Exists() && summary.IsArray() {
|
||||
var parts []string
|
||||
for _, part := range summary.Array() {
|
||||
if txt := part.Get("text").String(); txt != "" {
|
||||
parts = append(parts, txt)
|
||||
}
|
||||
}
|
||||
thinkingText = strings.Join(parts, "")
|
||||
}
|
||||
if thinkingText == "" {
|
||||
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
||||
var parts []string
|
||||
for _, part := range content.Array() {
|
||||
if txt := part.Get("text").String(); txt != "" {
|
||||
parts = append(parts, txt)
|
||||
}
|
||||
}
|
||||
thinkingText = strings.Join(parts, "")
|
||||
}
|
||||
}
|
||||
if thinkingText != "" {
|
||||
block := `{"type":"thinking","thinking":""}`
|
||||
block, _ = sjson.Set(block, "thinking", thinkingText)
|
||||
out, _ = sjson.SetRaw(out, "content.-1", block)
|
||||
}
|
||||
case "message":
|
||||
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
||||
for _, part := range content.Array() {
|
||||
@@ -798,10 +980,19 @@ func translateGitHubCopilotResponsesNonStreamToClaude(data []byte) string {
|
||||
|
||||
inputTokens := root.Get("usage.input_tokens").Int()
|
||||
outputTokens := root.Get("usage.output_tokens").Int()
|
||||
cachedTokens := root.Get("usage.input_tokens_details.cached_tokens").Int()
|
||||
if cachedTokens > 0 && inputTokens >= cachedTokens {
|
||||
inputTokens -= cachedTokens
|
||||
}
|
||||
out, _ = sjson.Set(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.Set(out, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
out, _ = sjson.Set(out, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
if hasToolUse {
|
||||
out, _ = sjson.Set(out, "stop_reason", "tool_use")
|
||||
} else if sr := root.Get("stop_reason").String(); sr == "max_tokens" || sr == "stop" {
|
||||
out, _ = sjson.Set(out, "stop_reason", sr)
|
||||
} else {
|
||||
out, _ = sjson.Set(out, "stop_reason", "end_turn")
|
||||
}
|
||||
@@ -892,6 +1083,31 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
||||
contentDelta, _ = sjson.Set(contentDelta, "delta.text", delta)
|
||||
results = append(results, "event: content_block_delta\ndata: "+contentDelta+"\n\n")
|
||||
}
|
||||
case "response.reasoning_summary_part.added":
|
||||
ensureMessageStart()
|
||||
state.ReasoningActive = true
|
||||
state.ReasoningIndex = state.NextContentIndex
|
||||
state.NextContentIndex++
|
||||
thinkingStart := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
|
||||
thinkingStart, _ = sjson.Set(thinkingStart, "index", state.ReasoningIndex)
|
||||
results = append(results, "event: content_block_start\ndata: "+thinkingStart+"\n\n")
|
||||
case "response.reasoning_summary_text.delta":
|
||||
if state.ReasoningActive {
|
||||
delta := gjson.GetBytes(payload, "delta").String()
|
||||
if delta != "" {
|
||||
thinkingDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
|
||||
thinkingDelta, _ = sjson.Set(thinkingDelta, "index", state.ReasoningIndex)
|
||||
thinkingDelta, _ = sjson.Set(thinkingDelta, "delta.thinking", delta)
|
||||
results = append(results, "event: content_block_delta\ndata: "+thinkingDelta+"\n\n")
|
||||
}
|
||||
}
|
||||
case "response.reasoning_summary_part.done":
|
||||
if state.ReasoningActive {
|
||||
thinkingStop := `{"type":"content_block_stop","index":0}`
|
||||
thinkingStop, _ = sjson.Set(thinkingStop, "index", state.ReasoningIndex)
|
||||
results = append(results, "event: content_block_stop\ndata: "+thinkingStop+"\n\n")
|
||||
state.ReasoningActive = false
|
||||
}
|
||||
case "response.output_item.added":
|
||||
if gjson.GetBytes(payload, "item.type").String() != "function_call" {
|
||||
break
|
||||
@@ -938,6 +1154,23 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
||||
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
||||
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
||||
results = append(results, "event: content_block_delta\ndata: "+inputDelta+"\n\n")
|
||||
case "response.function_call_arguments.delta":
|
||||
// Copilot sends tool call arguments via this event type (not response.output_item.delta).
|
||||
// Data format: {"delta":"...", "item_id":"...", "output_index":N, ...}
|
||||
itemID := gjson.GetBytes(payload, "item_id").String()
|
||||
outputIndex := int(gjson.GetBytes(payload, "output_index").Int())
|
||||
tool := resolveTool(itemID, outputIndex)
|
||||
if tool == nil {
|
||||
break
|
||||
}
|
||||
partial := gjson.GetBytes(payload, "delta").String()
|
||||
if partial == "" {
|
||||
break
|
||||
}
|
||||
inputDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
||||
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
||||
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
||||
results = append(results, "event: content_block_delta\ndata: "+inputDelta+"\n\n")
|
||||
case "response.output_item.done":
|
||||
if gjson.GetBytes(payload, "item.type").String() != "function_call" {
|
||||
break
|
||||
@@ -956,11 +1189,22 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
|
||||
stopReason := "end_turn"
|
||||
if state.HasToolUse {
|
||||
stopReason = "tool_use"
|
||||
} else if sr := gjson.GetBytes(payload, "response.stop_reason").String(); sr == "max_tokens" || sr == "stop" {
|
||||
stopReason = sr
|
||||
}
|
||||
inputTokens := gjson.GetBytes(payload, "response.usage.input_tokens").Int()
|
||||
outputTokens := gjson.GetBytes(payload, "response.usage.output_tokens").Int()
|
||||
cachedTokens := gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int()
|
||||
if cachedTokens > 0 && inputTokens >= cachedTokens {
|
||||
inputTokens -= cachedTokens
|
||||
}
|
||||
messageDelta := `{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
||||
messageDelta, _ = sjson.Set(messageDelta, "delta.stop_reason", stopReason)
|
||||
messageDelta, _ = sjson.Set(messageDelta, "usage.input_tokens", gjson.GetBytes(payload, "response.usage.input_tokens").Int())
|
||||
messageDelta, _ = sjson.Set(messageDelta, "usage.output_tokens", gjson.GetBytes(payload, "response.usage.output_tokens").Int())
|
||||
messageDelta, _ = sjson.Set(messageDelta, "usage.input_tokens", inputTokens)
|
||||
messageDelta, _ = sjson.Set(messageDelta, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
messageDelta, _ = sjson.Set(messageDelta, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
results = append(results, "event: message_delta\ndata: "+messageDelta+"\n\n")
|
||||
results = append(results, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||
state.MessageStopSent = true
|
||||
|
||||
@@ -103,11 +103,18 @@ func TestNormalizeGitHubCopilotResponsesInput_MissingInputExtractedFromSystemAnd
|
||||
body := []byte(`{"system":"sys text","messages":[{"role":"user","content":"user text"},{"role":"assistant","content":[{"type":"text","text":"assistant text"}]}]}`)
|
||||
got := normalizeGitHubCopilotResponsesInput(body)
|
||||
in := gjson.GetBytes(got, "input")
|
||||
if in.Type != gjson.String {
|
||||
t.Fatalf("input type = %v, want string", in.Type)
|
||||
if !in.IsArray() {
|
||||
t.Fatalf("input type = %v, want array", in.Type)
|
||||
}
|
||||
if !strings.Contains(in.String(), "sys text") || !strings.Contains(in.String(), "user text") || !strings.Contains(in.String(), "assistant text") {
|
||||
t.Fatalf("input = %q, want merged text", in.String())
|
||||
raw := in.Raw
|
||||
if !strings.Contains(raw, "sys text") || !strings.Contains(raw, "user text") || !strings.Contains(raw, "assistant text") {
|
||||
t.Fatalf("input = %s, want structured array with all texts", raw)
|
||||
}
|
||||
if gjson.GetBytes(got, "messages").Exists() {
|
||||
t.Fatal("messages should be removed after conversion")
|
||||
}
|
||||
if gjson.GetBytes(got, "system").Exists() {
|
||||
t.Fatal("system should be removed after conversion")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user