Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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, ¶m)
|
||||
usageOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, usageRaw, ¶m)
|
||||
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m)
|
||||
|
||||
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, ¶m)
|
||||
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m)
|
||||
|
||||
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, ¶m)
|
||||
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, ¶m)...)
|
||||
}
|
||||
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}}}`), ¶m)
|
||||
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, ¶m)
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue