Compare commits

...

6 Commits

Author SHA1 Message Date
Luis Pater
e5a6fd2d4f refactor: standardize dataTag processing across response translators
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Unified `dataTag` initialization by removing spaces after `data:`.
- Replaced manual slicing with `bytes.TrimSpace` for consistent and robust handling of JSON payloads.
2025-09-21 11:16:03 +08:00
Luis Pater
83a1fa618d Merge pull request #52 from router-for-me/gemini-web
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
Add support for image generation with Gemini models through the OpenAI chat completions translator.
2025-09-20 20:12:51 +08:00
hkfires
9253bdbf77 feat(provider): Introduce dedicated provider type for Gemini-Web 2025-09-20 19:47:58 +08:00
hkfires
41effa5aeb feat(gemini-web): Add support for image generation with Gemini models through the OpenAI chat completions translator. 2025-09-20 19:34:53 +08:00
Luis Pater
b07ed71de2 Merge pull request #51 from router-for-me/gemini-web
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
feat(gemini-web): Add support for real Nano Banana model
2025-09-20 14:26:03 +08:00
hkfires
deaa64b080 feat(gemini-web): Add support for real Nano Banana model 2025-09-20 13:35:27 +08:00
19 changed files with 207 additions and 63 deletions

View File

@@ -619,7 +619,7 @@ func (c *GeminiCLIClient) SendRawMessageStream(ctx context.Context, modelName st
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
lines := translator.Response(handlerType, c.Type(), newCtx, modelName, originalRequestRawJSON, rawJSON, line[6:], &param) lines := translator.Response(handlerType, c.Type(), newCtx, modelName, originalRequestRawJSON, rawJSON, bytes.TrimSpace(line[5:]), &param)
for i := 0; i < len(lines); i++ { for i := 0; i < len(lines); i++ {
dataChan <- []byte(lines[i]) dataChan <- []byte(lines[i])
} }
@@ -630,7 +630,7 @@ func (c *GeminiCLIClient) SendRawMessageStream(ctx context.Context, modelName st
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
dataChan <- line[6:] dataChan <- bytes.TrimSpace(line[5:])
} }
c.AddAPIResponseData(ctx, line) c.AddAPIResponseData(ctx, line)
} }

View File

@@ -33,6 +33,10 @@ type GeminiClient struct {
accountLabel string accountLabel string
} }
var NanoBananaModel = map[string]struct{}{
"gemini-2.5-flash-image-preview": {},
}
// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port). // NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port).
func NewGeminiClient(secure1psid string, secure1psidts string, proxy string, opts ...func(*GeminiClient)) *GeminiClient { func NewGeminiClient(secure1psid string, secure1psidts string, proxy string, opts ...func(*GeminiClient)) *GeminiClient {
c := &GeminiClient{ c := &GeminiClient{
@@ -239,6 +243,14 @@ func (c *GeminiClient) GenerateContent(prompt string, files []string, model Mode
} }
} }
func ensureAnyLen(slice []any, index int) []any {
if index < len(slice) {
return slice
}
gap := index + 1 - len(slice)
return append(slice, make([]any, gap)...)
}
func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) { func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
var empty ModelOutput var empty ModelOutput
// Build f.req // Build f.req
@@ -266,6 +278,14 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
} }
inner := []any{item0, nil, item2} inner := []any{item0, nil, item2}
requestedModel := strings.ToLower(model.Name)
if chat != nil && chat.RequestedModel() != "" {
requestedModel = chat.RequestedModel()
}
if _, ok := NanoBananaModel[requestedModel]; ok {
inner = ensureAnyLen(inner, 49)
inner[49] = 14
}
if gem != nil { if gem != nil {
// pad with 16 nils then gem ID // pad with 16 nils then gem ID
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
@@ -674,7 +694,7 @@ func truncateForLog(s string, n int) string {
// StartChat returns a ChatSession attached to the client // StartChat returns a ChatSession attached to the client
func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession { func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession {
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem} return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem, requestedModel: strings.ToLower(model.Name)}
} }
// ChatSession holds conversation metadata // ChatSession holds conversation metadata
@@ -684,6 +704,7 @@ type ChatSession struct {
lastOutput *ModelOutput lastOutput *ModelOutput
model Model model Model
gem *Gem gem *Gem
requestedModel string
} }
func (cs *ChatSession) String() string { func (cs *ChatSession) String() string {
@@ -710,6 +731,10 @@ func normalizeMeta(v []string) []string {
func (cs *ChatSession) Metadata() []string { return cs.metadata } func (cs *ChatSession) Metadata() []string { return cs.metadata }
func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) } func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) }
func (cs *ChatSession) RequestedModel() string { return cs.requestedModel }
func (cs *ChatSession) SetRequestedModel(name string) {
cs.requestedModel = strings.ToLower(name)
}
func (cs *ChatSession) CID() string { func (cs *ChatSession) CID() string {
if len(cs.metadata) > 0 { if len(cs.metadata) > 0 {
return cs.metadata[0] return cs.metadata[0]

View File

@@ -207,7 +207,7 @@ func (c *GeminiWebClient) registerModelsOnce() {
if c.modelsRegistered { if c.modelsRegistered {
return return
} }
c.RegisterModels(GEMINI, geminiWeb.GetGeminiWebAliasedModels()) c.RegisterModels(GEMINIWEB, geminiWeb.GetGeminiWebAliasedModels())
c.modelsRegistered = true c.modelsRegistered = true
} }
@@ -219,8 +219,8 @@ func (c *GeminiWebClient) EnsureRegistered() {
} }
} }
func (c *GeminiWebClient) Type() string { return GEMINI } func (c *GeminiWebClient) Type() string { return GEMINIWEB }
func (c *GeminiWebClient) Provider() string { return GEMINI } func (c *GeminiWebClient) Provider() string { return GEMINIWEB }
func (c *GeminiWebClient) CanProvideModel(modelName string) bool { func (c *GeminiWebClient) CanProvideModel(modelName string) bool {
geminiWeb.EnsureGeminiWebAliasMap() geminiWeb.EnsureGeminiWebAliasMap()
_, ok := geminiWeb.GeminiWebAliasMap[strings.ToLower(modelName)] _, ok := geminiWeb.GeminiWebAliasMap[strings.ToLower(modelName)]
@@ -394,6 +394,7 @@ func (c *GeminiWebClient) prepareChat(ctx context.Context, modelName string, raw
c.appendUpstreamRequestLog(ctx, modelName, res.tagged, true, res.prompt, len(uploadedFiles), res.reuse, res.metaLen) c.appendUpstreamRequestLog(ctx, modelName, res.tagged, true, res.prompt, len(uploadedFiles), res.reuse, res.metaLen)
gem := c.getConfiguredGem() gem := c.getConfiguredGem()
res.chat = c.gwc.StartChat(model, gem, meta) res.chat = c.gwc.StartChat(model, gem, meta)
res.chat.SetRequestedModel(modelName)
return res, nil return res, nil
} }

View File

@@ -342,7 +342,7 @@ func (c *GeminiClient) SendRawMessageStream(ctx context.Context, modelName strin
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
lines := translator.Response(handlerType, c.Type(), newCtx, modelName, originalRequestRawJSON, rawJSON, line[6:], &param) lines := translator.Response(handlerType, c.Type(), newCtx, modelName, originalRequestRawJSON, rawJSON, bytes.TrimSpace(line[5:]), &param)
for i := 0; i < len(lines); i++ { for i := 0; i < len(lines); i++ {
dataChan <- []byte(lines[i]) dataChan <- []byte(lines[i])
} }
@@ -353,7 +353,7 @@ func (c *GeminiClient) SendRawMessageStream(ctx context.Context, modelName strin
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
dataChan <- line[6:] dataChan <- bytes.TrimSpace(line[5:])
} }
c.AddAPIResponseData(ctx, line) c.AddAPIResponseData(ctx, line)
} }

View File

@@ -292,8 +292,7 @@ func (c *OpenAICompatibilityClient) SendRawMessageStream(ctx context.Context, mo
rawJSON = translator.Request(handlerType, c.Type(), modelName, rawJSON, true) rawJSON = translator.Request(handlerType, c.Type(), modelName, rawJSON, true)
dataTag := []byte("data:") dataTag := []byte("data:")
dataUglyTag := []byte("data:") // Some APIs providers don't add space after "data:", fuck for them all doneTag := []byte("[DONE]")
doneTag := []byte("data: [DONE]")
errChan := make(chan *interfaces.ErrorMessage) errChan := make(chan *interfaces.ErrorMessage)
dataChan := make(chan []byte) dataChan := make(chan []byte)
// log.Debugf(string(rawJSON)) // log.Debugf(string(rawJSON))
@@ -332,19 +331,10 @@ func (c *OpenAICompatibilityClient) SendRawMessageStream(ctx context.Context, mo
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
if bytes.Equal(line, doneTag) { if bytes.Equal(bytes.TrimSpace(line[5:]), doneTag) {
break break
} }
lines := translator.Response(handlerType, c.Type(), newCtx, modelName, originalRequestRawJSON, rawJSON, line[6:], &param) lines := translator.Response(handlerType, c.Type(), newCtx, modelName, originalRequestRawJSON, rawJSON, bytes.TrimSpace(line[5:]), &param)
for i := 0; i < len(lines); i++ {
c.AddAPIResponseData(ctx, line)
dataChan <- []byte(lines[i])
}
} else if bytes.HasPrefix(line, dataUglyTag) {
if bytes.Equal(line, doneTag) {
break
}
lines := translator.Response(handlerType, c.Type(), newCtx, modelName, originalRequestRawJSON, rawJSON, line[5:], &param)
for i := 0; i < len(lines); i++ { for i := 0; i < len(lines); i++ {
c.AddAPIResponseData(ctx, line) c.AddAPIResponseData(ctx, line)
dataChan <- []byte(lines[i]) dataChan <- []byte(lines[i])
@@ -356,13 +346,10 @@ func (c *OpenAICompatibilityClient) SendRawMessageStream(ctx context.Context, mo
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
if bytes.Equal(line, doneTag) { if bytes.Equal(bytes.TrimSpace(line[5:]), doneTag) {
break break
} }
c.AddAPIResponseData(newCtx, line[6:]) c.AddAPIResponseData(newCtx, bytes.TrimSpace(line[5:]))
dataChan <- line[6:]
} else if bytes.HasPrefix(line, dataUglyTag) {
c.AddAPIResponseData(newCtx, line[5:])
dataChan <- line[5:] dataChan <- line[5:]
} }
} }

View File

@@ -216,7 +216,7 @@ func (c *QwenClient) SendRawMessageStream(ctx context.Context, modelName string,
rawJSON = translator.Request(handlerType, c.Type(), modelName, rawJSON, true) rawJSON = translator.Request(handlerType, c.Type(), modelName, rawJSON, true)
dataTag := []byte("data:") dataTag := []byte("data:")
doneTag := []byte("data: [DONE]") doneTag := []byte("[DONE]")
errChan := make(chan *interfaces.ErrorMessage) errChan := make(chan *interfaces.ErrorMessage)
dataChan := make(chan []byte) dataChan := make(chan []byte)
@@ -264,7 +264,7 @@ func (c *QwenClient) SendRawMessageStream(ctx context.Context, modelName string,
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
lines := translator.Response(handlerType, c.Type(), ctx, modelName, originalRequestRawJSON, rawJSON, line[6:], &param) lines := translator.Response(handlerType, c.Type(), ctx, modelName, originalRequestRawJSON, rawJSON, bytes.TrimSpace(line[5:]), &param)
for i := 0; i < len(lines); i++ { for i := 0; i < len(lines); i++ {
dataChan <- []byte(lines[i]) dataChan <- []byte(lines[i])
} }
@@ -274,9 +274,9 @@ func (c *QwenClient) SendRawMessageStream(ctx context.Context, modelName string,
} else { } else {
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
if !bytes.HasPrefix(line, doneTag) {
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
dataChan <- line[6:] if !bytes.Equal(bytes.TrimSpace(line[5:]), doneTag) {
dataChan <- bytes.TrimSpace(line[5:])
} }
} }
c.AddAPIResponseData(ctx, line) c.AddAPIResponseData(ctx, line)

View File

@@ -3,6 +3,7 @@ package constant
const ( const (
GEMINI = "gemini" GEMINI = "gemini"
GEMINICLI = "gemini-cli" GEMINICLI = "gemini-cli"
GEMINIWEB = "gemini-web"
CODEX = "codex" CODEX = "codex"
CLAUDE = "claude" CLAUDE = "claude"
OPENAI = "openai" OPENAI = "openai"

View File

@@ -64,7 +64,7 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
if !bytes.HasPrefix(rawJSON, dataTag) { if !bytes.HasPrefix(rawJSON, dataTag) {
return []string{} return []string{}
} }
rawJSON = rawJSON[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
root := gjson.ParseBytes(rawJSON) root := gjson.ParseBytes(rawJSON)
eventType := root.Get("type").String() eventType := root.Get("type").String()
@@ -336,7 +336,7 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
line := scanner.Bytes() line := scanner.Bytes()
// log.Debug(string(line)) // log.Debug(string(line))
if bytes.HasPrefix(line, dataTag) { if bytes.HasPrefix(line, dataTag) {
jsonData := line[6:] jsonData := bytes.TrimSpace(line[5:])
streamingEvents = append(streamingEvents, jsonData) streamingEvents = append(streamingEvents, jsonData)
} }
} }

View File

@@ -62,7 +62,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
if !bytes.HasPrefix(rawJSON, dataTag) { if !bytes.HasPrefix(rawJSON, dataTag) {
return []string{} return []string{}
} }
rawJSON = rawJSON[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
root := gjson.ParseBytes(rawJSON) root := gjson.ParseBytes(rawJSON)
eventType := root.Get("type").String() eventType := root.Get("type").String()
@@ -289,7 +289,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
if !bytes.HasPrefix(line, dataTag) { if !bytes.HasPrefix(line, dataTag) {
continue continue
} }
chunks = append(chunks, line[6:]) chunks = append(chunks, bytes.TrimSpace(rawJSON[5:]))
} }
// Base OpenAI non-streaming response template // Base OpenAI non-streaming response template

View File

@@ -51,7 +51,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
if !bytes.HasPrefix(rawJSON, dataTag) { if !bytes.HasPrefix(rawJSON, dataTag) {
return []string{} return []string{}
} }
rawJSON = rawJSON[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
root := gjson.ParseBytes(rawJSON) root := gjson.ParseBytes(rawJSON)
ev := root.Get("type").String() ev := root.Get("type").String()
var out []string var out []string

View File

@@ -45,7 +45,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
if !bytes.HasPrefix(rawJSON, dataTag) { if !bytes.HasPrefix(rawJSON, dataTag) {
return []string{} return []string{}
} }
rawJSON = rawJSON[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
output := "" output := ""
rootResult := gjson.ParseBytes(rawJSON) rootResult := gjson.ParseBytes(rawJSON)

View File

@@ -53,7 +53,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
if !bytes.HasPrefix(rawJSON, dataTag) { if !bytes.HasPrefix(rawJSON, dataTag) {
return []string{} return []string{}
} }
rawJSON = rawJSON[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
rootResult := gjson.ParseBytes(rawJSON) rootResult := gjson.ParseBytes(rawJSON)
typeResult := rootResult.Get("type") typeResult := rootResult.Get("type")
@@ -161,7 +161,7 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
if !bytes.HasPrefix(line, dataTag) { if !bytes.HasPrefix(line, dataTag) {
continue continue
} }
rawJSON = line[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
rootResult := gjson.ParseBytes(rawJSON) rootResult := gjson.ParseBytes(rawJSON)

View File

@@ -54,7 +54,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
if !bytes.HasPrefix(rawJSON, dataTag) { if !bytes.HasPrefix(rawJSON, dataTag) {
return []string{} return []string{}
} }
rawJSON = rawJSON[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
// Initialize the OpenAI SSE template. // Initialize the OpenAI SSE template.
template := `{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}` template := `{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
@@ -175,7 +175,7 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
if !bytes.HasPrefix(line, dataTag) { if !bytes.HasPrefix(line, dataTag) {
continue continue
} }
rawJSON = line[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
rootResult := gjson.ParseBytes(rawJSON) rootResult := gjson.ParseBytes(rawJSON)
// Verify this is a response.completed event // Verify this is a response.completed event

View File

@@ -14,7 +14,7 @@ import (
// to OpenAI Responses SSE events (response.*). // to OpenAI Responses SSE events (response.*).
func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string { func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
if bytes.HasPrefix(rawJSON, []byte("data:")) { if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = rawJSON[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
if typeResult := gjson.GetBytes(rawJSON, "type"); typeResult.Exists() { if typeResult := gjson.GetBytes(rawJSON, "type"); typeResult.Exists() {
typeStr := typeResult.String() typeStr := typeResult.String()
if typeStr == "response.created" || typeStr == "response.in_progress" || typeStr == "response.completed" { if typeStr == "response.created" || typeStr == "response.in_progress" || typeStr == "response.completed" {
@@ -39,7 +39,7 @@ func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, modelName
if !bytes.HasPrefix(line, dataTag) { if !bytes.HasPrefix(line, dataTag) {
continue continue
} }
rawJSON = line[6:] rawJSON = bytes.TrimSpace(rawJSON[5:])
rootResult := gjson.ParseBytes(rawJSON) rootResult := gjson.ParseBytes(rawJSON)
// Verify this is a response.completed event // Verify this is a response.completed event

View File

@@ -0,0 +1,20 @@
package chat_completions
import (
. "github.com/luispater/CLIProxyAPI/v5/internal/constant"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
geminiChat "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini/openai/chat-completions"
"github.com/luispater/CLIProxyAPI/v5/internal/translator/translator"
)
func init() {
translator.Register(
OPENAI,
GEMINIWEB,
geminiChat.ConvertOpenAIRequestToGemini,
interfaces.TranslateResponse{
Stream: geminiChat.ConvertGeminiResponseToOpenAI,
NonStream: geminiChat.ConvertGeminiResponseToOpenAINonStream,
},
)
}

View File

@@ -0,0 +1,20 @@
package responses
import (
. "github.com/luispater/CLIProxyAPI/v5/internal/constant"
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
geminiResponses "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini/openai/responses"
"github.com/luispater/CLIProxyAPI/v5/internal/translator/translator"
)
func init() {
translator.Register(
OPENAI_RESPONSE,
GEMINIWEB,
geminiResponses.ConvertOpenAIResponsesRequestToGemini,
interfaces.TranslateResponse{
Stream: geminiResponses.ConvertGeminiResponseToOpenAIResponses,
NonStream: geminiResponses.ConvertGeminiResponseToOpenAIResponsesNonStream,
},
)
}

View File

@@ -170,6 +170,31 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
node := []byte(`{"role":"model","parts":[{"text":""}]}`) node := []byte(`{"role":"model","parts":[{"text":""}]}`)
node, _ = sjson.SetBytes(node, "parts.0.text", content.String()) node, _ = sjson.SetBytes(node, "parts.0.text", content.String())
out, _ = sjson.SetRawBytes(out, "contents.-1", node) out, _ = sjson.SetRawBytes(out, "contents.-1", node)
} else if content.IsArray() {
// Assistant multimodal content (e.g. text + image) -> single model content with parts
node := []byte(`{"role":"model","parts":[]}`)
p := 0
for _, item := range content.Array() {
switch item.Get("type").String() {
case "text":
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", item.Get("text").String())
p++
case "image_url":
// If the assistant returned an inline data URL, preserve it for history fidelity.
imageURL := item.Get("image_url.url").String()
if len(imageURL) > 5 { // expect data:...
pieces := strings.SplitN(imageURL[5:], ";", 2)
if len(pieces) == 2 && len(pieces[1]) > 7 {
mime := pieces[0]
data := pieces[1][7:]
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.mime_type", mime)
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".inlineData.data", data)
p++
}
}
}
}
out, _ = sjson.SetRawBytes(out, "contents.-1", node)
} else if !content.Exists() || content.Type == gjson.Null { } else if !content.Exists() || content.Type == gjson.Null {
// Tool calls -> single model content with functionCall parts // Tool calls -> single model content with functionCall parts
tcs := m.Get("tool_calls") tcs := m.Get("tool_calls")

View File

@@ -8,6 +8,7 @@ package chat_completions
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -99,6 +100,10 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
partResult := partResults[i] partResult := partResults[i]
partTextResult := partResult.Get("text") partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall") functionCallResult := partResult.Get("functionCall")
inlineDataResult := partResult.Get("inlineData")
if !inlineDataResult.Exists() {
inlineDataResult = partResult.Get("inline_data")
}
if partTextResult.Exists() { if partTextResult.Exists() {
// Handle text content, distinguishing between regular content and reasoning/thoughts. // Handle text content, distinguishing between regular content and reasoning/thoughts.
@@ -124,6 +129,34 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
} }
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant") template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallTemplate) template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
} else if inlineDataResult.Exists() {
data := inlineDataResult.Get("data").String()
if data == "" {
continue
}
mimeType := inlineDataResult.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineDataResult.Get("mime_type").String()
}
if mimeType == "" {
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
imagePayload, err := json.Marshal(map[string]any{
"type": "image_url",
"image_url": map[string]string{
"url": imageURL,
},
})
if err != nil {
continue
}
imagesResult := gjson.Get(template, "choices.0.delta.images")
if !imagesResult.Exists() || !imagesResult.IsArray() {
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
}
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", string(imagePayload))
} }
} }
} }
@@ -193,6 +226,10 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
partResult := partsResults[i] partResult := partsResults[i]
partTextResult := partResult.Get("text") partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall") functionCallResult := partResult.Get("functionCall")
inlineDataResult := partResult.Get("inlineData")
if !inlineDataResult.Exists() {
inlineDataResult = partResult.Get("inline_data")
}
if partTextResult.Exists() { if partTextResult.Exists() {
// Append text content, distinguishing between regular content and reasoning. // Append text content, distinguishing between regular content and reasoning.
@@ -217,9 +254,34 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
} }
template, _ = sjson.Set(template, "choices.0.message.role", "assistant") template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", functionCallItemTemplate) template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", functionCallItemTemplate)
} else { } else if inlineDataResult.Exists() {
// If no usable content is found, return an empty string. data := inlineDataResult.Get("data").String()
return "" if data == "" {
continue
}
mimeType := inlineDataResult.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineDataResult.Get("mime_type").String()
}
if mimeType == "" {
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
imagePayload, err := json.Marshal(map[string]any{
"type": "image_url",
"image_url": map[string]string{
"url": imageURL,
},
})
if err != nil {
continue
}
imagesResult := gjson.Get(template, "choices.0.message.images")
if !imagesResult.Exists() || !imagesResult.IsArray() {
template, _ = sjson.SetRaw(template, "choices.0.message.images", `[]`)
}
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.message.images.-1", string(imagePayload))
} }
} }
} }

View File

@@ -23,6 +23,9 @@ import (
_ "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini/openai/chat-completions" _ "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini/openai/chat-completions"
_ "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini/openai/responses" _ "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini/openai/responses"
_ "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini-web/openai/chat-completions"
_ "github.com/luispater/CLIProxyAPI/v5/internal/translator/gemini-web/openai/responses"
_ "github.com/luispater/CLIProxyAPI/v5/internal/translator/openai/claude" _ "github.com/luispater/CLIProxyAPI/v5/internal/translator/openai/claude"
_ "github.com/luispater/CLIProxyAPI/v5/internal/translator/openai/gemini" _ "github.com/luispater/CLIProxyAPI/v5/internal/translator/openai/gemini"
_ "github.com/luispater/CLIProxyAPI/v5/internal/translator/openai/gemini-cli" _ "github.com/luispater/CLIProxyAPI/v5/internal/translator/openai/gemini-cli"