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,28 @@
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,
Interactions,
ConvertOpenAIRequestToInteractions,
interfaces.TranslateResponse{
Stream: ConvertInteractionsResponseToOpenAI,
NonStream: ConvertInteractionsResponseToOpenAINonStream,
},
)
translator.Register(
Interactions,
OpenAI,
ConvertInteractionsRequestToOpenAI,
interfaces.TranslateResponse{
Stream: ConvertOpenAIResponseToInteractions,
NonStream: ConvertOpenAIResponseToInteractionsNonStream,
},
)
}

View file

@ -0,0 +1,408 @@
package chat_completions
import (
"fmt"
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","messages":[]}`)
out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String()))
if stream || root.Get("stream").Bool() {
out, _ = sjson.SetBytes(out, "stream", true)
}
messageCapacity := root.Get("input.#").Int()
if interactionsText(root.Get("system_instruction")) != "" {
messageCapacity++
}
messageItems := translatorcommon.NewRawArrayItems(messageCapacity)
appendInteractionsSystemToOpenAI(&messageItems, root)
appendInteractionsInputToOpenAIMessages(&messageItems, root.Get("input"))
out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
out = copyInteractionsToolsToOpenAI(out, root)
out = copyInteractionsGenerationConfigToOpenAI(out, root)
out = copyInteractionsOpenAITopLevel(out, root)
return out
}
func appendInteractionsSystemToOpenAI(items *[][]byte, root gjson.Result) {
text := interactionsText(root.Get("system_instruction"))
if text == "" {
return
}
msg := []byte(`{"role":"system","content":""}`)
msg, _ = sjson.SetBytes(msg, "content", text)
*items = append(*items, msg)
}
func appendInteractionsInputToOpenAIMessages(items *[][]byte, input gjson.Result) {
if input.Type == gjson.String {
msg := []byte(`{"role":"user","content":""}`)
msg, _ = sjson.SetBytes(msg, "content", input.String())
*items = append(*items, msg)
return
}
if input.IsArray() {
input.ForEach(func(_, step gjson.Result) bool {
appendInteractionsStepToOpenAI(items, step, "user")
return true
})
return
}
if input.IsObject() {
appendInteractionsStepToOpenAI(items, input, "user")
}
}
func appendInteractionsStepToOpenAI(items *[][]byte, step gjson.Result, defaultRole string) {
switch step.Get("type").String() {
case "user_input":
appendInteractionsMessageToOpenAI(items, step, "user")
case "model_output":
appendInteractionsMessageToOpenAI(items, step, "assistant")
case "thought":
appendInteractionsThoughtToOpenAI(items, step)
case "function_call":
appendInteractionsFunctionCallToOpenAI(items, step)
case "function_result":
appendInteractionsFunctionResultToOpenAI(items, step)
default:
if step.Type == gjson.String {
msg := []byte(`{"role":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "role", defaultRole)
msg, _ = sjson.SetBytes(msg, "content", step.String())
*items = append(*items, msg)
}
}
}
func appendInteractionsMessageToOpenAI(items *[][]byte, step gjson.Result, role string) {
msg := []byte(`{"role":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "role", role)
content := step.Get("content")
if content.Type == gjson.String {
msg, _ = sjson.SetBytes(msg, "content", content.String())
} else {
msg = appendInteractionsContentToOpenAIMessage(msg, content, role)
}
*items = append(*items, msg)
}
func appendInteractionsThoughtToOpenAI(items *[][]byte, step gjson.Result) {
msg := []byte(`{"role":"assistant","content":"","reasoning_content":""}`)
msg, _ = sjson.SetBytes(msg, "reasoning_content", interactionsText(step.Get("content")))
*items = append(*items, msg)
}
func appendInteractionsContentToOpenAIMessage(msg []byte, content gjson.Result, role string) []byte {
if !content.Exists() {
return msg
}
if content.Type == gjson.String {
msg, _ = sjson.SetBytes(msg, "content", content.String())
return msg
}
contentItems := make([][]byte, 0, 4)
textOnly := true
var textBuilder strings.Builder
appendPart := func(part gjson.Result) {
converted, ok := interactionsContentPartToOpenAI(part, role)
if !ok {
return
}
if gjson.GetBytes(converted, "type").String() == "text" {
textBuilder.WriteString(gjson.GetBytes(converted, "text").String())
} else {
textOnly = false
}
contentItems = append(contentItems, converted)
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
appendPart(part)
return true
})
} else if content.IsObject() {
appendPart(content)
}
if len(contentItems) > 0 {
if textOnly {
msg, _ = sjson.SetBytes(msg, "content", textBuilder.String())
} else {
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
}
}
return msg
}
func appendInteractionsFunctionCallToOpenAI(items *[][]byte, step gjson.Result) {
msg := []byte(`{"role":"assistant","content":"","tool_calls":[]}`)
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0")
toolCall, _ = sjson.SetBytes(toolCall, "id", callID)
toolCall, _ = sjson.SetBytes(toolCall, "function.name", step.Get("name").String())
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(step.Get("arguments"), "{}"))
msg = translatorcommon.SetRawArrayItems(msg, "tool_calls", [][]byte{toolCall})
*items = append(*items, msg)
}
func appendInteractionsFunctionResultToOpenAI(items *[][]byte, step gjson.Result) {
msg := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "tool_call_id", firstNonEmpty(step.Get("call_id").String(), step.Get("id").String()))
msg, _ = sjson.SetBytes(msg, "content", jsonStringValue(firstExisting(step.Get("result"), step.Get("output")), ""))
*items = append(*items, msg)
}
func copyInteractionsToolsToOpenAI(out []byte, root gjson.Result) []byte {
tools := root.Get("tools")
if !tools.Exists() || !tools.IsArray() {
return out
}
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if converted, ok := openAIToolFromInteractionsTool(tool); ok {
toolItems = append(toolItems, converted)
}
if decls := firstExisting(tool.Get("function_declarations"), tool.Get("functionDeclarations")); decls.Exists() && decls.IsArray() {
decls.ForEach(func(_, decl gjson.Result) bool {
if converted, ok := openAIToolFromInteractionsTool(decl); ok {
toolItems = append(toolItems, converted)
}
return true
})
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func copyInteractionsGenerationConfigToOpenAI(out []byte, root gjson.Result) []byte {
gen := root.Get("generation_config")
if !gen.Exists() {
gen = root.Get("generationConfig")
}
copyNumber(&out, "temperature", firstExisting(gen.Get("temperature"), root.Get("temperature")))
copyNumber(&out, "max_tokens", firstExisting(gen.Get("max_output_tokens"), gen.Get("maxOutputTokens"), root.Get("max_tokens"), root.Get("max_completion_tokens")))
copyNumber(&out, "top_p", firstExisting(gen.Get("top_p"), gen.Get("topP"), root.Get("top_p")))
copyNumber(&out, "top_k", firstExisting(gen.Get("top_k"), gen.Get("topK")))
copyNumber(&out, "n", firstExisting(gen.Get("candidate_count"), gen.Get("candidateCount"), root.Get("n")))
if stop := firstExisting(gen.Get("stop_sequences"), gen.Get("stopSequences"), root.Get("stop")); stop.Exists() {
out, _ = sjson.SetRawBytes(out, "stop", []byte(stop.Raw))
}
if toolChoice := firstExisting(gen.Get("tool_choice"), root.Get("tool_choice")); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
}
if effort := interactionsReasoningEffort(root, gen); effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
if responseModalities := root.Get("response_modalities"); responseModalities.Exists() {
out, _ = sjson.SetRawBytes(out, "modalities", []byte(responseModalities.Raw))
}
return out
}
func copyInteractionsOpenAITopLevel(out []byte, root gjson.Result) []byte {
if format := root.Get("response_format"); format.Exists() {
out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw))
}
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
}
if previousInteractionID := firstNonEmpty(root.Get("previous_interaction_id").String(), root.Get("previous_response_id").String()); previousInteractionID != "" {
out, _ = sjson.SetBytes(out, "previous_response_id", previousInteractionID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
out, _ = sjson.SetBytes(out, "environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
}
for _, key := range []string{"parallel_tool_calls", "seed", "user"} {
if value := root.Get(key); value.Exists() {
out, _ = sjson.SetRawBytes(out, key, []byte(value.Raw))
}
}
return out
}
func interactionsContentPartToOpenAI(part gjson.Result, role string) ([]byte, bool) {
partType := part.Get("type").String()
if partType == "" && part.Get("text").Exists() {
partType = "text"
}
switch partType {
case "text":
out := []byte(`{"type":"text","text":""}`)
out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "image":
out := []byte(`{"type":"image_url","image_url":{"url":""}}`)
out, _ = sjson.SetBytes(out, "image_url.url", interactionsMediaDataURL(part, "application/octet-stream"))
return out, true
case "audio":
out := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
out, _ = sjson.SetBytes(out, "input_audio.data", part.Get("data").String())
out, _ = sjson.SetBytes(out, "input_audio.format", openAIInputAudioFormatFromMIME(part.Get("mime_type").String()))
return out, true
case "video":
out := []byte(`{"type":"video_url","video_url":{"url":""}}`)
out, _ = sjson.SetBytes(out, "video_url.url", interactionsMediaDataURL(part, "video/mp4"))
return out, true
case "document", "file":
out := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`)
out, _ = sjson.SetBytes(out, "file.filename", firstNonEmpty(part.Get("filename").String(), openAIFileNameFromMIME(part.Get("mime_type").String())))
out, _ = sjson.SetBytes(out, "file.file_data", part.Get("data").String())
if url := firstNonEmpty(part.Get("file_url").String(), part.Get("url").String()); url != "" {
out, _ = sjson.DeleteBytes(out, "file.file_data")
out, _ = sjson.SetBytes(out, "file.file_url", url)
}
return out, true
default:
_ = role
}
return nil, false
}
func openAIToolFromInteractionsTool(tool gjson.Result) ([]byte, bool) {
name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String())
if name == "" {
return nil, false
}
out := []byte(`{"type":"function","function":{"name":""}}`)
out, _ = sjson.SetBytes(out, "function.name", name)
if desc := firstExisting(tool.Get("description"), tool.Get("function.description")); desc.Exists() {
out, _ = sjson.SetBytes(out, "function.description", desc.String())
}
if params := firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema")); params.Exists() {
out, _ = sjson.SetRawBytes(out, "function.parameters", []byte(params.Raw))
}
return out, true
}
func interactionsText(value gjson.Result) string {
if !value.Exists() {
return ""
}
if value.Type == gjson.String {
return value.String()
}
if text := value.Get("text"); text.Exists() {
return text.String()
}
for _, path := range []string{"content", "parts"} {
parts := value.Get(path)
if !parts.Exists() || !parts.IsArray() {
continue
}
var builder strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
builder.WriteString(firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()))
return true
})
return builder.String()
}
return ""
}
func interactionsReasoningEffort(root, gen gjson.Result) string {
for _, value := range []gjson.Result{
gen.Get("reasoning_effort"),
gen.Get("thinking_level"),
gen.Get("thinkingLevel"),
gen.Get("thinking_config.thinking_level"),
gen.Get("thinkingConfig.thinkingLevel"),
root.Get("reasoning_effort"),
} {
if value.Exists() && value.Type == gjson.String {
return strings.ToLower(strings.TrimSpace(value.String()))
}
}
return ""
}
func interactionsMediaDataURL(part gjson.Result, fallbackMimeType string) string {
if url := firstNonEmpty(part.Get("image_url").String(), part.Get("file_data").String(), part.Get("url").String()); url != "" {
return url
}
data := part.Get("data").String()
if data == "" {
return ""
}
mimeType := firstNonEmpty(part.Get("mime_type").String(), fallbackMimeType)
return "data:" + mimeType + ";base64," + data
}
func openAIInputAudioFormatFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "audio/wav", "audio/wave", "audio/x-wav":
return "wav"
case "audio/flac":
return "flac"
case "audio/opus", "audio/ogg":
return "opus"
case "audio/pcm", "audio/l16":
return "pcm16"
default:
return "mp3"
}
}
func openAIFileNameFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "application/pdf":
return "document.pdf"
case "text/plain":
return "document.txt"
case "text/csv":
return "document.csv"
case "application/json":
return "document.json"
default:
if _, suffix, ok := strings.Cut(mimeType, "/"); ok && suffix != "" {
return fmt.Sprintf("document.%s", strings.ReplaceAll(suffix, "+", "."))
}
return "document.bin"
}
}
func copyNumber(out *[]byte, path string, value gjson.Result) {
if value.Exists() {
*out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
}
}
func jsonStringValue(value gjson.Result, fallback string) string {
if !value.Exists() {
return fallback
}
if value.Type == gjson.String {
return value.String()
}
return value.Raw
}
func firstExisting(values ...gjson.Result) gjson.Result {
for _, value := range values {
if value.Exists() {
return value
}
}
return gjson.Result{}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View file

@ -0,0 +1,158 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToOpenAIPreservesExpressibleFields(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false)
if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "lookup" {
t.Fatalf("tool_choice.function.name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "modalities.0").String(); got != "text" {
t.Fatalf("modalities.0 = %q, want text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "modalities.1").String(); got != "image" {
t.Fatalf("modalities.1 = %q, want image. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractionsMapsMessagesToolsAndStream(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"messages":[{"role":"system","content":"be brief"},{"role":"user","content":"今天北京的天气怎么样?"}],"tools":[{"type":"function","function":{"name":"get_weather","description":"weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}}],"tool_choice":"auto","max_completion_tokens":128}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
if got := gjson.GetBytes(out, "model").String(); got != "gemini-3.1-flash-lite" {
t.Fatalf("model = %q, want gemini-3.1-flash-lite. Output: %s", got, string(out))
}
if !gjson.GetBytes(out, "stream").Bool() {
t.Fatalf("stream should be true. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "system_instruction").String(); got != "be brief" {
t.Fatalf("system_instruction = %q, want be brief. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("input text = %q. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.parameters.properties.location.type").String(); got != "string" {
t.Fatalf("tool schema missing. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "generation_config.tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 128 {
t.Fatalf("max_output_tokens = %d, want 128. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractionsMapsToolCallsAndResults(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","messages":[{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" {
t.Fatalf("input.0.type = %q, want function_call. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" {
t.Fatalf("call_id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" {
t.Fatalf("input.1.type = %q, want function_result. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.result").String(); got != "ok" {
t.Fatalf("result = %q, want ok. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIAcceptsImageContent(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image_url" {
t.Fatalf("content type = %q, want image_url. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIPreservesNonImageMediaContent(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.0.input_audio.format").String(); got != "wav" {
t.Fatalf("audio format = %q, want wav. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" {
t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" {
t.Fatalf("document content type = %q, want file. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIWithToolMessagesDirect(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false)
if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.name").String(); got != "lookup" {
t.Fatalf("tool call name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("tool call arguments = %q, want JSON object string. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "call_1" {
t.Fatalf("tool_call_id = %q, want call_1. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractions_AntigravitySanitizesGenerationConfigAndSetsAgentConfig(t *testing.T) {
raw := []byte(`{
"model":"antigravity-preview-05-2026",
"messages":[{"role":"user","content":"search"}],
"max_tokens":1024,
"temperature":0.5,
"top_p":0.9,
"tools":[{"type":"function","function":{"name":"search","parameters":{"type":"object"}}}]
}`)
out := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
// generation_config should not contain temperature, top_p, max_output_tokens
for _, knob := range []string{"temperature", "top_p", "top_k", "stop_sequences", "max_output_tokens"} {
if gjson.GetBytes(out, "generation_config."+knob).Exists() {
t.Fatalf("generation_config.%s should be stripped for antigravity model. Output: %s", knob, string(out))
}
}
if got := gjson.GetBytes(out, "agent_config.max_total_tokens").Int(); got != 1024 {
t.Fatalf("agent_config.max_total_tokens = %d, want 1024. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractions_PreservesEnvironmentIDAndPreviousInteractionID(t *testing.T) {
raw := []byte(`{
"model":"antigravity-preview-05-2026",
"messages":[{"role":"user","content":"continue"}],
"previous_response_id":"v1_prev123",
"environment_id":"env_456"
}`)
out := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "v1_prev123" {
t.Fatalf("previous_interaction_id = %q, want v1_prev123. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_456" {
t.Fatalf("environment_id = %q, want env_456. Output: %s", got, string(out))
}
}

View file

@ -0,0 +1,406 @@
package chat_completions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type openAIToInteractionsStreamState struct {
Created bool
StatusUpdated bool
Completed bool
Done bool
CurrentStepType string
CurrentStepID string
ToolCallIDs map[int]string
ToolCallNames map[int]string
ID string
StepIndex int
ActiveStepIndex int
ActiveStepOpen bool
Usage gjson.Result
}
func ConvertOpenAIResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &openAIToInteractionsStreamState{}
}
st := (*param).(*openAIToInteractionsStreamState)
if st.ToolCallIDs == nil {
st.ToolCallIDs = make(map[int]string)
}
if st.ToolCallNames == nil {
st.ToolCallNames = make(map[int]string)
}
return convertOpenAIChatStreamToInteractions(modelName, rawJSON, st)
}
func ConvertOpenAIResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
out := []byte(`{"id":"","status":"completed","object":"interaction","model":"","steps":[]}`)
out, _ = sjson.SetBytes(out, "id", firstNonEmpty(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String()))
choices := root.Get("choices")
var steps [][]byte
choices.ForEach(func(_, choice gjson.Result) bool {
message := choice.Get("message")
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
steps = append(steps, interactionsTextStep("thought", text))
}
}
if content := message.Get("content"); content.Exists() && content.String() != "" {
steps = append(steps, interactionsTextStep("model_output", content.String()))
}
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
if step, ok := openAIToolCallToInteractionsStep(toolCall); ok {
steps = append(steps, step)
}
return true
})
}
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
out, _ = sjson.SetBytes(out, "finish_reason", finishReason.String())
}
return true
})
if len(steps) > 0 {
out = translatorcommon.SetRawArrayItems(out, "steps", steps)
}
out = setInteractionsUsageFromOpenAIChat(out, "usage", root.Get("usage"))
return out
}
func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st *openAIToInteractionsStreamState) [][]byte {
payload := openAIChatSSEPayload(rawJSON)
if len(payload) == 0 {
return nil
}
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
out := make([][]byte, 0, 3)
out = appendInteractionsStepStop(out, st)
if !st.Completed {
out = appendInteractionsCompleted(out, st, modelName, gjson.Result{})
}
return appendInteractionsDone(out, st)
}
root := gjson.ParseBytes(payload)
if !root.Exists() {
return nil
}
if usage := root.Get("usage"); usage.Exists() {
st.Usage = usage
}
out := make([][]byte, 0)
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
if len(choices.Array()) == 0 {
if root.Get("usage").Exists() {
out = appendInteractionsStepStop(out, st)
out = appendInteractionsCompleted(out, st, modelName, root)
}
return out
}
choices.ForEach(func(_, choice gjson.Result) bool {
delta := choice.Get("delta")
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
out = ensureInteractionsStep(out, st, modelName, "thought", root)
out = appendInteractionsTextDelta(out, st, text, true)
}
}
if content := delta.Get("content"); content.Exists() && content.String() != "" {
out = ensureInteractionsStep(out, st, modelName, "model_output", root)
out = appendInteractionsTextDelta(out, st, content.String(), false)
}
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
out = appendOpenAIToolCallDelta(out, st, modelName, root, toolCall)
return true
})
}
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
out = appendInteractionsStepStop(out, st)
}
return true
})
}
return out
}
func appendOpenAIToolCallDelta(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root, toolCall gjson.Result) [][]byte {
index := int(toolCall.Get("index").Int())
if id := toolCall.Get("id").String(); id != "" {
st.ToolCallIDs[index] = id
}
function := toolCall.Get("function")
if name := function.Get("name").String(); name != "" {
st.ToolCallNames[index] = name
}
stepID := firstNonEmpty(st.ToolCallIDs[index], fmt.Sprintf("call_%d", index))
stepName := st.ToolCallNames[index]
if st.CurrentStepType != "function_call" || st.CurrentStepID != stepID {
out = appendInteractionsStepStop(out, st)
step := []byte(`{"type":"function_call","id":"","call_id":"","name":"","arguments":{}}`)
step, _ = sjson.SetBytes(step, "id", stepID)
step, _ = sjson.SetBytes(step, "call_id", stepID)
step, _ = sjson.SetBytes(step, "name", stepName)
out = appendInteractionsCreated(out, st, modelName, root)
out = appendInteractionsStepStart(out, st, "function_call", gjson.ParseBytes(step))
}
if args := function.Get("arguments"); args.Exists() && args.String() != "" {
out = appendInteractionsArgumentsDelta(out, st, args.String())
}
return out
}
func appendInteractionsCreated(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
if st.Created {
return out
}
st.ID = firstNonEmpty(root.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
created, _ = sjson.SetBytes(created, "interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
out = append(out, translatorcommon.SSEEventData("interaction.created", created))
st.Created = true
return appendInteractionsStatusUpdate(out, st)
}
func appendInteractionsStatusUpdate(out [][]byte, st *openAIToInteractionsStreamState) [][]byte {
if st.StatusUpdated {
return out
}
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
st.StatusUpdated = true
return out
}
func ensureInteractionsStep(out [][]byte, st *openAIToInteractionsStreamState, modelName, stepType string, step gjson.Result) [][]byte {
out = appendInteractionsCreated(out, st, modelName, step)
if st.ActiveStepOpen && st.CurrentStepType == stepType {
return out
}
out = appendInteractionsStepStop(out, st)
return appendInteractionsStepStart(out, st, stepType, step)
}
func appendInteractionsStepStart(out [][]byte, st *openAIToInteractionsStreamState, stepType string, step gjson.Result) [][]byte {
index := st.StepIndex
st.StepIndex++
st.ActiveStepIndex = index
st.CurrentStepType = stepType
st.ActiveStepOpen = true
payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
payload, _ = sjson.SetBytes(payload, "index", index)
payload, _ = sjson.SetBytes(payload, "step.type", stepType)
if stepType == "function_call" {
id := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), st.CurrentStepID)
st.CurrentStepID = id
if id != "" {
payload, _ = sjson.SetBytes(payload, "step.id", id)
payload, _ = sjson.SetBytes(payload, "step.call_id", id)
}
payload, _ = sjson.SetBytes(payload, "step.name", step.Get("name").String())
payload, _ = sjson.SetRawBytes(payload, "step.arguments", []byte(`{}`))
} else {
st.CurrentStepID = ""
}
return append(out, translatorcommon.SSEEventData("step.start", payload))
}
func appendInteractionsTextDelta(out [][]byte, st *openAIToInteractionsStreamState, text string, thought bool) [][]byte {
if thought {
payload := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
payload, _ = sjson.SetBytes(payload, "delta.content.text", text)
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
payload, _ = sjson.SetBytes(payload, "delta.text", text)
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
func appendInteractionsArgumentsDelta(out [][]byte, st *openAIToInteractionsStreamState, arguments string) [][]byte {
payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments)
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
func appendInteractionsStepStop(out [][]byte, st *openAIToInteractionsStreamState) [][]byte {
if !st.ActiveStepOpen {
return out
}
payload := []byte(`{"index":0,"event_type":"step.stop"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", payload))
st.ActiveStepOpen = false
st.CurrentStepType = ""
st.CurrentStepID = ""
return out
}
func appendInteractionsCompleted(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
if st.Completed {
return out
}
if !st.Created {
out = appendInteractionsCreated(out, st, modelName, root)
}
now := time.Now().UTC().Format(time.RFC3339)
payload := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
payload, _ = sjson.SetBytes(payload, "interaction.id", st.ID)
payload, _ = sjson.SetBytes(payload, "interaction.created", now)
payload, _ = sjson.SetBytes(payload, "interaction.updated", now)
payload, _ = sjson.SetBytes(payload, "interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
usage := root.Get("usage")
if !usage.Exists() {
usage = st.Usage
}
payload = setInteractionsUsageFromOpenAIChat(payload, "interaction.usage", usage)
out = append(out, translatorcommon.SSEEventData("interaction.completed", payload))
st.Completed = true
return out
}
func appendInteractionsDone(out [][]byte, st *openAIToInteractionsStreamState) [][]byte {
if st.Done {
return out
}
out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]")))
st.Done = true
return out
}
func isOpenAIStreamDone(rawJSON []byte) bool {
return bytes.Equal(bytes.TrimSpace(openAIChatSSEPayload(rawJSON)), []byte("[DONE]"))
}
func openAIChatSSEPayload(rawJSON []byte) []byte {
trimmed := bytes.TrimSpace(rawJSON)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) {
return trimmed
}
if bytes.HasPrefix(trimmed, []byte("data:")) {
return bytes.TrimSpace(trimmed[len("data:"):])
}
var dataLines [][]byte
for _, line := range bytes.Split(trimmed, []byte("\n")) {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("data:")) {
dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):]))
}
}
if len(dataLines) > 0 {
return bytes.Join(dataLines, []byte("\n"))
}
return trimmed
}
func interactionsTextStep(stepType, text string) []byte {
step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`)
step, _ = sjson.SetBytes(step, "type", stepType)
step, _ = sjson.SetBytes(step, "content.0.text", text)
return step
}
func openAIToolCallToInteractionsStep(toolCall gjson.Result) ([]byte, bool) {
if toolType := toolCall.Get("type").String(); toolType != "" && toolType != "function" {
return nil, false
}
function := toolCall.Get("function")
if !function.Exists() {
return nil, false
}
step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
if id := toolCall.Get("id").String(); id != "" {
step, _ = sjson.SetBytes(step, "id", id)
step, _ = sjson.SetBytes(step, "call_id", id)
}
step, _ = sjson.SetBytes(step, "name", function.Get("name").String())
setRawJSONValue(&step, "arguments", function.Get("arguments"), []byte(`{}`))
return step, true
}
func setInteractionsUsageFromOpenAIChat(out []byte, path string, usage gjson.Result) []byte {
if !usage.Exists() {
return out
}
if value := usage.Get("prompt_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".input_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_input_tokens", value.Int())
}
if value := usage.Get("completion_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".output_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_output_tokens", value.Int())
}
if value := usage.Get("total_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".total_tokens", value.Int())
}
if value := usage.Get("prompt_tokens_details.cached_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".cached_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_cached_tokens", value.Int())
}
if value := usage.Get("completion_tokens_details.reasoning_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".reasoning_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_thought_tokens", value.Int())
}
return out
}
func openAIReasoningTexts(reasoning gjson.Result) []string {
if reasoning.Type == gjson.String {
if reasoning.String() == "" {
return nil
}
return []string{reasoning.String()}
}
texts := make([]string, 0)
if reasoning.IsArray() {
reasoning.ForEach(func(_, item gjson.Result) bool {
if text := firstNonEmpty(item.Get("text").String(), item.Get("content").String()); text != "" {
texts = append(texts, text)
}
return true
})
}
return texts
}
func setRawJSONValue(out *[]byte, path string, value gjson.Result, fallback []byte) {
if !value.Exists() {
*out, _ = sjson.SetRawBytes(*out, path, fallback)
return
}
raw := strings.TrimSpace(value.String())
if value.Type == gjson.String && gjson.Valid(raw) {
*out, _ = sjson.SetRawBytes(*out, path, []byte(raw))
return
}
if value.Type == gjson.String {
*out, _ = sjson.SetBytes(*out, path, value.String())
return
}
*out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
}

View file

@ -0,0 +1,243 @@
package chat_completions
import (
"bytes"
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIResponseToInteractionsStreamUsageOnlyTerminalChunk(t *testing.T) {
var param any
finishRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`)
usageRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`)
doneRaw := []byte(`data: [DONE]`)
finishOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, finishRaw, &param)
usageOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, usageRaw, &param)
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, &param)
if got := countInteractionsEvents(finishOut, "interaction.completed"); got != 0 {
t.Fatalf("finish interaction.completed count = %d, want 0", got)
}
if got := countInteractionsEvents(usageOut, "interaction.completed"); got != 1 {
t.Fatalf("usage interaction.completed count = %d, want 1", got)
}
if got := countInteractionsEvents(doneOut, "interaction.completed"); got != 0 {
t.Fatalf("done interaction.completed count = %d, want 0", got)
}
if got := countInteractionsEvents(doneOut, "done"); got != 1 {
t.Fatalf("done event count = %d, want 1", got)
}
payload := findInteractionsEventPayload(usageOut, "interaction.completed")
if got := gjson.GetBytes(payload, "interaction.usage.total_input_tokens").Int(); got != 3 {
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.usage.total_output_tokens").Int(); got != 4 {
t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 7 {
t.Fatalf("total_tokens = %d, want 7. Payload: %s", got, string(payload))
}
}
func TestConvertOpenAIResponseToInteractionsCompletesOnDoneWithoutUsage(t *testing.T) {
var param any
finishRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`)
doneRaw := []byte(`data: [DONE]`)
finishOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, finishRaw, &param)
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, &param)
if got := countInteractionsEvents(finishOut, "interaction.completed"); got != 0 {
t.Fatalf("finish interaction.completed count = %d, want 0", got)
}
if got := countInteractionsEvents(doneOut, "interaction.completed"); got != 1 {
t.Fatalf("done interaction.completed count = %d, want 1", got)
}
if got := countInteractionsEvents(doneOut, "done"); got != 1 {
t.Fatalf("done event count = %d, want 1", got)
}
}
func TestConvertOpenAIResponseToInteractionsStreamCreatedUsesChunkIdentity(t *testing.T) {
var param any
raw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}`)
out := ConvertOpenAIResponseToInteractions(context.Background(), "", nil, nil, raw, &param)
payload := findInteractionsEventPayload(out, "interaction.created")
if got := gjson.GetBytes(payload, "interaction.id").String(); got != "chatcmpl_1" {
t.Fatalf("interaction.id = %q, want chatcmpl_1. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.model").String(); got != "gpt-test" {
t.Fatalf("interaction.model = %q, want gpt-test. Payload: %s", got, string(payload))
}
}
func TestConvertOpenAIResponseToInteractionsNonStreamDirectToolCall(t *testing.T) {
raw := []byte(`{"id":"chatcmpl_1","model":"gpt-test","choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`)
out := ConvertOpenAIResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_1" {
t.Fatalf("call_id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAIStreamToolCall(t *testing.T) {
var param any
chunks := [][]byte{
[]byte(`data: {"event_type":"interaction.created","interaction":{"id":"i1","model":"gemini-3.1-flash-lite"}}`),
[]byte(`data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"get_weather","arguments":{}}}`),
[]byte(`data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"{\"location\":\"北京\"}"}}`),
[]byte(`data: {"event_type":"step.stop","index":0}`),
[]byte(`data: {"event_type":"interaction.completed","interaction":{"id":"i1","status":"requires_action","usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}}`),
}
var out [][]byte
for _, chunk := range chunks {
out = append(out, ConvertInteractionsResponseToOpenAI(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, &param)...)
}
toolStart := findOpenAIChatChunk(out, "choices.0.delta.tool_calls.0.function.name")
if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Payload: %s", got, string(toolStart))
}
if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.function.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Payload: %s", got, string(toolStart))
}
toolArgs := findOpenAIChatChunkValue(out, "choices.0.delta.tool_calls.0.function.arguments", `{"location":"北京"}`)
if got := gjson.GetBytes(toolArgs, "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("tool args = %q, want location JSON. Payload: %s", got, string(toolArgs))
}
completed := findOpenAIChatChunkValue(out, "choices.0.finish_reason", "tool_calls")
if got := gjson.GetBytes(completed, "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("finish_reason = %q, want tool_calls. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 {
t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed))
}
}
func TestConvertInteractionsResponseToOpenAIStreamFinishMetadataUsage(t *testing.T) {
var param any
out := ConvertInteractionsResponseToOpenAI(context.Background(), "gpt-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), &param)
completed := findOpenAIChatChunkValue(out, "choices.0.finish_reason", "stop")
if len(completed) == 0 {
t.Fatalf("completion chunk not found")
}
if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 {
t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.completion_tokens").Int(); got != 6 {
t.Fatalf("completion_tokens = %d, want 6. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 3 {
t.Fatalf("reasoning_tokens = %d, want 3. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.prompt_tokens_details.cached_tokens").Int(); got != 1 {
t.Fatalf("cached_tokens = %d, want 1. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.total_tokens").Int(); got != 11 {
t.Fatalf("total_tokens = %d, want 11. Payload: %s", got, string(completed))
}
}
func TestConvertInteractionsResponseToOpenAINonStreamToolCall(t *testing.T) {
raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","id":"call_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`)
out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("tool args = %q, want location JSON. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("finish_reason = %q, want tool_calls. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAINonStream_PreservesEnvironmentID(t *testing.T) {
raw := []byte(`{"id":"i1","model":"antigravity-preview-05-2026","environment_id":"env_chat123","steps":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}],"usage":{"total_tokens":5}}`)
out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "antigravity-preview-05-2026", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_chat123" {
t.Fatalf("environment_id = %q, want env_chat123. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAIStream_PreservesEnvironmentID(t *testing.T) {
var param any
chunk := []byte(`data: {"event_type":"interaction.created","interaction":{"id":"i1","model":"antigravity-preview-05-2026","environment_id":"env_chat_stream456"}}`)
out := ConvertInteractionsResponseToOpenAI(context.Background(), "antigravity-preview-05-2026", nil, nil, chunk, &param)
if len(out) == 0 {
t.Fatalf("no output chunks generated")
}
if got := gjson.GetBytes(out[0], "environment_id").String(); got != "env_chat_stream456" {
t.Fatalf("environment_id = %q, want env_chat_stream456. Chunk: %s", got, string(out[0]))
}
}
func findInteractionsEventPayload(events [][]byte, eventType string) []byte {
for _, event := range events {
payload := interactionsSSEPayload(event)
if interactionsEventName(event, payload) == eventType {
return payload
}
}
return nil
}
func countInteractionsEvents(events [][]byte, eventType string) int {
count := 0
for _, event := range events {
payload := interactionsSSEPayload(event)
if interactionsEventName(event, payload) == eventType {
count++
}
}
return count
}
func interactionsEventName(event, payload []byte) string {
if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
return eventType
}
const prefix = "event: "
lineEnd := bytes.IndexByte(event, '\n')
if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) {
return ""
}
return string(event[len(prefix):lineEnd])
}
func interactionsSSEPayload(event []byte) []byte {
const prefix = "\ndata: "
idx := bytes.Index(event, []byte(prefix))
if idx < 0 {
return nil
}
return event[idx+len(prefix):]
}
func findOpenAIChatChunk(chunks [][]byte, path string) []byte {
for _, chunk := range chunks {
if gjson.GetBytes(chunk, path).Exists() {
return chunk
}
}
return nil
}
func findOpenAIChatChunkValue(chunks [][]byte, path, want string) []byte {
for _, chunk := range chunks {
if gjson.GetBytes(chunk, path).String() == want {
return chunk
}
}
return nil
}

View file

@ -0,0 +1,33 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToInteractionsNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
document := gjson.GetBytes(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := document.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("document.data = %q, want raw base64 payload. Output: %s", got, out)
}
}
func TestConvertOpenAIRequestToInteractionsPreservesRawFileDataWithMIMEType(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"document","mime_type":"application/pdf","data":"JVBERi0xLjQK"}]}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
document := gjson.GetBytes(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := document.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("document.data = %q, want unchanged raw base64 payload. Output: %s", got, out)
}
}

View file

@ -0,0 +1,345 @@
package chat_completions
import (
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","input":[]}`)
model := firstNonEmpty(modelName, root.Get("model").String())
out, _ = sjson.SetBytes(out, "model", model)
if streamValue, ok := openAIRequestStreamValue(root, stream); ok {
out, _ = sjson.SetBytes(out, "stream", streamValue)
}
if previousResponseID := firstNonEmpty(root.Get("previous_response_id").String(), root.Get("previous_interaction_id").String()); previousResponseID != "" {
out, _ = sjson.SetBytes(out, "previous_interaction_id", previousResponseID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
out, _ = sjson.SetBytes(out, "environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
}
out = appendOpenAIMessagesToInteractions(out, root.Get("messages"))
out = copyOpenAIChatGenerationConfigToInteractions(out, root, model)
out = appendOpenAIChatToolsToInteractions(out, root.Get("tools"))
return out
}
func openAIRequestStreamValue(root gjson.Result, stream bool) (bool, bool) {
if value := root.Get("stream"); value.Exists() {
return value.Bool(), true
}
if stream {
return true, true
}
return false, false
}
func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result) []byte {
if !messages.Exists() || !messages.IsArray() {
return out
}
inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int())
var systemBuilder strings.Builder
messages.ForEach(func(_, message gjson.Result) bool {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
switch role {
case "system", "developer":
if text := openAIChatContentText(message.Get("content")); text != "" {
if systemBuilder.Len() > 0 {
systemBuilder.WriteByte('\n')
}
systemBuilder.WriteString(text)
}
default:
appendOpenAIMessageToInteractions(&inputItems, message)
}
return true
})
if systemBuilder.Len() > 0 {
out, _ = sjson.SetBytes(out, "system_instruction", systemBuilder.String())
}
out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
return out
}
func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result) {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
switch role {
case "assistant":
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
*items = append(*items, interactionsTextStep("thought", text))
}
}
if step, ok := openAIChatContentStep("model_output", message.Get("content")); ok {
*items = append(*items, step)
}
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
if step, ok := openAIToolCallToInteractionsStep(toolCall); ok {
*items = append(*items, step)
}
return true
})
}
case "tool", "function":
*items = append(*items, openAIToolResultToInteractions(message))
default:
if step, ok := openAIChatContentStep("user_input", message.Get("content")); ok {
*items = append(*items, step)
}
}
}
func openAIChatContentStep(stepType string, content gjson.Result) ([]byte, bool) {
contentItems := make([][]byte, 0, 4)
if content.Type == gjson.String {
if content.String() == "" {
return nil, false
}
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", content.String())
contentItems = append(contentItems, part)
} else {
appendPart := func(part gjson.Result) {
if converted, ok := openAIChatContentPartToInteractions(part); ok {
contentItems = append(contentItems, converted)
}
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
appendPart(part)
return true
})
} else if content.IsObject() {
appendPart(content)
}
}
if len(contentItems) == 0 {
return nil, false
}
step := []byte(`{"type":"","content":[]}`)
step, _ = sjson.SetBytes(step, "type", stepType)
step, _ = sjson.SetRawBytes(step, "content", translatorcommon.JoinRawArray(contentItems))
return step, true
}
func openAIChatContentPartToInteractions(part gjson.Result) ([]byte, bool) {
partType := strings.ToLower(strings.TrimSpace(part.Get("type").String()))
if partType == "" && part.Get("text").Exists() {
partType = "text"
}
switch partType {
case "text", "input_text", "output_text":
out := []byte(`{"type":"text","text":""}`)
out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "image_url", "input_image", "image":
return openAIChatImagePartToInteractions(part), true
case "input_audio", "audio":
out := []byte(`{"type":"audio","data":""}`)
audio := part.Get("input_audio")
data := firstNonEmpty(audio.Get("data").String(), part.Get("data").String())
if data == "" {
return nil, false
}
out, _ = sjson.SetBytes(out, "data", data)
if format := firstNonEmpty(audio.Get("format").String(), part.Get("format").String()); format != "" {
out, _ = sjson.SetBytes(out, "mime_type", openAIInputAudioMIMEType(format))
}
return out, true
case "file", "input_file", "document":
file := part.Get("file")
filename := firstNonEmpty(file.Get("filename").String(), part.Get("filename").String())
fallbackMIMEType := firstNonEmpty(file.Get("mime_type").String(), file.Get("mimeType").String(), part.Get("mime_type").String(), part.Get("mimeType").String())
fileData := firstNonEmpty(file.Get("file_data").String(), part.Get("file_data").String(), part.Get("data").String())
fileURL := firstNonEmpty(file.Get("file_url").String(), part.Get("file_url").String(), part.Get("url").String())
out := []byte(`{"type":"document"}`)
if filename != "" {
out, _ = sjson.SetBytes(out, "filename", filename)
}
hasContent := false
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, fallbackMIMEType, fileData); ok {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
out, _ = sjson.SetBytes(out, "data", data)
hasContent = true
}
if fileURL != "" {
out, _ = sjson.SetBytes(out, "file_url", fileURL)
hasContent = true
}
return out, hasContent
}
return nil, false
}
func openAIChatImagePartToInteractions(part gjson.Result) []byte {
out := []byte(`{"type":"image"}`)
imageURL := firstNonEmpty(part.Get("image_url.url").String(), part.Get("image_url").String(), part.Get("url").String())
if mimeType, data, ok := openAIChatParseDataURL(imageURL); ok {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
out, _ = sjson.SetBytes(out, "data", data)
return out
}
if data := part.Get("data").String(); data != "" {
out, _ = sjson.SetBytes(out, "data", data)
if mimeType := part.Get("mime_type").String(); mimeType != "" {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
}
return out
}
if imageURL != "" {
out, _ = sjson.SetBytes(out, "image_url", imageURL)
}
return out
}
func openAIToolResultToInteractions(message gjson.Result) []byte {
out := []byte(`{"type":"function_result","result":""}`)
if callID := firstNonEmpty(message.Get("tool_call_id").String(), message.Get("id").String()); callID != "" {
out, _ = sjson.SetBytes(out, "id", callID)
out, _ = sjson.SetBytes(out, "call_id", callID)
}
if name := message.Get("name").String(); name != "" {
out, _ = sjson.SetBytes(out, "name", name)
}
content := message.Get("content")
if content.Exists() && content.Type == gjson.String {
out, _ = sjson.SetBytes(out, "result", content.String())
} else if content.Exists() {
out, _ = sjson.SetRawBytes(out, "result", []byte(content.Raw))
}
return out
}
func isAntigravityModel(model string) bool {
return strings.Contains(strings.ToLower(model), "antigravity")
}
func copyOpenAIChatGenerationConfigToInteractions(out []byte, root gjson.Result, model string) []byte {
if isAntigravityModel(model) {
if maxOutputTokens := firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens"), root.Get("max_output_tokens")); maxOutputTokens.Exists() && !root.Get("agent_config.max_total_tokens").Exists() {
out, _ = sjson.SetBytes(out, "agent_config.max_total_tokens", maxOutputTokens.Int())
}
} else {
copyNumber(&out, "generation_config.max_output_tokens", firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens")))
copyNumber(&out, "generation_config.temperature", root.Get("temperature"))
copyNumber(&out, "generation_config.top_p", root.Get("top_p"))
copyNumber(&out, "generation_config.presence_penalty", root.Get("presence_penalty"))
copyNumber(&out, "generation_config.frequency_penalty", root.Get("frequency_penalty"))
copyNumber(&out, "generation_config.candidate_count", root.Get("n"))
if stop := root.Get("stop"); stop.Exists() {
out, _ = sjson.SetRawBytes(out, "generation_config.stop_sequences", []byte(stop.Raw))
}
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", []byte(toolChoice.Raw))
}
if effort := root.Get("reasoning_effort"); effort.Exists() && effort.Type == gjson.String {
out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
}
if responseFormat := root.Get("response_format"); responseFormat.Exists() {
out, _ = sjson.SetRawBytes(out, "response_format", []byte(responseFormat.Raw))
}
if modalities := root.Get("modalities"); modalities.Exists() {
out, _ = sjson.SetRawBytes(out, "response_modalities", []byte(modalities.Raw))
}
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
}
return out
}
func appendOpenAIChatToolsToInteractions(out []byte, tools gjson.Result) []byte {
if !tools.Exists() || !tools.IsArray() {
return out
}
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if converted, ok := openAIChatToolToInteractions(tool); ok {
toolItems = append(toolItems, converted)
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func openAIChatToolToInteractions(tool gjson.Result) ([]byte, bool) {
toolType := strings.ToLower(strings.TrimSpace(tool.Get("type").String()))
if toolType != "" && toolType != "function" {
return nil, false
}
name := firstNonEmpty(tool.Get("function.name").String(), tool.Get("name").String())
if name == "" {
return nil, false
}
out := []byte(`{"type":"function","name":""}`)
out, _ = sjson.SetBytes(out, "name", name)
if desc := firstExisting(tool.Get("function.description"), tool.Get("description")); desc.Exists() {
out, _ = sjson.SetBytes(out, "description", desc.String())
}
if parameters := firstExisting(tool.Get("function.parameters"), tool.Get("parameters")); parameters.Exists() {
out, _ = sjson.SetRawBytes(out, "parameters", []byte(parameters.Raw))
}
return out, true
}
func openAIChatContentText(content gjson.Result) string {
if content.Type == gjson.String {
return content.String()
}
if content.IsObject() {
return content.Get("text").String()
}
if !content.IsArray() {
return ""
}
var builder strings.Builder
content.ForEach(func(_, part gjson.Result) bool {
if text := part.Get("text").String(); text != "" {
builder.WriteString(text)
}
return true
})
return builder.String()
}
func openAIInputAudioMIMEType(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "wav":
return "audio/wav"
case "flac":
return "audio/flac"
case "opus":
return "audio/opus"
case "pcm16":
return "audio/pcm"
default:
return "audio/mpeg"
}
}
func openAIChatParseDataURL(value string) (string, string, bool) {
if !strings.HasPrefix(value, "data:") {
return "", "", false
}
meta, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ",")
if !ok {
return "", "", false
}
mimeType, encoding, _ := strings.Cut(meta, ";")
if !strings.EqualFold(encoding, "base64") || strings.TrimSpace(mimeType) == "" || data == "" {
return "", "", false
}
return mimeType, data, true
}

View file

@ -0,0 +1,361 @@
package chat_completions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type interactionsToOpenAIChatStreamState struct {
ID string
Model string
EnvironmentID string
Created int64
Started bool
Completed bool
SawToolCall bool
StepTypes map[int]string
ToolIDs map[int]string
ToolNames map[int]string
ToolArguments map[int]*strings.Builder
TextByStepIndex map[int]*strings.Builder
}
func ConvertInteractionsResponseToOpenAI(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &interactionsToOpenAIChatStreamState{Model: modelName}
}
st := (*param).(*interactionsToOpenAIChatStreamState)
st.Model = firstNonEmpty(st.Model, modelName)
st.ensureMaps()
return convertInteractionsEventToOpenAIChat(modelName, rawJSON, st)
}
func ConvertInteractionsResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
interaction := root
if nested := root.Get("interaction"); nested.Exists() {
interaction = nested
}
out := []byte(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`)
out, _ = sjson.SetBytes(out, "id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
out, _ = sjson.SetBytes(out, "created", time.Now().Unix())
out, _ = sjson.SetBytes(out, "model", firstNonEmpty(interaction.Get("model").String(), modelName))
steps := interaction.Get("steps")
if !steps.Exists() {
steps = root.Get("steps")
}
var textBuilder strings.Builder
var reasoningBuilder strings.Builder
sawToolCall := false
var toolCalls [][]byte
steps.ForEach(func(_, step gjson.Result) bool {
switch step.Get("type").String() {
case "model_output":
for _, text := range interactionsContentTextsForOpenAIChat(step.Get("content")) {
textBuilder.WriteString(text)
}
case "thought":
for _, text := range interactionsContentTextsForOpenAIChat(step.Get("content")) {
reasoningBuilder.WriteString(text)
}
case "function_call":
sawToolCall = true
toolCalls = append(toolCalls, openAIChatToolCallFromInteractions(step, gjson.Result{}))
}
return true
})
if textBuilder.Len() > 0 {
out, _ = sjson.SetBytes(out, "choices.0.message.content", textBuilder.String())
}
if reasoningBuilder.Len() > 0 {
out, _ = sjson.SetBytes(out, "choices.0.message.reasoning_content", reasoningBuilder.String())
}
if len(toolCalls) > 0 {
out = translatorcommon.SetRawArrayItems(out, "choices.0.message.tool_calls", toolCalls)
}
if sawToolCall {
out, _ = sjson.SetBytes(out, "choices.0.message.content", nil)
out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls")
}
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String(), root.Get("interaction.environment_id").String()); envID != "" {
out, _ = sjson.SetBytes(out, "environment_id", envID)
}
out = setOpenAIChatUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root))
return out
}
func convertInteractionsEventToOpenAIChat(modelName string, rawJSON []byte, st *interactionsToOpenAIChatStreamState) [][]byte {
payload := openAIChatInteractionsPayload(rawJSON)
if len(payload) == 0 || bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
return nil
}
root := gjson.ParseBytes(payload)
if !root.Exists() {
return nil
}
switch root.Get("event_type").String() {
case "interaction.created":
interaction := root.Get("interaction")
st.ID = firstNonEmpty(interaction.Get("id").String(), st.ID)
st.Model = firstNonEmpty(interaction.Get("model").String(), st.Model, modelName)
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String()); envID != "" {
st.EnvironmentID = envID
}
return ensureOpenAIChatStarted(nil, st)
case "step.start":
return interactionsStepStartToOpenAIChat(modelName, root, st)
case "step.delta":
return interactionsStepDeltaToOpenAIChat(modelName, root, st)
case "interaction.completed", "finish":
interaction := root.Get("interaction")
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String()); envID != "" {
st.EnvironmentID = envID
}
return appendOpenAIChatCompleted(nil, root, st)
case "done":
return nil
}
return nil
}
func interactionsStepStartToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
_ = modelName
out := ensureOpenAIChatStarted(nil, st)
index := int(root.Get("index").Int())
step := root.Get("step")
stepType := step.Get("type").String()
st.StepTypes[index] = stepType
switch stepType {
case "function_call":
st.SawToolCall = true
st.ToolIDs[index] = firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), fmt.Sprintf("call_%d", index))
st.ToolNames[index] = step.Get("name").String()
if st.ToolArguments[index] == nil {
st.ToolArguments[index] = &strings.Builder{}
}
if args := step.Get("arguments"); args.Exists() && strings.TrimSpace(args.Raw) != "{}" {
st.ToolArguments[index].WriteString(jsonStringValue(args, "{}"))
}
return append(out, openAIChatToolCallStartChunk(st, index))
default:
return out
}
}
func interactionsStepDeltaToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
_ = modelName
index := int(root.Get("index").Int())
delta := root.Get("delta")
out := ensureOpenAIChatStarted(nil, st)
switch delta.Get("type").String() {
case "thought_summary":
text := firstNonEmpty(delta.Get("content.text").String(), delta.Get("text").String())
if text == "" {
return out
}
return append(out, openAIChatDeltaChunk(st, "reasoning_content", text))
case "arguments_delta":
args := delta.Get("arguments").String()
if st.ToolArguments[index] == nil {
st.ToolArguments[index] = &strings.Builder{}
}
st.ToolArguments[index].WriteString(args)
return append(out, openAIChatToolCallArgumentsChunk(st, index, args))
default:
text := delta.Get("text").String()
if text == "" {
return out
}
if st.TextByStepIndex[index] == nil {
st.TextByStepIndex[index] = &strings.Builder{}
}
st.TextByStepIndex[index].WriteString(text)
return append(out, openAIChatDeltaChunk(st, "content", text))
}
}
func ensureOpenAIChatStarted(out [][]byte, st *interactionsToOpenAIChatStreamState) [][]byte {
if st.Started {
return out
}
chunk := openAIChatBaseChunk(st)
chunk, _ = sjson.SetBytes(chunk, "choices.0.delta.role", "assistant")
st.Started = true
return append(out, chunk)
}
func appendOpenAIChatCompleted(out [][]byte, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
if st.Completed {
return out
}
out = ensureOpenAIChatStarted(out, st)
chunk := openAIChatBaseChunk(st)
finishReason := "stop"
if st.SawToolCall {
finishReason = "tool_calls"
}
chunk, _ = sjson.SetBytes(chunk, "choices.0.finish_reason", finishReason)
chunk = setOpenAIChatUsageFromInteractions(chunk, "usage", translatorcommon.InteractionsUsage(root))
st.Completed = true
return append(out, chunk)
}
func openAIChatBaseChunk(st *interactionsToOpenAIChatStreamState) []byte {
chunk := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`)
chunk, _ = sjson.SetBytes(chunk, "id", firstNonEmpty(st.ID, fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
chunk, _ = sjson.SetBytes(chunk, "created", openAIChatCreated(st))
chunk, _ = sjson.SetBytes(chunk, "model", st.Model)
if st != nil && st.EnvironmentID != "" {
chunk, _ = sjson.SetBytes(chunk, "environment_id", st.EnvironmentID)
}
return chunk
}
func openAIChatDeltaChunk(st *interactionsToOpenAIChatStreamState, field, value string) []byte {
chunk := openAIChatBaseChunk(st)
chunk, _ = sjson.SetBytes(chunk, "choices.0.delta."+field, value)
return chunk
}
func openAIChatToolCallStartChunk(st *interactionsToOpenAIChatStreamState, index int) []byte {
chunk := openAIChatBaseChunk(st)
toolCall := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
toolCall, _ = sjson.SetBytes(toolCall, "index", index)
toolCall, _ = sjson.SetBytes(toolCall, "id", firstNonEmpty(st.ToolIDs[index], fmt.Sprintf("call_%d", index)))
toolCall, _ = sjson.SetBytes(toolCall, "function.name", st.ToolNames[index])
chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall)
return chunk
}
func openAIChatToolCallArgumentsChunk(st *interactionsToOpenAIChatStreamState, index int, arguments string) []byte {
chunk := openAIChatBaseChunk(st)
toolCall := []byte(`{"index":0,"function":{"arguments":""}}`)
toolCall, _ = sjson.SetBytes(toolCall, "index", index)
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments)
chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall)
return chunk
}
func openAIChatToolCallFromInteractions(step, fallbackArgs gjson.Result) []byte {
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0")
toolCall, _ = sjson.SetBytes(toolCall, "id", callID)
toolCall, _ = sjson.SetBytes(toolCall, "function.name", step.Get("name").String())
args := step.Get("arguments")
if !args.Exists() {
args = fallbackArgs
}
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(args, "{}"))
return toolCall
}
func setOpenAIChatUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte {
if !usage.Exists() {
return out
}
if value, ok := interactionsUsageInt(usage, "input_tokens", "total_input_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".prompt_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "output_tokens", "total_output_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".completion_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "total_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".total_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".prompt_tokens_details.cached_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".completion_tokens_details.reasoning_tokens", value)
}
return out
}
func interactionsUsageInt(root gjson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value.Int(), true
}
}
return 0, false
}
func interactionsContentTextsForOpenAIChat(content gjson.Result) []string {
if !content.Exists() {
return nil
}
if content.Type == gjson.String {
return []string{content.String()}
}
var out []string
content.ForEach(func(_, part gjson.Result) bool {
if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" {
out = append(out, text)
}
return true
})
return out
}
func openAIChatInteractionsPayload(rawJSON []byte) []byte {
trimmed := bytes.TrimSpace(rawJSON)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) {
return trimmed
}
if bytes.HasPrefix(trimmed, []byte("data:")) {
return bytes.TrimSpace(trimmed[len("data:"):])
}
var dataLines [][]byte
for _, line := range bytes.Split(trimmed, []byte("\n")) {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("data:")) {
dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):]))
}
}
if len(dataLines) > 0 {
return bytes.Join(dataLines, []byte("\n"))
}
return trimmed
}
func openAIChatCreated(st *interactionsToOpenAIChatStreamState) int64 {
if st.Created == 0 {
st.Created = time.Now().Unix()
}
return st.Created
}
func (st *interactionsToOpenAIChatStreamState) ensureMaps() {
if st.StepTypes == nil {
st.StepTypes = make(map[int]string)
}
if st.ToolIDs == nil {
st.ToolIDs = make(map[int]string)
}
if st.ToolNames == nil {
st.ToolNames = make(map[int]string)
}
if st.ToolArguments == nil {
st.ToolArguments = make(map[int]*strings.Builder)
}
if st.TextByStepIndex == nil {
st.TextByStepIndex = make(map[int]*strings.Builder)
}
}