Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,19 @@
package chat_completions
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenAI,
OpenAI,
ConvertOpenAIRequestToOpenAI,
interfaces.TranslateResponse{
Stream: ConvertOpenAIResponseToOpenAI,
NonStream: ConvertOpenAIResponseToOpenAINonStream,
},
)
}

View file

@ -0,0 +1,36 @@
// Package openai provides request translation functionality for OpenAI to OpenAI API compatibility.
// It converts OpenAI Chat Completions requests into OpenAI-compatible JSON using gjson/sjson only.
package chat_completions
import (
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertOpenAIRequestToOpenAI converts an OpenAI Chat Completions request (raw JSON)
// into a complete OpenAI request JSON. All JSON construction uses sjson and lookups use gjson.
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the OpenAI API
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
//
// Returns:
// - []byte: The transformed request data in OpenAI API format
func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) []byte {
currentModel := gjson.GetBytes(inputRawJSON, "model")
if currentModel.Type == gjson.String && currentModel.String() == modelName {
return inputRawJSON
}
// Update the "model" field in the JSON payload with the provided modelName
// The sjson.SetBytes function returns a new byte slice with the updated JSON.
updatedJSON, err := sjson.SetBytes(inputRawJSON, "model", modelName)
if err != nil {
// If there's an error, return the original JSON or handle the error appropriately.
// For now, we'll return the original, but in a real scenario, logging or a more robust error
// handling mechanism would be needed.
return inputRawJSON
}
return updatedJSON
}

View file

@ -0,0 +1,27 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToOpenAIReusesMatchingModelPayload(t *testing.T) {
input := []byte(`{"model":"gpt-test","messages":[{"role":"user","content":"hello"}]}`)
output := ConvertOpenAIRequestToOpenAI("gpt-test", input, false)
if &output[0] != &input[0] {
t.Fatal("matching model caused a payload copy")
}
}
func TestConvertOpenAIRequestToOpenAIUpdatesDifferentModel(t *testing.T) {
input := []byte(`{"model":"old-model","messages":[]}`)
output := ConvertOpenAIRequestToOpenAI("new-model", input, false)
if model := gjson.GetBytes(output, "model").String(); model != "new-model" {
t.Fatalf("model = %q, want new-model", model)
}
}

View file

@ -0,0 +1,53 @@
// Package chat_completions provides passthrough response translation for OpenAI Chat Completions.
// It normalizes OpenAI-compatible SSE lines by stripping the "data:" prefix and dropping "[DONE]".
package chat_completions
import (
"bytes"
"context"
)
// ConvertOpenAIResponseToOpenAI normalizes a single chunk of an OpenAI-compatible streaming response.
// If the chunk is an SSE "data:" line, the prefix is stripped and the remaining JSON payload is returned.
// The "[DONE]" marker yields no output.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response (unused in current implementation)
// - rawJSON: The raw JSON response from the OpenAI API
// - param: A pointer to a parameter object for maintaining state between calls
//
// Returns:
// - [][]byte: A slice of JSON payload chunks in OpenAI format.
func ConvertOpenAIResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if param != nil {
if done, ok := (*param).(bool); ok && done {
// Drop any chunks that arrive after the terminal [DONE] marker.
return [][]byte{}
}
}
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
if bytes.Equal(rawJSON, []byte("[DONE]")) {
if param != nil {
*param = true
}
return [][]byte{}
}
return [][]byte{rawJSON}
}
// ConvertOpenAIResponseToOpenAINonStream passes through a non-streaming OpenAI response.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response
// - rawJSON: The raw JSON response from the OpenAI API
// - param: A pointer to a parameter object for the conversion
//
// Returns:
// - []byte: The OpenAI-compatible JSON response.
func ConvertOpenAIResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
return rawJSON
}

View file

@ -0,0 +1,38 @@
package chat_completions
import (
"bytes"
"context"
"testing"
)
func TestConvertOpenAIResponseToOpenAIDropsChunksAfterDone(t *testing.T) {
var param any
ctx := context.Background()
first := ConvertOpenAIResponseToOpenAI(ctx, "m", nil, nil, []byte(`data: {"id":"x","choices":[]}`), &param)
if len(first) != 1 || !bytes.Contains(first[0], []byte(`"id":"x"`)) {
t.Fatalf("first chunk = %v", first)
}
done := ConvertOpenAIResponseToOpenAI(ctx, "m", nil, nil, []byte("data: [DONE]"), &param)
if len(done) != 0 {
t.Fatalf("DONE should yield no output, got %v", done)
}
if doneFlag, ok := param.(bool); !ok || !doneFlag {
t.Fatalf("param after DONE = %#v, want true", param)
}
trailing := ConvertOpenAIResponseToOpenAI(ctx, "m", nil, nil, []byte(`data: {"choices":[],"cost":"0"}`), &param)
if len(trailing) != 0 {
t.Fatalf("post-DONE chunk should be dropped, got %v", trailing)
}
}
func TestConvertOpenAIResponseToOpenAIPassthroughWithoutDone(t *testing.T) {
var param any
out := ConvertOpenAIResponseToOpenAI(context.Background(), "m", nil, nil, []byte(`{"id":"y"}`), &param)
if len(out) != 1 || !bytes.Equal(out[0], []byte(`{"id":"y"}`)) {
t.Fatalf("out = %v", out)
}
}

View file

@ -0,0 +1,19 @@
package responses
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenaiResponse,
OpenAI,
ConvertOpenAIResponsesRequestToOpenAIChatCompletions,
interfaces.TranslateResponse{
Stream: ConvertOpenAIChatCompletionsResponseToOpenAIResponses,
NonStream: ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream,
},
)
}

View file

@ -0,0 +1,560 @@
package responses
import (
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertOpenAIResponsesRequestToOpenAIChatCompletions converts OpenAI responses format to OpenAI chat completions format.
// It transforms the OpenAI responses API format (with instructions and input array) into the standard
// OpenAI chat completions format (with messages array and system content).
//
// The conversion handles:
// 1. Model name and streaming configuration
// 2. Instructions to system message conversion
// 3. Input array to messages array transformation
// 4. Tool definitions and tool choice conversion
// 5. Function calls and function results handling
// 6. Generation parameters mapping (max_tokens, reasoning, etc.)
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data in OpenAI responses format
// - stream: A boolean indicating if the request is for a streaming response
//
// Returns:
// - []byte: The transformed request data in OpenAI chat completions format
func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON
// Base OpenAI chat completions template with default values
out := []byte(`{"model":"","messages":[],"stream":false}`)
root := gjson.ParseBytes(rawJSON)
messages := make([][]byte, 0)
appendMessage := func(message []byte) {
messages = append(messages, message)
}
// Set model name
out, _ = sjson.SetBytes(out, "model", modelName)
// Set stream configuration
out, _ = sjson.SetBytes(out, "stream", stream)
// Map Responses text format to Chat Completions response format.
if textFormat := root.Get("text.format"); textFormat.Exists() {
if responseFormat := convertResponsesTextFormatToChatResponseFormat(textFormat); len(responseFormat) > 0 {
out, _ = sjson.SetRawBytes(out, "response_format", responseFormat)
}
}
// Map generation parameters from responses format to chat completions format
if maxTokens := root.Get("max_output_tokens"); maxTokens.Exists() {
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
}
// Convert instructions to system message
if instructions := root.Get("instructions"); instructions.Exists() {
systemMessage := []byte(`{"role":"system","content":""}`)
systemMessage, _ = sjson.SetBytes(systemMessage, "content", instructions.String())
appendMessage(systemMessage)
}
// Convert input array to messages
if input := root.Get("input"); input.Exists() && input.IsArray() {
inputItems := input.Array()
outputCallIDs := make(map[string]struct{})
for _, item := range inputItems {
itemType := item.Get("type").String()
if itemType != "function_call_output" && itemType != "custom_tool_call_output" {
continue
}
callID := strings.TrimSpace(item.Get("call_id").String())
if callID == "" {
continue
}
outputCallIDs[callID] = struct{}{}
}
pendingToolCalls := make([]interface{}, 0)
pendingToolCallIDs := make([]string, 0)
pendingReasoningContent := ""
awaitingToolOutputs := make(map[string]struct{})
deferredMessages := make([][]byte, 0)
mergeableAssistantIndex := -1
takePendingReasoningContent := func() string {
reasoningContent := pendingReasoningContent
pendingReasoningContent = ""
return reasoningContent
}
flushPendingToolCalls := func() {
if len(pendingToolCalls) == 0 {
return
}
reasoningContent := takePendingReasoningContent()
mergedIntoAssistant := false
if mergeableAssistantIndex >= 0 && mergeableAssistantIndex == len(messages)-1 {
assistantMessage := gjson.ParseBytes(messages[mergeableAssistantIndex])
if assistantMessage.Get("role").String() == "assistant" && !assistantMessage.Get("tool_calls").Exists() {
updatedMessage, _ := sjson.SetBytes(messages[mergeableAssistantIndex], "tool_calls", pendingToolCalls)
combinedReasoning := combineOpenAIResponsesReasoning(assistantMessage.Get("reasoning_content").String(), reasoningContent)
if combinedReasoning != "" {
updatedMessage, _ = sjson.SetBytes(updatedMessage, "reasoning_content", combinedReasoning)
}
messages[mergeableAssistantIndex] = updatedMessage
mergedIntoAssistant = true
}
}
if !mergedIntoAssistant {
assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`)
assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls)
if reasoningContent != "" {
assistantMessage, _ = sjson.SetBytes(assistantMessage, "reasoning_content", reasoningContent)
}
appendMessage(assistantMessage)
}
for _, id := range pendingToolCallIDs {
if strings.TrimSpace(id) == "" {
continue
}
awaitingToolOutputs[id] = struct{}{}
}
pendingToolCalls = pendingToolCalls[:0]
pendingToolCallIDs = pendingToolCallIDs[:0]
mergeableAssistantIndex = -1
}
flushDeferredMessages := func() {
for _, message := range deferredMessages {
appendMessage(message)
}
deferredMessages = deferredMessages[:0]
}
hasAwaitingToolOutput := func() bool {
for id := range awaitingToolOutputs {
if _, ok := outputCallIDs[id]; ok {
return true
}
}
return false
}
appendRegularMessage := func(message []byte) int {
// Keep tool-call adjacency strict for providers that require
// assistant(tool_calls) -> tool(tool_call_id) with no message in between.
if hasAwaitingToolOutput() {
deferredMessages = append(deferredMessages, message)
return -1
}
appendMessage(message)
return len(messages) - 1
}
appendPendingReasoningMessage := func() {
reasoningContent := takePendingReasoningContent()
if reasoningContent == "" {
return
}
message := []byte(`{"role":"assistant","content":"","reasoning_content":""}`)
message, _ = sjson.SetBytes(message, "reasoning_content", reasoningContent)
appendRegularMessage(message)
}
for _, item := range inputItems {
itemType := item.Get("type").String()
if itemType == "" && item.Get("role").String() != "" {
itemType = "message"
}
if itemType != "function_call" && itemType != "custom_tool_call" {
flushPendingToolCalls()
}
switch itemType {
case "message", "":
// Handle regular message conversion
role := item.Get("role").String()
if role == "developer" {
role = "user"
}
mergeableAssistantIndex = -1
if role != "assistant" {
appendPendingReasoningMessage()
}
message := []byte(`{"role":"","content":[]}`)
message, _ = sjson.SetBytes(message, "role", role)
if content := item.Get("content"); content.Exists() && content.IsArray() {
var contentItems [][]byte
content.ForEach(func(_, contentItem gjson.Result) bool {
contentType := contentItem.Get("type").String()
if contentType == "" {
contentType = "input_text"
}
switch contentType {
case "input_text", "output_text":
text := contentItem.Get("text").String()
contentPart := []byte(`{"type":"text","text":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "text", text)
contentItems = append(contentItems, contentPart)
case "input_image":
imageURL := contentItem.Get("image_url").String()
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", imageURL)
if detail, ok := normalizeChatImageDetail(contentItem.Get("detail")); ok && detail != "" {
contentPart, _ = sjson.SetBytes(contentPart, "image_url.detail", detail)
}
contentItems = append(contentItems, contentPart)
}
return true
})
message = translatorcommon.SetRawArrayItems(message, "content", contentItems)
} else if content.Type == gjson.String {
message, _ = sjson.SetBytes(message, "content", content.String())
}
if role == "assistant" {
reasoningContent := combineOpenAIResponsesReasoning(takePendingReasoningContent(), item.Get("reasoning_content").String())
if reasoningContent != "" {
message, _ = sjson.SetBytes(message, "reasoning_content", reasoningContent)
}
}
messageIndex := appendRegularMessage(message)
if role == "assistant" {
mergeableAssistantIndex = messageIndex
}
case "reasoning":
reasoningContent := collectOpenAIResponsesReasoningContent(item)
pendingReasoningContent = combineOpenAIResponsesReasoning(pendingReasoningContent, reasoningContent)
case "function_call":
pendingReasoningContent = combineOpenAIResponsesReasoning(pendingReasoningContent, item.Get("reasoning_content").String())
// Buffer consecutive function calls and emit them as one assistant message.
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
if callId := item.Get("call_id"); callId.Exists() {
toolCall, _ = sjson.SetBytes(toolCall, "id", callId.String())
}
if name := item.Get("name"); name.Exists() {
functionName := name.String()
if namespace := strings.TrimSpace(item.Get("namespace").String()); namespace != "" {
functionName = qualifyResponsesNamespaceToolName(namespace, functionName)
}
toolCall, _ = sjson.SetBytes(toolCall, "function.name", functionName)
}
if arguments := item.Get("arguments"); arguments.Exists() {
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String())
}
pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
pendingToolCallIDs = append(pendingToolCallIDs, callID)
}
case "function_call_output":
mergeableAssistantIndex = -1
// Handle function call output conversion to tool message
toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
callID := ""
if callId := item.Get("call_id"); callId.Exists() {
callID = strings.TrimSpace(callId.String())
toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
}
if output := item.Get("output"); output.Exists() {
toolMessage = setFunctionCallOutputContent(toolMessage, output)
}
appendMessage(toolMessage)
if callID != "" {
delete(awaitingToolOutputs, callID)
}
if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 {
flushDeferredMessages()
}
case "custom_tool_call":
pendingReasoningContent = combineOpenAIResponsesReasoning(pendingReasoningContent, item.Get("reasoning_content").String())
// Codex freeform tool call replay: wrap the raw input so it
// matches the {"input": string} function shape used when
// converting custom tool definitions.
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
toolCall, _ = sjson.SetBytes(toolCall, "id", item.Get("call_id").String())
toolCall, _ = sjson.SetBytes(toolCall, "function.name", item.Get("name").String())
wrappedArgs, _ := sjson.SetBytes([]byte(`{"input":""}`), "input", item.Get("input").String())
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", string(wrappedArgs))
pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
pendingToolCallIDs = append(pendingToolCallIDs, callID)
}
case "custom_tool_call_output":
mergeableAssistantIndex = -1
toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
callID := strings.TrimSpace(item.Get("call_id").String())
toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
if output := item.Get("output"); output.Exists() {
toolMessage = setCustomToolCallOutputContent(toolMessage, output)
}
appendMessage(toolMessage)
if callID != "" {
delete(awaitingToolOutputs, callID)
}
if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 {
flushDeferredMessages()
}
default:
mergeableAssistantIndex = -1
}
}
flushPendingToolCalls()
appendPendingReasoningMessage()
flushDeferredMessages()
} else if input.Type == gjson.String {
msg := []byte(`{}`)
msg, _ = sjson.SetBytes(msg, "role", "user")
msg, _ = sjson.SetBytes(msg, "content", input.String())
appendMessage(msg)
}
if len(messages) > 0 {
out, _ = sjson.SetRawBytes(out, "messages", translatorcommon.JoinRawArray(messages))
}
// Convert tools from responses format to chat completions format.
// Codex Desktop (Responses Lite) delivers tool definitions through an
// "additional_tools" input item instead of the top-level "tools" field,
// so merge both sources.
var chatCompletionsTools []interface{}
for _, chatTool := range mergeResponsesRequestChatTools(root) {
chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value())
}
if len(chatCompletionsTools) > 0 {
out, _ = sjson.SetBytes(out, "tools", chatCompletionsTools)
if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Exists() {
out, _ = sjson.SetBytes(out, "parallel_tool_calls", parallelToolCalls.Bool())
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
}
}
if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() {
effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String()))
if effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
}
return out
}
func convertResponsesTextFormatToChatResponseFormat(textFormat gjson.Result) []byte {
formatType := textFormat.Get("type").String()
switch formatType {
case "text", "json_object":
responseFormat := []byte(`{"type":""}`)
responseFormat, _ = sjson.SetBytes(responseFormat, "type", formatType)
return responseFormat
case "json_schema":
responseFormat := []byte(`{"type":"json_schema","json_schema":{}}`)
for _, field := range []string{"name", "description", "strict"} {
if value := textFormat.Get(field); value.Exists() {
responseFormat, _ = sjson.SetBytes(responseFormat, "json_schema."+field, value.Value())
}
}
if schema := textFormat.Get("schema"); schema.Exists() {
responseFormat, _ = sjson.SetRawBytes(responseFormat, "json_schema.schema", []byte(schema.Raw))
}
return responseFormat
default:
return nil
}
}
func setFunctionCallOutputContent(toolMessage []byte, output gjson.Result) []byte {
structuredContent := output
if output.Type == gjson.String {
if !gjson.Valid(output.String()) {
toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String())
return toolMessage
}
structuredContent = gjson.Parse(output.String())
}
if hasChatToolOutputImagePart(structuredContent) {
contentItems := make([][]byte, 0, len(structuredContent.Array()))
for _, item := range structuredContent.Array() {
contentItems = append(contentItems, chatToolOutputContentPart(item))
}
return translatorcommon.SetRawArrayItems(toolMessage, "content", contentItems)
}
toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String())
return toolMessage
}
func setCustomToolCallOutputContent(toolMessage []byte, output gjson.Result) []byte {
structuredContent := output
if output.Type == gjson.String && gjson.Valid(output.String()) {
structuredContent = gjson.Parse(output.String())
}
if hasChatToolOutputImagePart(structuredContent) {
return setFunctionCallOutputContent(toolMessage, output)
}
toolMessage, _ = sjson.SetBytes(toolMessage, "content", responsesToolOutputText(output))
return toolMessage
}
func chatToolOutputContentPart(item gjson.Result) []byte {
itemType := item.Get("type").String()
switch itemType {
case "text", "input_text", "output_text":
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
return part
case "image_url", "input_image":
imageURL, detail, ok := chatToolOutputImageFields(item)
if !ok {
return chatToolOutputFallbackPart(item)
}
part := []byte(`{"type":"image_url","image_url":{"url":""}}`)
part, _ = sjson.SetBytes(part, "image_url.url", imageURL)
if detail != "" {
part, _ = sjson.SetBytes(part, "image_url.detail", detail)
}
return part
default:
return chatToolOutputFallbackPart(item)
}
}
func hasChatToolOutputImagePart(content gjson.Result) bool {
if !content.IsArray() {
return false
}
hasImage := false
for _, item := range content.Array() {
itemType := item.Get("type")
if itemType.Type != gjson.String {
continue
}
switch itemType.String() {
case "text", "input_text", "output_text":
if item.Get("text").Type != gjson.String {
return false
}
case "image_url", "input_image":
if _, _, ok := chatToolOutputImageFields(item); !ok {
return false
}
hasImage = true
}
}
return hasImage
}
func chatToolOutputImageFields(item gjson.Result) (imageURL, detail string, ok bool) {
var imageURLValue gjson.Result
var detailValue gjson.Result
switch item.Get("type").String() {
case "image_url":
imageURLValue = item.Get("image_url.url")
detailValue = item.Get("image_url.detail")
case "input_image":
imageURLValue = item.Get("image_url")
detailValue = item.Get("detail")
default:
return "", "", false
}
if imageURLValue.Type != gjson.String {
return "", "", false
}
imageURL = strings.TrimSpace(imageURLValue.String())
if imageURL == "" {
return "", "", false
}
detail, ok = normalizeChatImageDetail(detailValue)
if !ok {
return "", "", false
}
return imageURL, detail, true
}
func normalizeChatImageDetail(detailValue gjson.Result) (string, bool) {
if !detailValue.Exists() {
return "", true
}
if detailValue.Type != gjson.String {
return "", false
}
normalizedDetail := strings.ToLower(strings.TrimSpace(detailValue.String()))
switch normalizedDetail {
case "auto", "low", "high":
return normalizedDetail, true
case "original":
// Chat Completions does not support Codex's original detail value.
return "high", true
default:
return "", true
}
}
func chatToolOutputFallbackPart(item gjson.Result) []byte {
text := item.Raw
if item.Type == gjson.String || text == "" {
text = item.String()
}
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
return part
}
func collectOpenAIResponsesReasoningContent(item gjson.Result) string {
var reasoningText strings.Builder
if summary := item.Get("summary"); summary.Exists() && summary.IsArray() {
summary.ForEach(func(_, summaryItem gjson.Result) bool {
if summaryItem.Get("type").String() != "summary_text" {
return true
}
reasoningText.WriteString(summaryItem.Get("text").String())
return true
})
}
if reasoningText.Len() == 0 {
return "[reasoning unavailable]"
}
return reasoningText.String()
}
func combineOpenAIResponsesReasoning(existing, incoming string) string {
existingTrimmed := strings.TrimSpace(existing)
incomingTrimmed := strings.TrimSpace(incoming)
switch {
case existingTrimmed == "":
return incoming
case incomingTrimmed == "":
return existing
case existingTrimmed == "[reasoning unavailable]":
return incoming
case incomingTrimmed == "[reasoning unavailable]", existingTrimmed == incomingTrimmed:
return existing
default:
return existing + "\n\n" + incoming
}
}

View file

@ -0,0 +1,996 @@
package responses
import (
"bytes"
"context"
"fmt"
"sort"
"strings"
"sync/atomic"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type oaiToResponsesStateReasoning struct {
ReasoningID string
ReasoningData string
OutputIndex int
}
type oaiToResponsesState struct {
Seq int
ResponseID string
Created int64
Started bool
CompletedEmitted bool
ReasoningID string
ReasoningIndex int
// aggregation buffers for response.output
// Per-output message text buffers by index
MsgTextBuf map[int]*strings.Builder
ReasoningBuf strings.Builder
Reasonings []oaiToResponsesStateReasoning
FuncArgsBuf map[string]*strings.Builder
FuncNames map[string]string
FuncCallIDs map[string]string
FuncOutputIx map[string]int
FuncArgsSent map[string]int
MsgOutputIx map[int]int
NextOutputIx int
// message item state per output index
MsgItemAdded map[int]bool // whether response.output_item.added emitted for message
MsgContentAdded map[int]bool // whether response.content_part.added emitted for message
MsgItemDone map[int]bool // whether message done events were emitted
// function item state
FuncItemAdded map[string]bool
FuncItemCustom map[string]bool
FuncArgsDone map[string]bool
FuncItemDone map[string]bool
// names of freeform ("custom") tools from the original request; calls to
// these are emitted as custom_tool_call items instead of function_call
CustomToolNames map[string]struct{}
FinishReason string
// usage aggregation
PromptTokens int64
CachedTokens int64
CompletionTokens int64
TotalTokens int64
ReasoningTokens int64
UsageSeen bool
}
// responseIDCounter provides a process-wide unique counter for synthesized response identifiers.
var responseIDCounter uint64
func emitRespEvent(event string, payload []byte) []byte {
return translatorcommon.SSEEventData(event, payload)
}
func incompleteByFinishReason(reason string) ([]byte, bool) {
switch reason {
case "length", "max_tokens":
return []byte(`{"reason":"max_output_tokens"}`), true
case "content_filter":
return []byte(`{"reason":"content_filter"}`), true
default:
return nil, false
}
}
func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte, nextSeq func() int) []byte {
eventType := "response.completed"
status := "completed"
incompleteDetails, isIncomplete := incompleteByFinishReason(st.FinishReason)
if isIncomplete {
eventType = "response.incomplete"
status = "incomplete"
}
completed := []byte(`{"type":"","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null}}`)
completed, _ = sjson.SetBytes(completed, "type", eventType)
completed, _ = sjson.SetBytes(completed, "sequence_number", nextSeq())
completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID)
completed, _ = sjson.SetBytes(completed, "response.created_at", st.Created)
completed, _ = sjson.SetBytes(completed, "response.status", status)
if len(incompleteDetails) > 0 {
completed, _ = sjson.SetRawBytes(completed, "response.incomplete_details", incompleteDetails)
}
// Inject original request fields into response as per docs/response.completed.json
if requestRawJSON != nil {
req := gjson.ParseBytes(requestRawJSON)
if v := req.Get("instructions"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.instructions", v.String())
}
if v := req.Get("max_output_tokens"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.max_output_tokens", v.Int())
}
if v := req.Get("max_tool_calls"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.max_tool_calls", v.Int())
}
if v := req.Get("model"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.model", v.String())
}
if v := req.Get("parallel_tool_calls"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.parallel_tool_calls", v.Bool())
}
if v := req.Get("previous_response_id"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.previous_response_id", v.String())
}
if v := req.Get("prompt_cache_key"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.prompt_cache_key", v.String())
}
if v := req.Get("reasoning"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.reasoning", v.Value())
}
if v := req.Get("safety_identifier"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.safety_identifier", v.String())
}
if v := req.Get("service_tier"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.service_tier", v.String())
}
if v := req.Get("store"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.store", v.Bool())
}
if v := req.Get("temperature"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.temperature", v.Float())
}
if v := req.Get("text"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.text", v.Value())
}
if v := req.Get("tool_choice"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.tool_choice", v.Value())
}
if v := req.Get("tools"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.tools", v.Value())
}
if v := req.Get("top_logprobs"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.top_logprobs", v.Int())
}
if v := req.Get("top_p"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.top_p", v.Float())
}
if v := req.Get("truncation"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.truncation", v.String())
}
if v := req.Get("user"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.user", v.Value())
}
if v := req.Get("metadata"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.metadata", v.Value())
}
}
type completedOutputItem struct {
index int
raw []byte
}
outputItems := make([]completedOutputItem, 0, len(st.Reasonings)+len(st.MsgItemAdded)+len(st.FuncArgsBuf))
if len(st.Reasonings) > 0 {
for _, r := range st.Reasonings {
item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
item, _ = sjson.SetBytes(item, "id", r.ReasoningID)
item, _ = sjson.SetBytes(item, "summary.0.text", r.ReasoningData)
outputItems = append(outputItems, completedOutputItem{index: r.OutputIndex, raw: item})
}
}
if len(st.MsgItemAdded) > 0 {
for i := range st.MsgItemAdded {
txt := ""
if b := st.MsgTextBuf[i]; b != nil {
txt = b.String()
}
msgStatus := "completed"
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
msgStatus = "incomplete"
}
item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
item, _ = sjson.SetBytes(item, "status", msgStatus)
item, _ = sjson.SetBytes(item, "content.0.text", txt)
outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: item})
}
}
if len(st.FuncArgsBuf) > 0 {
for key := range st.FuncArgsBuf {
if !st.FuncItemDone[key] {
continue
}
args := ""
if b := st.FuncArgsBuf[key]; b != nil {
args = b.String()
}
callID := st.FuncCallIDs[key]
name := st.FuncNames[key]
toolStatus := "completed"
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
toolStatus = "incomplete"
}
if st.FuncItemCustom[key] {
item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args))
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestRawJSON, name, "")
outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
continue
}
item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "arguments", args)
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestRawJSON, name, "")
outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
}
}
sort.Slice(outputItems, func(i, j int) bool { return outputItems[i].index < outputItems[j].index })
outputs := make([][]byte, 0, len(outputItems))
for _, item := range outputItems {
outputs = append(outputs, item.raw)
}
if len(outputs) > 0 {
completed, _ = sjson.SetRawBytes(completed, "response.output", translatorcommon.JoinRawArray(outputs))
}
if st.UsageSeen {
completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens", st.PromptTokens)
completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens_details.cached_tokens", st.CachedTokens)
completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens", st.CompletionTokens)
if st.ReasoningTokens > 0 {
completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens_details.reasoning_tokens", st.ReasoningTokens)
}
total := st.TotalTokens
if total == 0 {
total = st.PromptTokens + st.CompletionTokens
}
completed, _ = sjson.SetBytes(completed, "response.usage.total_tokens", total)
}
return emitRespEvent(eventType, completed)
}
// ConvertOpenAIChatCompletionsResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
// to OpenAI Responses SSE events (response.*).
func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &oaiToResponsesState{
FuncArgsBuf: make(map[string]*strings.Builder),
FuncNames: make(map[string]string),
FuncCallIDs: make(map[string]string),
FuncOutputIx: make(map[string]int),
FuncArgsSent: make(map[string]int),
MsgOutputIx: make(map[int]int),
MsgTextBuf: make(map[int]*strings.Builder),
MsgItemAdded: make(map[int]bool),
MsgContentAdded: make(map[int]bool),
MsgItemDone: make(map[int]bool),
FuncItemAdded: make(map[string]bool),
FuncItemCustom: make(map[string]bool),
FuncArgsDone: make(map[string]bool),
FuncItemDone: make(map[string]bool),
Reasonings: make([]oaiToResponsesStateReasoning, 0),
}
}
st := (*param).(*oaiToResponsesState)
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
rawJSON = bytes.TrimSpace(rawJSON)
if len(rawJSON) == 0 {
return [][]byte{}
}
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
isDone := bytes.Equal(rawJSON, []byte("[DONE]"))
if isDone && (!st.Started || st.CompletedEmitted) {
return [][]byte{}
}
root := gjson.ParseBytes(rawJSON)
if !isDone {
obj := root.Get("object")
if obj.Exists() && obj.String() != "" && obj.String() != "chat.completion.chunk" {
return [][]byte{}
}
if !root.Get("choices").Exists() || !root.Get("choices").IsArray() {
return [][]byte{}
}
}
if usage := root.Get("usage"); usage.Exists() {
if v := usage.Get("prompt_tokens"); v.Exists() {
st.PromptTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() {
st.CachedTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("completion_tokens"); v.Exists() {
st.CompletionTokens = v.Int()
st.UsageSeen = true
} else if v := usage.Get("output_tokens"); v.Exists() {
st.CompletionTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
st.ReasoningTokens = v.Int()
st.UsageSeen = true
} else if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
st.ReasoningTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("total_tokens"); v.Exists() {
st.TotalTokens = v.Int()
st.UsageSeen = true
}
}
nextSeq := func() int { st.Seq++; return st.Seq }
allocOutputIndex := func() int {
ix := st.NextOutputIx
st.NextOutputIx++
return ix
}
toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) }
var out [][]byte
emitToolItem := func(key string, force bool) {
if st.FuncItemAdded[key] {
return
}
callID := st.FuncCallIDs[key]
name := st.FuncNames[key]
if !force && (callID == "" || name == "") {
return
}
if name == "" {
if customToolName, ok := responsesSingleCustomToolName(requestForNamespace); ok {
name = customToolName
st.FuncNames[key] = customToolName
}
}
if callID == "" {
callID = fmt.Sprintf("call_%s_%s", st.ResponseID, strings.ReplaceAll(key, ":", "_"))
st.FuncCallIDs[key] = callID
}
outputIndex := st.FuncOutputIx[key]
_, isCustomTool := st.CustomToolNames[name]
st.FuncItemCustom[key] = isCustomTool
if isCustomTool {
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("ctc_%s", callID))
o, _ = sjson.SetBytes(o, "item.call_id", callID)
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, name, "item")
out = append(out, emitRespEvent("response.output_item.added", o))
} else {
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", callID))
o, _ = sjson.SetBytes(o, "item.call_id", callID)
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, name, "item")
out = append(out, emitRespEvent("response.output_item.added", o))
}
st.FuncItemAdded[key] = true
}
emitPendingFunctionArgs := func(key string) {
if !st.FuncItemAdded[key] || st.FuncItemCustom[key] {
return
}
argsBuf := st.FuncArgsBuf[key]
if argsBuf == nil || argsBuf.Len() <= st.FuncArgsSent[key] {
return
}
args := argsBuf.String()
delta := args[st.FuncArgsSent[key]:]
callID := st.FuncCallIDs[key]
ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", callID))
ad, _ = sjson.SetBytes(ad, "output_index", st.FuncOutputIx[key])
ad, _ = sjson.SetBytes(ad, "delta", delta)
out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
st.FuncArgsSent[key] = len(args)
}
if !st.Started {
st.ResponseID = root.Get("id").String()
st.Created = root.Get("created").Int()
// reset aggregation state for a new streaming response
st.MsgTextBuf = make(map[int]*strings.Builder)
st.ReasoningBuf.Reset()
st.ReasoningID = ""
st.ReasoningIndex = 0
st.FuncArgsBuf = make(map[string]*strings.Builder)
st.FuncNames = make(map[string]string)
st.FuncCallIDs = make(map[string]string)
st.FuncOutputIx = make(map[string]int)
st.FuncArgsSent = make(map[string]int)
st.MsgOutputIx = make(map[int]int)
st.NextOutputIx = 0
st.MsgItemAdded = make(map[int]bool)
st.MsgContentAdded = make(map[int]bool)
st.MsgItemDone = make(map[int]bool)
st.FuncItemAdded = make(map[string]bool)
st.FuncItemCustom = make(map[string]bool)
st.FuncArgsDone = make(map[string]bool)
st.FuncItemDone = make(map[string]bool)
st.CustomToolNames = responsesCustomToolNames(requestForNamespace)
st.PromptTokens = 0
st.CachedTokens = 0
st.CompletionTokens = 0
st.TotalTokens = 0
st.ReasoningTokens = 0
st.FinishReason = ""
st.UsageSeen = false
st.CompletedEmitted = false
// response.created
created := []byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
created, _ = sjson.SetBytes(created, "sequence_number", nextSeq())
created, _ = sjson.SetBytes(created, "response.id", st.ResponseID)
created, _ = sjson.SetBytes(created, "response.created_at", st.Created)
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" {
requestModelName = modelName
}
if requestModelName != "" {
created, _ = sjson.SetBytes(created, "response.model", requestModelName)
}
out = append(out, emitRespEvent("response.created", created))
inprog := []byte(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
inprog, _ = sjson.SetBytes(inprog, "sequence_number", nextSeq())
inprog, _ = sjson.SetBytes(inprog, "response.id", st.ResponseID)
inprog, _ = sjson.SetBytes(inprog, "response.created_at", st.Created)
if requestModelName != "" {
inprog, _ = sjson.SetBytes(inprog, "response.model", requestModelName)
}
out = append(out, emitRespEvent("response.in_progress", inprog))
st.Started = true
}
stopReasoning := func(text string) {
// Emit reasoning done events
textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq())
textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningID)
textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex)
textDone, _ = sjson.SetBytes(textDone, "text", text)
out = append(out, emitRespEvent("response.reasoning_summary_text.done", textDone))
partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningID)
partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
partDone, _ = sjson.SetBytes(partDone, "part.text", text)
out = append(out, emitRespEvent("response.reasoning_summary_part.done", partDone))
outputItemDone := []byte(`{"type":"response.output_item.done","item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]},"output_index":0,"sequence_number":0}`)
outputItemDone, _ = sjson.SetBytes(outputItemDone, "sequence_number", nextSeq())
outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.id", st.ReasoningID)
outputItemDone, _ = sjson.SetBytes(outputItemDone, "output_index", st.ReasoningIndex)
outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.0.text", text)
out = append(out, emitRespEvent("response.output_item.done", outputItemDone))
st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex})
st.ReasoningID = ""
}
emitMessageItemDone := func(idx int) {
if !st.MsgItemAdded[idx] || st.MsgItemDone[idx] {
return
}
msgOutputIndex := st.MsgOutputIx[idx]
fullText := ""
if b := st.MsgTextBuf[idx]; b != nil {
fullText = b.String()
}
done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex)
done, _ = sjson.SetBytes(done, "content_index", 0)
done, _ = sjson.SetBytes(done, "text", fullText)
out = append(out, emitRespEvent("response.output_text.done", done))
partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex)
partDone, _ = sjson.SetBytes(partDone, "content_index", 0)
partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
out = append(out, emitRespEvent("response.content_part.done", partDone))
msgStatus := "completed"
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
msgStatus = "incomplete"
}
itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex)
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
itemDone, _ = sjson.SetBytes(itemDone, "item.status", msgStatus)
itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)
out = append(out, emitRespEvent("response.output_item.done", itemDone))
st.MsgItemDone[idx] = true
}
finalizeOpenItems := func() {
if len(st.MsgItemAdded) > 0 {
idxs := make([]int, 0, len(st.MsgItemAdded))
for idx := range st.MsgItemAdded {
idxs = append(idxs, idx)
}
sort.Slice(idxs, func(i, j int) bool { return st.MsgOutputIx[idxs[i]] < st.MsgOutputIx[idxs[j]] })
for _, idx := range idxs {
emitMessageItemDone(idx)
}
}
if st.ReasoningID != "" {
stopReasoning(st.ReasoningBuf.String())
st.ReasoningBuf.Reset()
}
if len(st.FuncArgsBuf) == 0 {
return
}
keys := make([]string, 0, len(st.FuncArgsBuf))
for key := range st.FuncArgsBuf {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
left := st.FuncOutputIx[keys[i]]
right := st.FuncOutputIx[keys[j]]
return left < right || (left == right && keys[i] < keys[j])
})
for _, key := range keys {
if st.FuncItemDone[key] {
continue
}
b := st.FuncArgsBuf[key]
hasArgs := b != nil && b.Len() > 0
_, isIncomplete := incompleteByFinishReason(st.FinishReason)
isExplicitToolFinish := st.FinishReason == "tool_calls" || st.FinishReason == "stop"
// If stream ended without finish_reason:
// If no arguments or partial/invalid JSON arguments were received, do not synthesize empty arguments
// or complete the in-flight tool call item as successfully completed.
if st.FinishReason == "" && (!hasArgs || !gjson.Valid(b.String())) {
continue
}
emitToolItem(key, true)
emitPendingFunctionArgs(key)
callID := st.FuncCallIDs[key]
if callID == "" || st.FuncItemDone[key] {
continue
}
outputIndex := st.FuncOutputIx[key]
toolStatus := "completed"
args := "{}"
if hasArgs {
args = b.String()
} else if isIncomplete || !isExplicitToolFinish {
args = ""
}
if isIncomplete {
toolStatus = "incomplete"
}
if st.FuncItemCustom[key] {
input := unwrapCustomToolInput(args)
inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID))
inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex)
inputDone, _ = sjson.SetBytes(inputDone, "input", input)
out = append(out, emitRespEvent("response.custom_tool_call_input.done", inputDone))
itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", callID))
itemDone, _ = sjson.SetBytes(itemDone, "item.status", toolStatus)
itemDone, _ = sjson.SetBytes(itemDone, "item.input", input)
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForNamespace, st.FuncNames[key], "item")
out = append(out, emitRespEvent("response.output_item.done", itemDone))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
continue
}
fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID))
fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)
out = append(out, emitRespEvent("response.function_call_arguments.done", fcDone))
itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
itemDone, _ = sjson.SetBytes(itemDone, "item.status", toolStatus)
itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForNamespace, st.FuncNames[key], "item")
out = append(out, emitRespEvent("response.output_item.done", itemDone))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
}
}
if isDone {
finalizeOpenItems()
hasActiveUnfinishedTool := false
for key := range st.FuncItemAdded {
if !st.FuncItemDone[key] {
hasActiveUnfinishedTool = true
break
}
}
if hasActiveUnfinishedTool {
return out
}
if len(st.MsgItemAdded) == 0 && len(st.FuncItemAdded) == 0 {
return out
}
st.CompletedEmitted = true
out = append(out, buildResponsesCompletedEvent(st, requestForNamespace, nextSeq))
return out
}
// choices[].delta content / tool_calls / reasoning_content
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
choices.ForEach(func(_, choice gjson.Result) bool {
idx := int(choice.Get("index").Int())
delta := choice.Get("delta")
if delta.Exists() {
if c := delta.Get("content"); c.Exists() && c.String() != "" {
// Ensure the message item and its first content part are announced before any text deltas
if st.ReasoningID != "" {
stopReasoning(st.ReasoningBuf.String())
st.ReasoningBuf.Reset()
}
if _, exists := st.MsgOutputIx[idx]; !exists {
st.MsgOutputIx[idx] = allocOutputIndex()
}
msgOutputIndex := st.MsgOutputIx[idx]
if !st.MsgItemAdded[idx] {
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
item, _ = sjson.SetBytes(item, "output_index", msgOutputIndex)
item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
out = append(out, emitRespEvent("response.output_item.added", item))
st.MsgItemAdded[idx] = true
}
if !st.MsgContentAdded[idx] {
part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
part, _ = sjson.SetBytes(part, "output_index", msgOutputIndex)
part, _ = sjson.SetBytes(part, "content_index", 0)
out = append(out, emitRespEvent("response.content_part.added", part))
st.MsgContentAdded[idx] = true
}
msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
msg, _ = sjson.SetBytes(msg, "output_index", msgOutputIndex)
msg, _ = sjson.SetBytes(msg, "content_index", 0)
msg, _ = sjson.SetBytes(msg, "delta", c.String())
out = append(out, emitRespEvent("response.output_text.delta", msg))
// aggregate for response.output
if st.MsgTextBuf[idx] == nil {
st.MsgTextBuf[idx] = &strings.Builder{}
}
st.MsgTextBuf[idx].WriteString(c.String())
}
// reasoning_content (OpenAI reasoning incremental text)
rc := delta.Get("reasoning_content")
if !rc.Exists() || rc.String() == "" {
rc = delta.Get("reasoning")
}
if rc.Exists() && rc.String() != "" {
// On first appearance, add reasoning item and part
if st.ReasoningID == "" {
st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
st.ReasoningIndex = allocOutputIndex()
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID)
out = append(out, emitRespEvent("response.output_item.added", item))
part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
part, _ = sjson.SetBytes(part, "item_id", st.ReasoningID)
part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex)
out = append(out, emitRespEvent("response.reasoning_summary_part.added", part))
}
// Append incremental text to reasoning buffer
st.ReasoningBuf.WriteString(rc.String())
msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningID)
msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex)
msg, _ = sjson.SetBytes(msg, "delta", rc.String())
out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg))
}
// tool calls
if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() {
if st.ReasoningID != "" {
stopReasoning(st.ReasoningBuf.String())
st.ReasoningBuf.Reset()
}
// Before emitting any function events, if a message is open for this index,
// close its text/content to match Codex expected ordering.
emitMessageItemDone(idx)
tcs.ForEach(func(_, tc gjson.Result) bool {
toolIndex := int(tc.Get("index").Int())
key := toolStateKey(idx, toolIndex)
if st.FuncArgsBuf[key] == nil {
st.FuncArgsBuf[key] = &strings.Builder{}
st.FuncOutputIx[key] = allocOutputIndex()
}
if newCallID := tc.Get("id").String(); newCallID != "" && st.FuncCallIDs[key] == "" {
st.FuncCallIDs[key] = newCallID
}
nameChunk := tc.Get("function.name").String()
if nameChunk != "" && !st.FuncItemAdded[key] {
st.FuncNames[key] = nameChunk
}
if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" {
st.FuncArgsBuf[key].WriteString(args.String())
}
emitToolItem(key, false)
emitPendingFunctionArgs(key)
return true
})
}
}
// finish_reason triggers item-level finalization. response.completed is
// deferred until the terminal [DONE] marker so late usage-only chunks can
// still populate response.usage.
if fr := choice.Get("finish_reason"); fr.Exists() && fr.String() != "" {
st.FinishReason = fr.String()
finalizeOpenItems()
}
return true
})
}
return out
}
// ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream builds a single Responses JSON
// from a non-streaming OpenAI Chat Completions response.
func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
root := gjson.ParseBytes(rawJSON)
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
finishReason := root.Get("choices.0.finish_reason").String()
incompleteDetails, isIncomplete := incompleteByFinishReason(finishReason)
respStatus := "completed"
if isIncomplete {
respStatus = "incomplete"
}
// Basic response scaffold
resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`)
resp, _ = sjson.SetBytes(resp, "status", respStatus)
if isIncomplete {
resp, _ = sjson.SetRawBytes(resp, "incomplete_details", incompleteDetails)
}
// id: use provider id if present, otherwise synthesize
id := root.Get("id").String()
if id == "" {
id = fmt.Sprintf("resp_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&responseIDCounter, 1))
}
resp, _ = sjson.SetBytes(resp, "id", id)
// created_at: map from chat.completion created
created := root.Get("created").Int()
if created == 0 {
created = time.Now().Unix()
}
resp, _ = sjson.SetBytes(resp, "created_at", created)
// Echo request fields when available (aligns with streaming path behavior)
if len(requestRawJSON) > 0 {
req := gjson.ParseBytes(requestRawJSON)
if v := req.Get("instructions"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "instructions", v.String())
}
if v := req.Get("max_output_tokens"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "max_output_tokens", v.Int())
} else {
// Also support max_tokens from chat completion style
if v = req.Get("max_tokens"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "max_output_tokens", v.Int())
}
}
if v := req.Get("max_tool_calls"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "max_tool_calls", v.Int())
}
if v := req.Get("model"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "model", v.String())
} else if v = root.Get("model"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "model", v.String())
}
if v := req.Get("parallel_tool_calls"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "parallel_tool_calls", v.Bool())
}
if v := req.Get("previous_response_id"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "previous_response_id", v.String())
}
if v := req.Get("prompt_cache_key"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "prompt_cache_key", v.String())
}
if v := req.Get("reasoning"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "reasoning", v.Value())
}
if v := req.Get("safety_identifier"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "safety_identifier", v.String())
}
if v := req.Get("service_tier"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "service_tier", v.String())
}
if v := req.Get("store"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "store", v.Bool())
}
if v := req.Get("temperature"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "temperature", v.Float())
}
if v := req.Get("text"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "text", v.Value())
}
if v := req.Get("tool_choice"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "tool_choice", v.Value())
}
if v := req.Get("tools"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "tools", v.Value())
}
if v := req.Get("top_logprobs"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "top_logprobs", v.Int())
}
if v := req.Get("top_p"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "top_p", v.Float())
}
if v := req.Get("truncation"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "truncation", v.String())
}
if v := req.Get("user"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "user", v.Value())
}
if v := req.Get("metadata"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "metadata", v.Value())
}
} else if v := root.Get("model"); v.Exists() {
// Fallback model from response
resp, _ = sjson.SetBytes(resp, "model", v.String())
}
// Build output list from choices[...]
var outputItems [][]byte
// Detect and capture reasoning content if present (with fallback to reasoning)
rc := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content")
if !rc.Exists() || rc.String() == "" {
rc = gjson.GetBytes(rawJSON, "choices.0.message.reasoning")
}
rcText := rc.String()
includeReasoning := rcText != ""
if !includeReasoning && len(requestRawJSON) > 0 {
includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists()
}
if includeReasoning {
rid := id
if strings.HasPrefix(rid, "resp_") {
rid = strings.TrimPrefix(rid, "resp_")
}
// Prefer summary_text from reasoning_content; encrypted_content is optional
reasoningItem := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
reasoningItem, _ = sjson.SetBytes(reasoningItem, "id", fmt.Sprintf("rs_%s", rid))
if rcText != "" {
reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.type", "summary_text")
reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rcText)
}
outputItems = append(outputItems, reasoningItem)
}
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
choices.ForEach(func(_, choice gjson.Result) bool {
msg := choice.Get("message")
if msg.Exists() {
// Text message part
if c := msg.Get("content"); c.Exists() && c.String() != "" {
itemStatus := "completed"
if isIncomplete {
itemStatus = "incomplete"
}
item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", id, int(choice.Get("index").Int())))
item, _ = sjson.SetBytes(item, "status", itemStatus)
item, _ = sjson.SetBytes(item, "content.0.text", c.String())
outputItems = append(outputItems, item)
}
// Function/tool calls
if tcs := msg.Get("tool_calls"); tcs.Exists() && tcs.IsArray() {
customToolNames := responsesCustomToolNames(requestForNamespace)
tcs.ForEach(func(tcIndex, tc gjson.Result) bool {
callID := tc.Get("id").String()
if callID == "" {
// Providers may omit tool_call ids; synthesize one so the
// function_call item stays usable for Codex round-trips.
callID = fmt.Sprintf("call_%s_%d_%d", id, choice.Get("index").Int(), tcIndex.Int())
}
name := tc.Get("function.name").String()
args := tc.Get("function.arguments").String()
toolStatus := "completed"
if isIncomplete {
toolStatus = "incomplete"
}
if _, isCustomTool := customToolNames[name]; isCustomTool {
item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args))
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestForNamespace, name, "")
outputItems = append(outputItems, item)
return true
}
item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "arguments", args)
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestForNamespace, name, "")
outputItems = append(outputItems, item)
return true
})
}
}
return true
})
}
if len(outputItems) > 0 {
resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputItems))
}
// usage mapping
if usage := root.Get("usage"); usage.Exists() {
// Map common tokens
if usage.Get("prompt_tokens").Exists() || usage.Get("completion_tokens").Exists() || usage.Get("total_tokens").Exists() {
resp, _ = sjson.SetBytes(resp, "usage.input_tokens", usage.Get("prompt_tokens").Int())
if d := usage.Get("prompt_tokens_details.cached_tokens"); d.Exists() {
resp, _ = sjson.SetBytes(resp, "usage.input_tokens_details.cached_tokens", d.Int())
}
resp, _ = sjson.SetBytes(resp, "usage.output_tokens", usage.Get("completion_tokens").Int())
// Reasoning tokens not available in Chat Completions; set only if present under output_tokens_details
if d := usage.Get("output_tokens_details.reasoning_tokens"); d.Exists() {
resp, _ = sjson.SetBytes(resp, "usage.output_tokens_details.reasoning_tokens", d.Int())
}
resp, _ = sjson.SetBytes(resp, "usage.total_tokens", usage.Get("total_tokens").Int())
} else {
// Fallback to raw usage object if structure differs
resp, _ = sjson.SetBytes(resp, "usage", usage.Value())
}
}
return resp
}

View file

@ -0,0 +1,326 @@
package responses
import (
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// responsesToolDeclaration is one Responses tool declaration paired with the
// Chat Completions function name it produces. Namespace children carry both
// their declared name and the owning namespace, so reverse translation can
// restore the split identity.
type responsesToolDeclaration struct {
tool gjson.Result
chatName string
localName string
namespace string
custom bool
}
// walkResponsesToolDeclarations visits the tool declarations of a Responses
// request in one canonical order: the top-level "tools" field first, then
// Codex Desktop (Responses Lite) "additional_tools" input items, namespace
// children in declaration order. Declarations that produce no Chat Completions
// tool are skipped. Visiting stops early once visit returns false.
//
// Request conversion, reverse name resolution and freeform tool classification
// all traverse through here, so they cannot disagree about which declaration
// backs a given Chat Completions tool name.
func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDeclaration) bool) {
proceed := true
emit := func(tool gjson.Result, namespaceName string) {
if !proceed {
return
}
var custom bool
switch strings.TrimSpace(tool.Get("type").String()) {
case "", "function":
case "custom":
custom = true
default:
return
}
localName := responsesToolName(tool)
if localName == "" {
return
}
proceed = visit(responsesToolDeclaration{
tool: tool,
chatName: qualifyResponsesNamespaceToolName(namespaceName, localName),
localName: localName,
namespace: namespaceName,
custom: custom,
})
}
scan := func(tools gjson.Result) {
if !proceed || !tools.Exists() || !tools.IsArray() {
return
}
tools.ForEach(func(_, tool gjson.Result) bool {
if strings.TrimSpace(tool.Get("type").String()) == "namespace" {
if children := tool.Get("tools"); children.Exists() && children.IsArray() {
namespaceName := strings.TrimSpace(tool.Get("name").String())
children.ForEach(func(_, child gjson.Result) bool {
emit(child, namespaceName)
return proceed
})
}
return proceed
}
emit(tool, "")
return proceed
})
}
scan(root.Get("tools"))
if input := root.Get("input"); input.Exists() && input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "additional_tools" {
scan(item.Get("tools"))
}
return proceed
})
}
}
// mergeResponsesRequestChatTools converts every tool declaration in a Responses
// request into Chat Completions form, merging the top-level "tools" field with
// Codex Desktop (Responses Lite) "additional_tools" input items.
//
// Codex clients may deliver the same tool through both channels, and namespace
// qualification can collapse distinct declarations onto one Chat Completions
// name, so entries are deduplicated by function name. The first occurrence
// wins, which keeps the top-level "tools" definition authoritative over the
// "additional_tools" copy. Chat Completions requires tool names to be unique;
// strict upstreams reject the whole request otherwise.
func mergeResponsesRequestChatTools(root gjson.Result) [][]byte {
var merged [][]byte
seenToolNames := make(map[string]struct{})
walkResponsesToolDeclarations(root, func(declaration responsesToolDeclaration) bool {
if _, duplicate := seenToolNames[declaration.chatName]; duplicate {
return true
}
convert := convertResponsesFunctionToolToOpenAIChat
if declaration.custom {
convert = convertResponsesCustomToolToOpenAIChat
}
if chatTool, ok := convert(declaration.tool, declaration.chatName); ok {
seenToolNames[declaration.chatName] = struct{}{}
merged = append(merged, chatTool)
}
return true
})
return merged
}
// convertResponsesCustomToolToOpenAIChat maps a Responses freeform ("custom")
// tool onto a Chat Completions function tool with a single freeform "input"
// string, mirroring the function-based shape Codex uses for apply_patch.
func convertResponsesCustomToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
}
if name == "" {
return nil, false
}
chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}}`)
chatTool, _ = sjson.SetBytes(chatTool, "function.name", name)
if description := responsesToolDescription(tool); description != "" {
chatTool, _ = sjson.SetBytes(chatTool, "function.description", description)
}
return chatTool, true
}
func convertResponsesFunctionToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
}
if name == "" {
return nil, false
}
chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{}}}`)
chatTool, _ = sjson.SetBytes(chatTool, "function.name", name)
if description := responsesToolDescription(tool); description != "" {
chatTool, _ = sjson.SetBytes(chatTool, "function.description", description)
}
if parameters := responsesToolParameters(tool); parameters.Exists() {
chatTool, _ = sjson.SetRawBytes(chatTool, "function.parameters", []byte(parameters.Raw))
}
return chatTool, true
}
func responsesToolName(tool gjson.Result) string {
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
return name
}
return strings.TrimSpace(tool.Get("function.name").String())
}
func responsesToolDescription(tool gjson.Result) string {
if description := tool.Get("description").String(); description != "" {
return description
}
return tool.Get("function.description").String()
}
func responsesToolParameters(tool gjson.Result) gjson.Result {
for _, path := range []string{
"parameters",
"parametersJsonSchema",
"input_schema",
"function.parameters",
"function.parametersJsonSchema",
} {
if parameters := tool.Get(path); parameters.Exists() {
return parameters
}
}
return gjson.Result{}
}
// responsesToolOutputText flattens a tool output value that may be a plain
// string or an array of content parts ({"type":"input_text","text":...}) into
// a single text payload for a Chat Completions tool message.
func responsesToolOutputText(output gjson.Result) string {
if output.Type == gjson.String {
return output.String()
}
if output.IsArray() {
var b strings.Builder
output.ForEach(func(_, part gjson.Result) bool {
if part.Type == gjson.String {
b.WriteString(part.String())
return true
}
if text := part.Get("text"); text.Exists() {
b.WriteString(text.String())
}
return true
})
return b.String()
}
if output.Exists() {
return output.Raw
}
return ""
}
// responsesCustomToolNames collects the Chat Completions names of the freeform
// ("custom") tools that survive the merge, so response translation only unwraps
// freeform arguments for calls whose winning declaration really was freeform.
//
// Declaration types may differ across the two delivery channels: a top-level
// function and an "additional_tools" custom tool can flatten to the same name.
// Classification therefore follows the same first-wins rule as the merge —
// a discarded custom declaration must not turn a surviving ordinary function
// into a custom_tool_call.
func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} {
names := make(map[string]struct{})
seenToolNames := make(map[string]struct{})
walkResponsesToolDeclarations(gjson.ParseBytes(requestRawJSON), func(declaration responsesToolDeclaration) bool {
if _, duplicate := seenToolNames[declaration.chatName]; duplicate {
return true
}
seenToolNames[declaration.chatName] = struct{}{}
if declaration.custom {
names[declaration.chatName] = struct{}{}
}
return true
})
return names
}
func responsesSingleCustomToolName(requestRawJSON []byte) (string, bool) {
customToolNames := responsesCustomToolNames(requestRawJSON)
if len(customToolNames) != 1 {
return "", false
}
// Count the tools actually emitted, which are deduplicated by name, so a
// tool delivered through both "tools" and "additional_tools" still counts
// once and freeform unwrapping stays enabled.
toolCount := len(mergeResponsesRequestChatTools(gjson.ParseBytes(requestRawJSON)))
for name := range customToolNames {
return name, toolCount == 1
}
return "", false
}
// unwrapCustomToolInput extracts the freeform input from the {"input": "..."}
// function-call arguments produced for a converted custom tool; it falls back
// to the raw arguments when the wrapper is absent.
func unwrapCustomToolInput(arguments string) string {
if v := gjson.Get(arguments, "input"); v.Exists() {
if v.Type == gjson.String {
return v.String()
}
return v.Raw
}
return arguments
}
func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
childName = strings.TrimSpace(childName)
if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
return childName
}
if strings.HasPrefix(childName, namespaceName) {
return childName
}
if strings.HasSuffix(namespaceName, "__") {
return namespaceName + childName
}
return namespaceName + "__" + childName
}
// resolveResponsesQualifiedToolIdentity maps an emitted Chat Completions
// function name back to the Responses declaration that produced it.
//
// Declarations are walked in the same order mergeResponsesRequestChatTools
// uses, and the first one producing the name wins, so reverse translation
// reports the identity of the declaration that actually survived the merge. A
// flat top-level tool named "editor__apply_patch" therefore stays flat even
// when a later namespace declares a child qualifying to the same name.
func resolveResponsesQualifiedToolIdentity(root gjson.Result, qualifiedName string) (name, namespace string, found bool) {
walkResponsesToolDeclarations(root, func(declaration responsesToolDeclaration) bool {
if declaration.chatName != qualifiedName {
return true
}
name, namespace, found = declaration.localName, declaration.namespace, true
return false
})
return name, namespace, found
}
func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, qualifiedName string) (name, namespace string) {
qualifiedName = strings.TrimSpace(qualifiedName)
if qualifiedName == "" {
return "", ""
}
if resolvedName, resolvedNamespace, ok := resolveResponsesQualifiedToolIdentity(gjson.ParseBytes(requestRawJSON), qualifiedName); ok {
return resolvedName, resolvedNamespace
}
return qualifiedName, ""
}
func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
return originalRequestRawJSON
}
if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
return requestRawJSON
}
return nil
}
func applyResponsesFunctionCallNamespaceFields(item []byte, requestRawJSON []byte, qualifiedName string, itemPath string) []byte {
name, namespace := splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON, qualifiedName)
return translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, itemPath)
}