mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
338 lines
12 KiB
Go
338 lines
12 KiB
Go
// Package gemini provides response translation functionality for Codex to Gemini API compatibility.
|
|
// This package handles the conversion of Codex API responses into Gemini-compatible
|
|
// JSON format, transforming streaming events and non-streaming responses into the format
|
|
// expected by Gemini API clients.
|
|
package gemini
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
var (
|
|
dataTag = []byte("data:")
|
|
)
|
|
|
|
// ConvertCodexResponseToGeminiParams holds parameters for response conversion.
|
|
type ConvertCodexResponseToGeminiParams struct {
|
|
Model string
|
|
CreatedAt int64
|
|
ResponseID string
|
|
LastStorageOutput string
|
|
}
|
|
|
|
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
|
|
// This function processes various Codex event types and transforms them into Gemini-compatible JSON responses.
|
|
// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.
|
|
// The function maintains state across multiple calls to ensure proper response sequencing.
|
|
//
|
|
// Parameters:
|
|
// - ctx: The context for the request, used for cancellation and timeout handling
|
|
// - modelName: The name of the model being used for the response
|
|
// - rawJSON: The raw JSON response from the Codex API
|
|
// - param: A pointer to a parameter object for maintaining state between calls
|
|
//
|
|
// Returns:
|
|
// - []string: A slice of strings, each containing a Gemini-compatible JSON response
|
|
func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
|
|
if *param == nil {
|
|
*param = &ConvertCodexResponseToGeminiParams{
|
|
Model: modelName,
|
|
CreatedAt: 0,
|
|
ResponseID: "",
|
|
LastStorageOutput: "",
|
|
}
|
|
}
|
|
|
|
if !bytes.HasPrefix(rawJSON, dataTag) {
|
|
return []string{}
|
|
}
|
|
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
|
|
|
rootResult := gjson.ParseBytes(rawJSON)
|
|
typeResult := rootResult.Get("type")
|
|
typeStr := typeResult.String()
|
|
|
|
// Base Gemini response template
|
|
template := `{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`
|
|
if (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput != "" && typeStr == "response.output_item.done" {
|
|
template = (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput
|
|
} else {
|
|
template, _ = sjson.Set(template, "modelVersion", (*param).(*ConvertCodexResponseToGeminiParams).Model)
|
|
createdAtResult := rootResult.Get("response.created_at")
|
|
if createdAtResult.Exists() {
|
|
(*param).(*ConvertCodexResponseToGeminiParams).CreatedAt = createdAtResult.Int()
|
|
template, _ = sjson.Set(template, "createTime", time.Unix((*param).(*ConvertCodexResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))
|
|
}
|
|
template, _ = sjson.Set(template, "responseId", (*param).(*ConvertCodexResponseToGeminiParams).ResponseID)
|
|
}
|
|
|
|
// Handle function call completion
|
|
if typeStr == "response.output_item.done" {
|
|
itemResult := rootResult.Get("item")
|
|
itemType := itemResult.Get("type").String()
|
|
if itemType == "function_call" {
|
|
// Create function call part
|
|
functionCall := `{"functionCall":{"name":"","args":{}}}`
|
|
{
|
|
// Restore original tool name if shortened
|
|
n := itemResult.Get("name").String()
|
|
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
|
|
if orig, ok := rev[n]; ok {
|
|
n = orig
|
|
}
|
|
functionCall, _ = sjson.Set(functionCall, "functionCall.name", n)
|
|
}
|
|
|
|
// Parse and set arguments
|
|
argsStr := itemResult.Get("arguments").String()
|
|
if argsStr != "" {
|
|
argsResult := gjson.Parse(argsStr)
|
|
if argsResult.IsObject() {
|
|
functionCall, _ = sjson.SetRaw(functionCall, "functionCall.args", argsStr)
|
|
}
|
|
}
|
|
|
|
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", functionCall)
|
|
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
|
|
|
|
(*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput = template
|
|
|
|
// Use this return to storage message
|
|
return []string{}
|
|
}
|
|
}
|
|
|
|
if typeStr == "response.created" { // Handle response creation - set model and response ID
|
|
template, _ = sjson.Set(template, "modelVersion", rootResult.Get("response.model").String())
|
|
template, _ = sjson.Set(template, "responseId", rootResult.Get("response.id").String())
|
|
(*param).(*ConvertCodexResponseToGeminiParams).ResponseID = rootResult.Get("response.id").String()
|
|
} else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta
|
|
part := `{"thought":true,"text":""}`
|
|
part, _ = sjson.Set(part, "text", rootResult.Get("delta").String())
|
|
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", part)
|
|
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
|
|
part := `{"text":""}`
|
|
part, _ = sjson.Set(part, "text", rootResult.Get("delta").String())
|
|
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", part)
|
|
} else if typeStr == "response.completed" { // Handle response completion with usage metadata
|
|
template, _ = sjson.Set(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
|
|
template, _ = sjson.Set(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
|
|
totalTokens := rootResult.Get("response.usage.input_tokens").Int() + rootResult.Get("response.usage.output_tokens").Int()
|
|
template, _ = sjson.Set(template, "usageMetadata.totalTokenCount", totalTokens)
|
|
} else {
|
|
return []string{}
|
|
}
|
|
|
|
if (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput != "" {
|
|
return []string{(*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput, template}
|
|
} else {
|
|
return []string{template}
|
|
}
|
|
|
|
}
|
|
|
|
// ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response.
|
|
// This function processes the complete Codex response and transforms it into a single Gemini-compatible
|
|
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
|
|
// the information into a single response that matches the Gemini API format.
|
|
//
|
|
// Parameters:
|
|
// - ctx: The context for the request, used for cancellation and timeout handling
|
|
// - modelName: The name of the model being used for the response
|
|
// - rawJSON: The raw JSON response from the Codex API
|
|
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
|
//
|
|
// Returns:
|
|
// - string: A Gemini-compatible JSON response containing all message content and metadata
|
|
func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
|
|
rootResult := gjson.ParseBytes(rawJSON)
|
|
|
|
// Verify this is a response.completed event
|
|
if rootResult.Get("type").String() != "response.completed" {
|
|
return ""
|
|
}
|
|
|
|
// Base Gemini response template for non-streaming
|
|
template := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`
|
|
|
|
// Set model version
|
|
template, _ = sjson.Set(template, "modelVersion", modelName)
|
|
|
|
// Set response metadata from the completed response
|
|
responseData := rootResult.Get("response")
|
|
if responseData.Exists() {
|
|
// Set response ID
|
|
if responseId := responseData.Get("id"); responseId.Exists() {
|
|
template, _ = sjson.Set(template, "responseId", responseId.String())
|
|
}
|
|
|
|
// Set creation time
|
|
if createdAt := responseData.Get("created_at"); createdAt.Exists() {
|
|
template, _ = sjson.Set(template, "createTime", time.Unix(createdAt.Int(), 0).Format(time.RFC3339Nano))
|
|
}
|
|
|
|
// Set usage metadata
|
|
if usage := responseData.Get("usage"); usage.Exists() {
|
|
inputTokens := usage.Get("input_tokens").Int()
|
|
outputTokens := usage.Get("output_tokens").Int()
|
|
totalTokens := inputTokens + outputTokens
|
|
|
|
template, _ = sjson.Set(template, "usageMetadata.promptTokenCount", inputTokens)
|
|
template, _ = sjson.Set(template, "usageMetadata.candidatesTokenCount", outputTokens)
|
|
template, _ = sjson.Set(template, "usageMetadata.totalTokenCount", totalTokens)
|
|
}
|
|
|
|
// Process output content to build parts array
|
|
var parts []interface{}
|
|
hasToolCall := false
|
|
var pendingFunctionCalls []interface{}
|
|
|
|
flushPendingFunctionCalls := func() {
|
|
if len(pendingFunctionCalls) > 0 {
|
|
// Add all pending function calls as individual parts
|
|
// This maintains the original Gemini API format while ensuring consecutive calls are grouped together
|
|
for _, fc := range pendingFunctionCalls {
|
|
parts = append(parts, fc)
|
|
}
|
|
pendingFunctionCalls = nil
|
|
}
|
|
}
|
|
|
|
if output := responseData.Get("output"); output.Exists() && output.IsArray() {
|
|
output.ForEach(func(key, value gjson.Result) bool {
|
|
itemType := value.Get("type").String()
|
|
|
|
switch itemType {
|
|
case "reasoning":
|
|
// Flush any pending function calls before adding non-function content
|
|
flushPendingFunctionCalls()
|
|
|
|
// Add thinking content
|
|
if content := value.Get("content"); content.Exists() {
|
|
part := map[string]interface{}{
|
|
"thought": true,
|
|
"text": content.String(),
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
|
|
case "message":
|
|
// Flush any pending function calls before adding non-function content
|
|
flushPendingFunctionCalls()
|
|
|
|
// Add regular text content
|
|
if content := value.Get("content"); content.Exists() && content.IsArray() {
|
|
content.ForEach(func(_, contentItem gjson.Result) bool {
|
|
if contentItem.Get("type").String() == "output_text" {
|
|
if text := contentItem.Get("text"); text.Exists() {
|
|
part := map[string]interface{}{
|
|
"text": text.String(),
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
case "function_call":
|
|
// Collect function call for potential merging with consecutive ones
|
|
hasToolCall = true
|
|
functionCall := map[string]interface{}{
|
|
"functionCall": map[string]interface{}{
|
|
"name": func() string {
|
|
n := value.Get("name").String()
|
|
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
|
|
if orig, ok := rev[n]; ok {
|
|
return orig
|
|
}
|
|
return n
|
|
}(),
|
|
"args": map[string]interface{}{},
|
|
},
|
|
}
|
|
|
|
// Parse and set arguments
|
|
if argsStr := value.Get("arguments").String(); argsStr != "" {
|
|
argsResult := gjson.Parse(argsStr)
|
|
if argsResult.IsObject() {
|
|
var args map[string]interface{}
|
|
if err := json.Unmarshal([]byte(argsStr), &args); err == nil {
|
|
functionCall["functionCall"].(map[string]interface{})["args"] = args
|
|
}
|
|
}
|
|
}
|
|
|
|
pendingFunctionCalls = append(pendingFunctionCalls, functionCall)
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Handle any remaining pending function calls at the end
|
|
flushPendingFunctionCalls()
|
|
}
|
|
|
|
// Set the parts array
|
|
if len(parts) > 0 {
|
|
template, _ = sjson.SetRaw(template, "candidates.0.content.parts", mustMarshalJSON(parts))
|
|
}
|
|
|
|
// Set finish reason based on whether there were tool calls
|
|
if hasToolCall {
|
|
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
|
|
} else {
|
|
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
|
|
}
|
|
}
|
|
return template
|
|
}
|
|
|
|
// buildReverseMapFromGeminiOriginal builds a map[short]original from original Gemini request tools.
|
|
func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
|
|
tools := gjson.GetBytes(original, "tools")
|
|
rev := map[string]string{}
|
|
if !tools.IsArray() {
|
|
return rev
|
|
}
|
|
var names []string
|
|
tarr := tools.Array()
|
|
for i := 0; i < len(tarr); i++ {
|
|
fns := tarr[i].Get("functionDeclarations")
|
|
if !fns.IsArray() {
|
|
continue
|
|
}
|
|
for _, fn := range fns.Array() {
|
|
if v := fn.Get("name"); v.Exists() {
|
|
names = append(names, v.String())
|
|
}
|
|
}
|
|
}
|
|
if len(names) > 0 {
|
|
m := buildShortNameMap(names)
|
|
for orig, short := range m {
|
|
rev[short] = orig
|
|
}
|
|
}
|
|
return rev
|
|
}
|
|
|
|
// mustMarshalJSON marshals a value to JSON, panicking on error.
|
|
func mustMarshalJSON(v interface{}) string {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func GeminiTokenCount(ctx context.Context, count int64) string {
|
|
return fmt.Sprintf(`{"totalTokens":%d,"promptTokensDetails":[{"modality":"TEXT","tokenCount":%d}]}`, count, count)
|
|
}
|