Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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":[]}`), ¶m)
|
||||
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]"), ¶m)
|
||||
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"}`), ¶m)
|
||||
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"}`), ¶m)
|
||||
if len(out) != 1 || !bytes.Equal(out[0], []byte(`{"id":"y"}`)) {
|
||||
t.Fatalf("out = %v", out)
|
||||
}
|
||||
}
|
||||
19
backend/internal/translator/openai/openai/responses/init.go
Normal file
19
backend/internal/translator/openai/openai/responses/init.go
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in a new issue