|
|
|
|
|
|
|
|
|
|
|
package executor |
|
|
|
|
|
import ( |
|
|
"bufio" |
|
|
"bytes" |
|
|
"context" |
|
|
"encoding/json" |
|
|
"fmt" |
|
|
"io" |
|
|
"net/http" |
|
|
"regexp" |
|
|
"strconv" |
|
|
"strings" |
|
|
"time" |
|
|
|
|
|
"github.com/gin-gonic/gin" |
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc" |
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" |
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" |
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" |
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" |
|
|
log "github.com/sirupsen/logrus" |
|
|
"github.com/tidwall/gjson" |
|
|
"github.com/tidwall/sjson" |
|
|
"golang.org/x/oauth2" |
|
|
"golang.org/x/oauth2/google" |
|
|
) |
|
|
|
|
|
const ( |
|
|
codeAssistEndpoint = "https://cloudcode-pa.googleapis.com" |
|
|
codeAssistVersion = "v1internal" |
|
|
geminiOAuthClientID = "YOUR_CLIENT_ID" |
|
|
geminiOAuthClientSecret = "YOUR_CLIENT_SECRET" |
|
|
) |
|
|
|
|
|
var geminiOAuthScopes = []string{ |
|
|
"https://www.googleapis.com/auth/cloud-platform", |
|
|
"https://www.googleapis.com/auth/userinfo.email", |
|
|
"https://www.googleapis.com/auth/userinfo.profile", |
|
|
} |
|
|
|
|
|
|
|
|
type GeminiCLIExecutor struct { |
|
|
cfg *config.Config |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func NewGeminiCLIExecutor(cfg *config.Config) *GeminiCLIExecutor { |
|
|
return &GeminiCLIExecutor{cfg: cfg} |
|
|
} |
|
|
|
|
|
|
|
|
func (e *GeminiCLIExecutor) Identifier() string { return "gemini-cli" } |
|
|
|
|
|
|
|
|
func (e *GeminiCLIExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } |
|
|
|
|
|
|
|
|
func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { |
|
|
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth) |
|
|
if err != nil { |
|
|
return resp, err |
|
|
} |
|
|
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) |
|
|
defer reporter.trackFailure(ctx, &err) |
|
|
|
|
|
from := opts.SourceFormat |
|
|
to := sdktranslator.FromString("gemini-cli") |
|
|
originalPayload := bytes.Clone(req.Payload) |
|
|
if len(opts.OriginalRequest) > 0 { |
|
|
originalPayload = bytes.Clone(opts.OriginalRequest) |
|
|
} |
|
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false) |
|
|
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) |
|
|
basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) |
|
|
basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) |
|
|
basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload) |
|
|
basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) |
|
|
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) |
|
|
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) |
|
|
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload, originalTranslated) |
|
|
|
|
|
action := "generateContent" |
|
|
if req.Metadata != nil { |
|
|
if a, _ := req.Metadata["action"].(string); a == "countTokens" { |
|
|
action = "countTokens" |
|
|
} |
|
|
} |
|
|
|
|
|
projectID := resolveGeminiProjectID(auth) |
|
|
models := cliPreviewFallbackOrder(req.Model) |
|
|
if len(models) == 0 || models[0] != req.Model { |
|
|
models = append([]string{req.Model}, models...) |
|
|
} |
|
|
|
|
|
httpClient := newHTTPClient(ctx, e.cfg, auth, 0) |
|
|
respCtx := context.WithValue(ctx, "alt", opts.Alt) |
|
|
|
|
|
var authID, authLabel, authType, authValue string |
|
|
authID = auth.ID |
|
|
authLabel = auth.Label |
|
|
authType, authValue = auth.AccountInfo() |
|
|
|
|
|
var lastStatus int |
|
|
var lastBody []byte |
|
|
|
|
|
for idx, attemptModel := range models { |
|
|
payload := append([]byte(nil), basePayload...) |
|
|
if action == "countTokens" { |
|
|
payload = deleteJSONField(payload, "project") |
|
|
payload = deleteJSONField(payload, "model") |
|
|
} else { |
|
|
payload = setJSONField(payload, "project", projectID) |
|
|
payload = setJSONField(payload, "model", attemptModel) |
|
|
} |
|
|
|
|
|
tok, errTok := tokenSource.Token() |
|
|
if errTok != nil { |
|
|
err = errTok |
|
|
return resp, err |
|
|
} |
|
|
updateGeminiCLITokenMetadata(auth, baseTokenData, tok) |
|
|
|
|
|
url := fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, codeAssistVersion, action) |
|
|
if opts.Alt != "" && action != "countTokens" { |
|
|
url = url + fmt.Sprintf("?$alt=%s", opts.Alt) |
|
|
} |
|
|
|
|
|
reqHTTP, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) |
|
|
if errReq != nil { |
|
|
err = errReq |
|
|
return resp, err |
|
|
} |
|
|
reqHTTP.Header.Set("Content-Type", "application/json") |
|
|
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken) |
|
|
applyGeminiCLIHeaders(reqHTTP) |
|
|
reqHTTP.Header.Set("Accept", "application/json") |
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ |
|
|
URL: url, |
|
|
Method: http.MethodPost, |
|
|
Headers: reqHTTP.Header.Clone(), |
|
|
Body: payload, |
|
|
Provider: e.Identifier(), |
|
|
AuthID: authID, |
|
|
AuthLabel: authLabel, |
|
|
AuthType: authType, |
|
|
AuthValue: authValue, |
|
|
}) |
|
|
|
|
|
httpResp, errDo := httpClient.Do(reqHTTP) |
|
|
if errDo != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errDo) |
|
|
err = errDo |
|
|
return resp, err |
|
|
} |
|
|
|
|
|
data, errRead := io.ReadAll(httpResp.Body) |
|
|
if errClose := httpResp.Body.Close(); errClose != nil { |
|
|
log.Errorf("gemini cli executor: close response body error: %v", errClose) |
|
|
} |
|
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) |
|
|
if errRead != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errRead) |
|
|
err = errRead |
|
|
return resp, err |
|
|
} |
|
|
appendAPIResponseChunk(ctx, e.cfg, data) |
|
|
if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 { |
|
|
reporter.publish(ctx, parseGeminiCLIUsage(data)) |
|
|
var param any |
|
|
out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, ¶m) |
|
|
resp = cliproxyexecutor.Response{Payload: []byte(out)} |
|
|
return resp, nil |
|
|
} |
|
|
|
|
|
lastStatus = httpResp.StatusCode |
|
|
lastBody = append([]byte(nil), data...) |
|
|
log.Warnf("gemini-cli executor: upstream API error, status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) |
|
|
if httpResp.StatusCode == 429 { |
|
|
if idx+1 < len(models) { |
|
|
log.Debugf("gemini cli executor: rate limited, retrying with next model: %s", models[idx+1]) |
|
|
} else { |
|
|
log.Debug("gemini cli executor: rate limited, no additional fallback model") |
|
|
} |
|
|
continue |
|
|
} |
|
|
|
|
|
err = newGeminiStatusErr(httpResp.StatusCode, data) |
|
|
return resp, err |
|
|
} |
|
|
|
|
|
if len(lastBody) > 0 { |
|
|
appendAPIResponseChunk(ctx, e.cfg, lastBody) |
|
|
} |
|
|
if lastStatus == 0 { |
|
|
lastStatus = 429 |
|
|
} |
|
|
err = newGeminiStatusErr(lastStatus, lastBody) |
|
|
return resp, err |
|
|
} |
|
|
|
|
|
|
|
|
func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) { |
|
|
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth) |
|
|
if err != nil { |
|
|
return nil, err |
|
|
} |
|
|
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) |
|
|
defer reporter.trackFailure(ctx, &err) |
|
|
|
|
|
from := opts.SourceFormat |
|
|
to := sdktranslator.FromString("gemini-cli") |
|
|
originalPayload := bytes.Clone(req.Payload) |
|
|
if len(opts.OriginalRequest) > 0 { |
|
|
originalPayload = bytes.Clone(opts.OriginalRequest) |
|
|
} |
|
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, true) |
|
|
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) |
|
|
basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) |
|
|
basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload) |
|
|
basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload) |
|
|
basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload) |
|
|
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) |
|
|
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) |
|
|
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload, originalTranslated) |
|
|
|
|
|
projectID := resolveGeminiProjectID(auth) |
|
|
|
|
|
models := cliPreviewFallbackOrder(req.Model) |
|
|
if len(models) == 0 || models[0] != req.Model { |
|
|
models = append([]string{req.Model}, models...) |
|
|
} |
|
|
|
|
|
httpClient := newHTTPClient(ctx, e.cfg, auth, 0) |
|
|
respCtx := context.WithValue(ctx, "alt", opts.Alt) |
|
|
|
|
|
var authID, authLabel, authType, authValue string |
|
|
authID = auth.ID |
|
|
authLabel = auth.Label |
|
|
authType, authValue = auth.AccountInfo() |
|
|
|
|
|
var lastStatus int |
|
|
var lastBody []byte |
|
|
|
|
|
for idx, attemptModel := range models { |
|
|
payload := append([]byte(nil), basePayload...) |
|
|
payload = setJSONField(payload, "project", projectID) |
|
|
payload = setJSONField(payload, "model", attemptModel) |
|
|
|
|
|
tok, errTok := tokenSource.Token() |
|
|
if errTok != nil { |
|
|
err = errTok |
|
|
return nil, err |
|
|
} |
|
|
updateGeminiCLITokenMetadata(auth, baseTokenData, tok) |
|
|
|
|
|
url := fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, codeAssistVersion, "streamGenerateContent") |
|
|
if opts.Alt == "" { |
|
|
url = url + "?alt=sse" |
|
|
} else { |
|
|
url = url + fmt.Sprintf("?$alt=%s", opts.Alt) |
|
|
} |
|
|
|
|
|
reqHTTP, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) |
|
|
if errReq != nil { |
|
|
err = errReq |
|
|
return nil, err |
|
|
} |
|
|
reqHTTP.Header.Set("Content-Type", "application/json") |
|
|
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken) |
|
|
applyGeminiCLIHeaders(reqHTTP) |
|
|
reqHTTP.Header.Set("Accept", "text/event-stream") |
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ |
|
|
URL: url, |
|
|
Method: http.MethodPost, |
|
|
Headers: reqHTTP.Header.Clone(), |
|
|
Body: payload, |
|
|
Provider: e.Identifier(), |
|
|
AuthID: authID, |
|
|
AuthLabel: authLabel, |
|
|
AuthType: authType, |
|
|
AuthValue: authValue, |
|
|
}) |
|
|
|
|
|
httpResp, errDo := httpClient.Do(reqHTTP) |
|
|
if errDo != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errDo) |
|
|
err = errDo |
|
|
return nil, err |
|
|
} |
|
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) |
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { |
|
|
data, errRead := io.ReadAll(httpResp.Body) |
|
|
if errClose := httpResp.Body.Close(); errClose != nil { |
|
|
log.Errorf("gemini cli executor: close response body error: %v", errClose) |
|
|
} |
|
|
if errRead != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errRead) |
|
|
err = errRead |
|
|
return nil, err |
|
|
} |
|
|
appendAPIResponseChunk(ctx, e.cfg, data) |
|
|
lastStatus = httpResp.StatusCode |
|
|
lastBody = append([]byte(nil), data...) |
|
|
log.Warnf("gemini-cli executor: upstream API error, status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) |
|
|
if httpResp.StatusCode == 429 { |
|
|
if idx+1 < len(models) { |
|
|
log.Debugf("gemini cli executor: rate limited, retrying with next model: %s", models[idx+1]) |
|
|
} else { |
|
|
log.Debug("gemini cli executor: rate limited, no additional fallback model") |
|
|
} |
|
|
continue |
|
|
} |
|
|
err = newGeminiStatusErr(httpResp.StatusCode, data) |
|
|
return nil, err |
|
|
} |
|
|
|
|
|
out := make(chan cliproxyexecutor.StreamChunk) |
|
|
stream = out |
|
|
go func(resp *http.Response, reqBody []byte, attemptModel string) { |
|
|
defer close(out) |
|
|
defer func() { |
|
|
if errClose := resp.Body.Close(); errClose != nil { |
|
|
log.Errorf("gemini cli executor: close response body error: %v", errClose) |
|
|
} |
|
|
}() |
|
|
if opts.Alt == "" { |
|
|
scanner := bufio.NewScanner(resp.Body) |
|
|
scanner.Buffer(nil, streamScannerBuffer) |
|
|
var param any |
|
|
for scanner.Scan() { |
|
|
line := scanner.Bytes() |
|
|
appendAPIResponseChunk(ctx, e.cfg, line) |
|
|
if detail, ok := parseGeminiCLIStreamUsage(line); ok { |
|
|
reporter.publish(ctx, detail) |
|
|
} |
|
|
if bytes.HasPrefix(line, dataTag) { |
|
|
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), ¶m) |
|
|
for i := range segments { |
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone([]byte("[DONE]")), ¶m) |
|
|
for i := range segments { |
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])} |
|
|
} |
|
|
if errScan := scanner.Err(); errScan != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errScan) |
|
|
reporter.publishFailure(ctx) |
|
|
out <- cliproxyexecutor.StreamChunk{Err: errScan} |
|
|
} |
|
|
return |
|
|
} |
|
|
|
|
|
data, errRead := io.ReadAll(resp.Body) |
|
|
if errRead != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errRead) |
|
|
reporter.publishFailure(ctx) |
|
|
out <- cliproxyexecutor.StreamChunk{Err: errRead} |
|
|
return |
|
|
} |
|
|
appendAPIResponseChunk(ctx, e.cfg, data) |
|
|
reporter.publish(ctx, parseGeminiCLIUsage(data)) |
|
|
var param any |
|
|
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, data, ¶m) |
|
|
for i := range segments { |
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])} |
|
|
} |
|
|
|
|
|
segments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone([]byte("[DONE]")), ¶m) |
|
|
for i := range segments { |
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])} |
|
|
} |
|
|
}(httpResp, append([]byte(nil), payload...), attemptModel) |
|
|
|
|
|
return stream, nil |
|
|
} |
|
|
|
|
|
if len(lastBody) > 0 { |
|
|
appendAPIResponseChunk(ctx, e.cfg, lastBody) |
|
|
} |
|
|
if lastStatus == 0 { |
|
|
lastStatus = 429 |
|
|
} |
|
|
err = newGeminiStatusErr(lastStatus, lastBody) |
|
|
return nil, err |
|
|
} |
|
|
|
|
|
|
|
|
func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { |
|
|
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth) |
|
|
if err != nil { |
|
|
return cliproxyexecutor.Response{}, err |
|
|
} |
|
|
|
|
|
from := opts.SourceFormat |
|
|
to := sdktranslator.FromString("gemini-cli") |
|
|
|
|
|
models := cliPreviewFallbackOrder(req.Model) |
|
|
if len(models) == 0 || models[0] != req.Model { |
|
|
models = append([]string{req.Model}, models...) |
|
|
} |
|
|
|
|
|
httpClient := newHTTPClient(ctx, e.cfg, auth, 0) |
|
|
respCtx := context.WithValue(ctx, "alt", opts.Alt) |
|
|
|
|
|
var authID, authLabel, authType, authValue string |
|
|
if auth != nil { |
|
|
authID = auth.ID |
|
|
authLabel = auth.Label |
|
|
authType, authValue = auth.AccountInfo() |
|
|
} |
|
|
|
|
|
var lastStatus int |
|
|
var lastBody []byte |
|
|
|
|
|
|
|
|
|
|
|
for _, attemptModel := range models { |
|
|
payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false) |
|
|
payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model) |
|
|
payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload) |
|
|
payload = deleteJSONField(payload, "project") |
|
|
payload = deleteJSONField(payload, "model") |
|
|
payload = deleteJSONField(payload, "request.safetySettings") |
|
|
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload) |
|
|
payload = fixGeminiCLIImageAspectRatio(req.Model, payload) |
|
|
|
|
|
tok, errTok := tokenSource.Token() |
|
|
if errTok != nil { |
|
|
return cliproxyexecutor.Response{}, errTok |
|
|
} |
|
|
updateGeminiCLITokenMetadata(auth, baseTokenData, tok) |
|
|
|
|
|
url := fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, codeAssistVersion, "countTokens") |
|
|
if opts.Alt != "" { |
|
|
url = url + fmt.Sprintf("?$alt=%s", opts.Alt) |
|
|
} |
|
|
|
|
|
reqHTTP, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) |
|
|
if errReq != nil { |
|
|
return cliproxyexecutor.Response{}, errReq |
|
|
} |
|
|
reqHTTP.Header.Set("Content-Type", "application/json") |
|
|
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken) |
|
|
applyGeminiCLIHeaders(reqHTTP) |
|
|
reqHTTP.Header.Set("Accept", "application/json") |
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ |
|
|
URL: url, |
|
|
Method: http.MethodPost, |
|
|
Headers: reqHTTP.Header.Clone(), |
|
|
Body: payload, |
|
|
Provider: e.Identifier(), |
|
|
AuthID: authID, |
|
|
AuthLabel: authLabel, |
|
|
AuthType: authType, |
|
|
AuthValue: authValue, |
|
|
}) |
|
|
|
|
|
resp, errDo := httpClient.Do(reqHTTP) |
|
|
if errDo != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errDo) |
|
|
return cliproxyexecutor.Response{}, errDo |
|
|
} |
|
|
data, errRead := io.ReadAll(resp.Body) |
|
|
_ = resp.Body.Close() |
|
|
recordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone()) |
|
|
if errRead != nil { |
|
|
recordAPIResponseError(ctx, e.cfg, errRead) |
|
|
return cliproxyexecutor.Response{}, errRead |
|
|
} |
|
|
appendAPIResponseChunk(ctx, e.cfg, data) |
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
|
|
count := gjson.GetBytes(data, "totalTokens").Int() |
|
|
translated := sdktranslator.TranslateTokenCount(respCtx, to, from, count, data) |
|
|
return cliproxyexecutor.Response{Payload: []byte(translated)}, nil |
|
|
} |
|
|
lastStatus = resp.StatusCode |
|
|
lastBody = append([]byte(nil), data...) |
|
|
if resp.StatusCode == 429 { |
|
|
log.Debugf("gemini cli executor: rate limited, retrying with next model") |
|
|
continue |
|
|
} |
|
|
break |
|
|
} |
|
|
|
|
|
if lastStatus == 0 { |
|
|
lastStatus = 429 |
|
|
} |
|
|
return cliproxyexecutor.Response{}, newGeminiStatusErr(lastStatus, lastBody) |
|
|
} |
|
|
|
|
|
|
|
|
func (e *GeminiCLIExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { |
|
|
return auth, nil |
|
|
} |
|
|
|
|
|
func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) { |
|
|
metadata := geminiOAuthMetadata(auth) |
|
|
if auth == nil || metadata == nil { |
|
|
return nil, nil, fmt.Errorf("gemini-cli auth metadata missing") |
|
|
} |
|
|
|
|
|
var base map[string]any |
|
|
if tokenRaw, ok := metadata["token"].(map[string]any); ok && tokenRaw != nil { |
|
|
base = cloneMap(tokenRaw) |
|
|
} else { |
|
|
base = make(map[string]any) |
|
|
} |
|
|
|
|
|
var token oauth2.Token |
|
|
if len(base) > 0 { |
|
|
if raw, err := json.Marshal(base); err == nil { |
|
|
_ = json.Unmarshal(raw, &token) |
|
|
} |
|
|
} |
|
|
|
|
|
if token.AccessToken == "" { |
|
|
token.AccessToken = stringValue(metadata, "access_token") |
|
|
} |
|
|
if token.RefreshToken == "" { |
|
|
token.RefreshToken = stringValue(metadata, "refresh_token") |
|
|
} |
|
|
if token.TokenType == "" { |
|
|
token.TokenType = stringValue(metadata, "token_type") |
|
|
} |
|
|
if token.Expiry.IsZero() { |
|
|
if expiry := stringValue(metadata, "expiry"); expiry != "" { |
|
|
if ts, err := time.Parse(time.RFC3339, expiry); err == nil { |
|
|
token.Expiry = ts |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
conf := &oauth2.Config{ |
|
|
ClientID: geminiOAuthClientID, |
|
|
ClientSecret: geminiOAuthClientSecret, |
|
|
Scopes: geminiOAuthScopes, |
|
|
Endpoint: google.Endpoint, |
|
|
} |
|
|
|
|
|
ctxToken := ctx |
|
|
if httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0); httpClient != nil { |
|
|
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient) |
|
|
} |
|
|
|
|
|
src := conf.TokenSource(ctxToken, &token) |
|
|
currentToken, err := src.Token() |
|
|
if err != nil { |
|
|
return nil, nil, err |
|
|
} |
|
|
updateGeminiCLITokenMetadata(auth, base, currentToken) |
|
|
return oauth2.ReuseTokenSource(currentToken, src), base, nil |
|
|
} |
|
|
|
|
|
func updateGeminiCLITokenMetadata(auth *cliproxyauth.Auth, base map[string]any, tok *oauth2.Token) { |
|
|
if auth == nil || tok == nil { |
|
|
return |
|
|
} |
|
|
merged := buildGeminiTokenMap(base, tok) |
|
|
fields := buildGeminiTokenFields(tok, merged) |
|
|
shared := geminicli.ResolveSharedCredential(auth.Runtime) |
|
|
if shared != nil { |
|
|
snapshot := shared.MergeMetadata(fields) |
|
|
if !geminicli.IsVirtual(auth.Runtime) { |
|
|
auth.Metadata = snapshot |
|
|
} |
|
|
return |
|
|
} |
|
|
if auth.Metadata == nil { |
|
|
auth.Metadata = make(map[string]any) |
|
|
} |
|
|
for k, v := range fields { |
|
|
auth.Metadata[k] = v |
|
|
} |
|
|
} |
|
|
|
|
|
func buildGeminiTokenMap(base map[string]any, tok *oauth2.Token) map[string]any { |
|
|
merged := cloneMap(base) |
|
|
if merged == nil { |
|
|
merged = make(map[string]any) |
|
|
} |
|
|
if raw, err := json.Marshal(tok); err == nil { |
|
|
var tokenMap map[string]any |
|
|
if err = json.Unmarshal(raw, &tokenMap); err == nil { |
|
|
for k, v := range tokenMap { |
|
|
merged[k] = v |
|
|
} |
|
|
} |
|
|
} |
|
|
return merged |
|
|
} |
|
|
|
|
|
func buildGeminiTokenFields(tok *oauth2.Token, merged map[string]any) map[string]any { |
|
|
fields := make(map[string]any, 5) |
|
|
if tok.AccessToken != "" { |
|
|
fields["access_token"] = tok.AccessToken |
|
|
} |
|
|
if tok.TokenType != "" { |
|
|
fields["token_type"] = tok.TokenType |
|
|
} |
|
|
if tok.RefreshToken != "" { |
|
|
fields["refresh_token"] = tok.RefreshToken |
|
|
} |
|
|
if !tok.Expiry.IsZero() { |
|
|
fields["expiry"] = tok.Expiry.Format(time.RFC3339) |
|
|
} |
|
|
if len(merged) > 0 { |
|
|
fields["token"] = cloneMap(merged) |
|
|
} |
|
|
return fields |
|
|
} |
|
|
|
|
|
func resolveGeminiProjectID(auth *cliproxyauth.Auth) string { |
|
|
if auth == nil { |
|
|
return "" |
|
|
} |
|
|
if runtime := auth.Runtime; runtime != nil { |
|
|
if virtual, ok := runtime.(*geminicli.VirtualCredential); ok && virtual != nil { |
|
|
return strings.TrimSpace(virtual.ProjectID) |
|
|
} |
|
|
} |
|
|
return strings.TrimSpace(stringValue(auth.Metadata, "project_id")) |
|
|
} |
|
|
|
|
|
func geminiOAuthMetadata(auth *cliproxyauth.Auth) map[string]any { |
|
|
if auth == nil { |
|
|
return nil |
|
|
} |
|
|
if shared := geminicli.ResolveSharedCredential(auth.Runtime); shared != nil { |
|
|
if snapshot := shared.MetadataSnapshot(); len(snapshot) > 0 { |
|
|
return snapshot |
|
|
} |
|
|
} |
|
|
return auth.Metadata |
|
|
} |
|
|
|
|
|
func newHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client { |
|
|
return newProxyAwareHTTPClient(ctx, cfg, auth, timeout) |
|
|
} |
|
|
|
|
|
func cloneMap(in map[string]any) map[string]any { |
|
|
if in == nil { |
|
|
return nil |
|
|
} |
|
|
out := make(map[string]any, len(in)) |
|
|
for k, v := range in { |
|
|
out[k] = v |
|
|
} |
|
|
return out |
|
|
} |
|
|
|
|
|
func stringValue(m map[string]any, key string) string { |
|
|
if m == nil { |
|
|
return "" |
|
|
} |
|
|
if v, ok := m[key]; ok { |
|
|
switch typed := v.(type) { |
|
|
case string: |
|
|
return typed |
|
|
case fmt.Stringer: |
|
|
return typed.String() |
|
|
} |
|
|
} |
|
|
return "" |
|
|
} |
|
|
|
|
|
|
|
|
func applyGeminiCLIHeaders(r *http.Request) { |
|
|
var ginHeaders http.Header |
|
|
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { |
|
|
ginHeaders = ginCtx.Request.Header |
|
|
} |
|
|
|
|
|
misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", "google-api-nodejs-client/9.15.1") |
|
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Goog-Api-Client", "gl-node/22.17.0") |
|
|
misc.EnsureHeader(r.Header, ginHeaders, "Client-Metadata", geminiCLIClientMetadata()) |
|
|
} |
|
|
|
|
|
|
|
|
func geminiCLIClientMetadata() string { |
|
|
|
|
|
return "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI" |
|
|
} |
|
|
|
|
|
|
|
|
func cliPreviewFallbackOrder(model string) []string { |
|
|
switch model { |
|
|
case "gemini-2.5-pro": |
|
|
return []string{ |
|
|
|
|
|
|
|
|
} |
|
|
case "gemini-2.5-flash": |
|
|
return []string{ |
|
|
|
|
|
|
|
|
} |
|
|
case "gemini-2.5-flash-lite": |
|
|
return []string{ |
|
|
|
|
|
} |
|
|
default: |
|
|
return nil |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
func setJSONField(body []byte, key, value string) []byte { |
|
|
if key == "" { |
|
|
return body |
|
|
} |
|
|
updated, err := sjson.SetBytes(body, key, value) |
|
|
if err != nil { |
|
|
return body |
|
|
} |
|
|
return updated |
|
|
} |
|
|
|
|
|
|
|
|
func deleteJSONField(body []byte, key string) []byte { |
|
|
if key == "" || len(body) == 0 { |
|
|
return body |
|
|
} |
|
|
updated, err := sjson.DeleteBytes(body, key) |
|
|
if err != nil { |
|
|
return body |
|
|
} |
|
|
return updated |
|
|
} |
|
|
|
|
|
func fixGeminiCLIImageAspectRatio(modelName string, rawJSON []byte) []byte { |
|
|
if modelName == "gemini-2.5-flash-image-preview" { |
|
|
aspectRatioResult := gjson.GetBytes(rawJSON, "request.generationConfig.imageConfig.aspectRatio") |
|
|
if aspectRatioResult.Exists() { |
|
|
contents := gjson.GetBytes(rawJSON, "request.contents") |
|
|
contentArray := contents.Array() |
|
|
if len(contentArray) > 0 { |
|
|
hasInlineData := false |
|
|
loopContent: |
|
|
for i := 0; i < len(contentArray); i++ { |
|
|
parts := contentArray[i].Get("parts").Array() |
|
|
for j := 0; j < len(parts); j++ { |
|
|
if parts[j].Get("inlineData").Exists() { |
|
|
hasInlineData = true |
|
|
break loopContent |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if !hasInlineData { |
|
|
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String()) |
|
|
emptyImagePart := `{"inlineData":{"mime_type":"image/png","data":""}}` |
|
|
emptyImagePart, _ = sjson.Set(emptyImagePart, "inlineData.data", emptyImageBase64ed) |
|
|
newPartsJson := `[]` |
|
|
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", `{"text": "Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear."}`) |
|
|
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", emptyImagePart) |
|
|
|
|
|
parts := contentArray[0].Get("parts").Array() |
|
|
for j := 0; j < len(parts); j++ { |
|
|
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", parts[j].Raw) |
|
|
} |
|
|
|
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents.0.parts", []byte(newPartsJson)) |
|
|
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.generationConfig.responseModalities", []byte(`["IMAGE", "TEXT"]`)) |
|
|
} |
|
|
} |
|
|
rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.generationConfig.imageConfig") |
|
|
} |
|
|
} |
|
|
return rawJSON |
|
|
} |
|
|
|
|
|
func newGeminiStatusErr(statusCode int, body []byte) statusErr { |
|
|
err := statusErr{code: statusCode, msg: string(body)} |
|
|
if statusCode == http.StatusTooManyRequests { |
|
|
if retryAfter, parseErr := parseRetryDelay(body); parseErr == nil && retryAfter != nil { |
|
|
err.retryAfter = retryAfter |
|
|
} |
|
|
} |
|
|
return err |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func parseRetryDelay(errorBody []byte) (*time.Duration, error) { |
|
|
|
|
|
|
|
|
details := gjson.GetBytes(errorBody, "error.details") |
|
|
if details.Exists() && details.IsArray() { |
|
|
for _, detail := range details.Array() { |
|
|
typeVal := detail.Get("@type").String() |
|
|
if typeVal == "type.googleapis.com/google.rpc.RetryInfo" { |
|
|
retryDelay := detail.Get("retryDelay").String() |
|
|
if retryDelay != "" { |
|
|
|
|
|
duration, err := time.ParseDuration(retryDelay) |
|
|
if err != nil { |
|
|
return nil, fmt.Errorf("failed to parse duration") |
|
|
} |
|
|
return &duration, nil |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
for _, detail := range details.Array() { |
|
|
typeVal := detail.Get("@type").String() |
|
|
if typeVal == "type.googleapis.com/google.rpc.ErrorInfo" { |
|
|
quotaResetDelay := detail.Get("metadata.quotaResetDelay").String() |
|
|
if quotaResetDelay != "" { |
|
|
duration, err := time.ParseDuration(quotaResetDelay) |
|
|
if err == nil { |
|
|
return &duration, nil |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
message := gjson.GetBytes(errorBody, "error.message").String() |
|
|
if message != "" { |
|
|
re := regexp.MustCompile(`after\s+(\d+)s\.?`) |
|
|
if matches := re.FindStringSubmatch(message); len(matches) > 1 { |
|
|
seconds, err := strconv.Atoi(matches[1]) |
|
|
if err == nil { |
|
|
duration := time.Duration(seconds) * time.Second |
|
|
return &duration, nil |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return nil, fmt.Errorf("no RetryInfo found") |
|
|
} |
|
|
|