v6 version first commit

This commit is contained in:
Luis Pater
2025-09-22 01:40:24 +08:00
parent d42384cdb7
commit 4999fce7f4
171 changed files with 7626 additions and 7494 deletions

View File

@@ -0,0 +1,153 @@
package executor
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
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"
"github.com/tidwall/sjson"
)
// ClaudeExecutor is a stateless executor for Anthropic Claude over the messages API.
// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
type ClaudeExecutor struct{}
func NewClaudeExecutor() *ClaudeExecutor { return &ClaudeExecutor{} }
func (e *ClaudeExecutor) Identifier() string { return "claude" }
func (e *ClaudeExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
apiKey, baseURL := claudeCreds(auth)
if apiKey == "" {
return NewClientAdapter("claude").Execute(ctx, auth, req, opts)
}
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
from := opts.SourceFormat
to := sdktranslator.FromString("claude")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
if !strings.HasPrefix(req.Model, "claude-3-5-haiku") {
body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions))
}
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return cliproxyexecutor.Response{}, err
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Anthropic-Version", "2023-06-01")
httpClient := &http.Client{}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return cliproxyexecutor.Response{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return cliproxyexecutor.Response{}, err
}
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
}
func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
apiKey, baseURL := claudeCreds(auth)
if apiKey == "" {
return NewClientAdapter("claude").ExecuteStream(ctx, auth, req, opts)
}
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
from := opts.SourceFormat
to := sdktranslator.FromString("claude")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions))
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Anthropic-Version", "2023-06-01")
httpReq.Header.Set("Accept", "text/event-stream")
httpClient := &http.Client{Timeout: 0}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return nil, statusErr{code: resp.StatusCode, msg: string(b)}
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() { _ = resp.Body.Close() }()
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
var param any
for scanner.Scan() {
line := scanner.Bytes()
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
}
if err = scanner.Err(); err != nil {
out <- cliproxyexecutor.StreamChunk{Err: err}
}
}()
return out, nil
}
func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
_ = ctx
return auth, nil
}
func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
if a == nil {
return "", ""
}
if a.Attributes != nil {
apiKey = a.Attributes["api_key"]
baseURL = a.Attributes["base_url"]
}
if apiKey == "" && a.Metadata != nil {
if v, ok := a.Metadata["access_token"].(string); ok {
apiKey = v
}
}
return
}

View File

@@ -0,0 +1,181 @@
package executor
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
// ClientAdapter bridges legacy stateful clients to the new ProviderExecutor contract.
type ClientAdapter struct {
provider string
}
// NewClientAdapter creates a new adapter for the specified provider key.
func NewClientAdapter(provider string) *ClientAdapter {
return &ClientAdapter{provider: provider}
}
// Identifier implements cliproxyauth.ProviderExecutor.
func (a *ClientAdapter) Identifier() string {
return a.provider
}
// PrepareRequest implements optional request preparation hook (no-op for legacy clients).
func (a *ClientAdapter) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { return nil }
// Execute implements cliproxyauth.ProviderExecutor.
func (a *ClientAdapter) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
client, mutex, err := resolveLegacyClient(auth)
if err != nil {
return cliproxyexecutor.Response{}, err
}
unlock := lock(mutex)
defer unlock()
// Support special actions via request metadata (e.g., countTokens)
if req.Metadata != nil {
if action, _ := req.Metadata["action"].(string); action == "countTokens" {
if tc, ok := any(client).(interface {
SendRawTokenCount(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage)
}); ok {
payload, errMsg := tc.SendRawTokenCount(ctx, req.Model, req.Payload, opts.Alt)
if errMsg != nil {
return cliproxyexecutor.Response{}, errorFromMessage(errMsg)
}
return cliproxyexecutor.Response{Payload: payload}, nil
}
return cliproxyexecutor.Response{}, fmt.Errorf("legacy client does not support countTokens")
}
}
payload, errMsg := client.SendRawMessage(ctx, req.Model, req.Payload, opts.Alt)
if errMsg != nil {
return cliproxyexecutor.Response{}, errorFromMessage(errMsg)
}
return cliproxyexecutor.Response{Payload: payload}, nil
}
// ExecuteStream implements cliproxyauth.ProviderExecutor.
func (a *ClientAdapter) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
client, mutex, err := resolveLegacyClient(auth)
if err != nil {
return nil, err
}
unlock := lock(mutex)
dataCh, errCh := client.SendRawMessageStream(ctx, req.Model, req.Payload, opts.Alt)
if dataCh == nil {
unlock()
if errCh != nil {
if msg := <-errCh; msg != nil {
return nil, errorFromMessage(msg)
}
}
return nil, errors.New("stream not available")
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer unlock()
for chunk := range dataCh {
if chunk == nil {
continue
}
out <- cliproxyexecutor.StreamChunk{Payload: chunk}
}
if errCh != nil {
if msg, ok := <-errCh; ok && msg != nil {
out <- cliproxyexecutor.StreamChunk{Err: errorFromMessage(msg)}
}
}
}()
return out, nil
}
// Refresh delegates to the legacy client's refresh logic when available.
func (a *ClientAdapter) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
client, _, err := resolveLegacyClient(auth)
if err != nil {
return nil, err
}
if refresher, ok := client.(interface{ RefreshTokens(context.Context) error }); ok {
if errRefresh := refresher.RefreshTokens(ctx); errRefresh != nil {
return nil, errRefresh
}
}
return auth, nil
}
// legacyClient defines the minimum surface required from the historical clients.
type legacyClient interface {
SendRawMessage(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage)
SendRawMessageStream(ctx context.Context, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage)
GetRequestMutex() *sync.Mutex
}
func resolveLegacyClient(auth *cliproxyauth.Auth) (legacyClient, *sync.Mutex, error) {
if auth == nil {
return nil, nil, fmt.Errorf("legacy adapter: auth is nil")
}
client, ok := auth.Runtime.(legacyClient)
if !ok || client == nil {
return nil, nil, fmt.Errorf("legacy adapter: runtime client missing for %s", auth.ID)
}
return client, client.GetRequestMutex(), nil
}
func lock(mutex *sync.Mutex) func() {
if mutex == nil {
return func() {}
}
mutex.Lock()
return func() {
mutex.Unlock()
}
}
func errorFromMessage(msg *interfaces.ErrorMessage) error {
if msg == nil {
return nil
}
return legacyError{message: msg}
}
type legacyError struct {
message *interfaces.ErrorMessage
}
func (e legacyError) Error() string {
if e.message == nil {
return "legacy client error"
}
if e.message.Error != nil {
return e.message.Error.Error()
}
return fmt.Sprintf("legacy client error: status %d", e.message.StatusCode)
}
// StatusCode implements executor.StatusError, exposing HTTP-like status.
func (e legacyError) StatusCode() int {
if e.message != nil {
return e.message.StatusCode
}
return 0
}
// UnwrapError extracts the legacy interfaces.ErrorMessage from adapter errors.
func UnwrapError(err error) (*interfaces.ErrorMessage, bool) {
var legacy legacyError
if errors.As(err, &legacy) {
return legacy.message, true
}
return nil, false
}

View File

@@ -0,0 +1,199 @@
package executor
import (
"bufio"
"bytes"
"context"
"io"
"net/http"
"strings"
"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"
"github.com/tidwall/sjson"
)
// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).
// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
type CodexExecutor struct{}
func NewCodexExecutor() *CodexExecutor { return &CodexExecutor{} }
func (e *CodexExecutor) Identifier() string { return "codex" }
func (e *CodexExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
apiKey, baseURL := codexCreds(auth)
if apiKey == "" {
return NewClientAdapter("codex").Execute(ctx, auth, req, opts)
}
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
from := opts.SourceFormat
to := sdktranslator.FromString("codex")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
if util.InArray([]string{"gpt-5-minimal", "gpt-5-low", "gpt-5-medium", "gpt-5-high"}, req.Model) {
body, _ = sjson.SetBytes(body, "model", "gpt-5")
switch req.Model {
case "gpt-5-minimal":
body, _ = sjson.SetBytes(body, "reasoning.effort", "minimal")
case "gpt-5-low":
body, _ = sjson.SetBytes(body, "reasoning.effort", "low")
case "gpt-5-medium":
body, _ = sjson.SetBytes(body, "reasoning.effort", "medium")
case "gpt-5-high":
body, _ = sjson.SetBytes(body, "reasoning.effort", "high")
}
} else if util.InArray([]string{"gpt-5-codex", "gpt-5-codex-low", "gpt-5-codex-medium", "gpt-5-codex-high"}, req.Model) {
body, _ = sjson.SetBytes(body, "model", "gpt-5-codex")
switch req.Model {
case "gpt-5-codex":
body, _ = sjson.SetBytes(body, "reasoning.effort", "medium")
case "gpt-5-codex-low":
body, _ = sjson.SetBytes(body, "reasoning.effort", "low")
case "gpt-5-codex-medium":
body, _ = sjson.SetBytes(body, "reasoning.effort", "medium")
case "gpt-5-codex-high":
body, _ = sjson.SetBytes(body, "reasoning.effort", "high")
}
}
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return cliproxyexecutor.Response{}, err
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
httpClient := &http.Client{}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return cliproxyexecutor.Response{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return cliproxyexecutor.Response{}, err
}
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
}
func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
apiKey, baseURL := codexCreds(auth)
if apiKey == "" {
return NewClientAdapter("codex").ExecuteStream(ctx, auth, req, opts)
}
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
from := opts.SourceFormat
to := sdktranslator.FromString("codex")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
if util.InArray([]string{"gpt-5-minimal", "gpt-5-low", "gpt-5-medium", "gpt-5-high"}, req.Model) {
body, _ = sjson.SetBytes(body, "model", "gpt-5")
switch req.Model {
case "gpt-5-minimal":
body, _ = sjson.SetBytes(body, "reasoning.effort", "minimal")
case "gpt-5-low":
body, _ = sjson.SetBytes(body, "reasoning.effort", "low")
case "gpt-5-medium":
body, _ = sjson.SetBytes(body, "reasoning.effort", "medium")
case "gpt-5-high":
body, _ = sjson.SetBytes(body, "reasoning.effort", "high")
}
} else if util.InArray([]string{"gpt-5-codex", "gpt-5-codex-low", "gpt-5-codex-medium", "gpt-5-codex-high"}, req.Model) {
body, _ = sjson.SetBytes(body, "model", "gpt-5-codex")
switch req.Model {
case "gpt-5-codex":
body, _ = sjson.SetBytes(body, "reasoning.effort", "medium")
case "gpt-5-codex-low":
body, _ = sjson.SetBytes(body, "reasoning.effort", "low")
case "gpt-5-codex-medium":
body, _ = sjson.SetBytes(body, "reasoning.effort", "medium")
case "gpt-5-codex-high":
body, _ = sjson.SetBytes(body, "reasoning.effort", "high")
}
}
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpClient := &http.Client{Timeout: 0}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return nil, statusErr{code: resp.StatusCode, msg: string(b)}
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() { _ = resp.Body.Close() }()
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
var param any
for scanner.Scan() {
line := scanner.Bytes()
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
}
if err = scanner.Err(); err != nil {
out <- cliproxyexecutor.StreamChunk{Err: err}
}
}()
return out, nil
}
func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
_ = ctx
return auth, nil
}
func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
if a == nil {
return "", ""
}
if a.Attributes != nil {
apiKey = a.Attributes["api_key"]
baseURL = a.Attributes["base_url"]
}
if apiKey == "" && a.Metadata != nil {
if v, ok := a.Metadata["access_token"].(string); ok {
apiKey = v
}
}
return
}

View File

@@ -0,0 +1,424 @@
package executor
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
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"
"github.com/tidwall/sjson"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const (
codeAssistEndpoint = "https://cloudcode-pa.googleapis.com"
codeAssistVersion = "v1internal"
geminiOauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
geminiOauthClientSecret = "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"
)
var geminiOauthScopes = []string{
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
}
// GeminiCLIExecutor talks to the Cloud Code Assist endpoint using OAuth credentials from auth metadata.
type GeminiCLIExecutor struct{}
func NewGeminiCLIExecutor() *GeminiCLIExecutor { return &GeminiCLIExecutor{} }
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) (cliproxyexecutor.Response, error) {
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
if err != nil {
return cliproxyexecutor.Response{}, err
}
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-cli")
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
action := "generateContent"
if req.Metadata != nil {
if a, _ := req.Metadata["action"].(string); a == "countTokens" {
action = "countTokens"
}
}
projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id"))
models := cliPreviewFallbackOrder(req.Model)
if len(models) == 0 || models[0] != req.Model {
models = append([]string{req.Model}, models...)
}
httpClient := newHTTPClient(ctx, 0)
respCtx := context.WithValue(ctx, "alt", opts.Alt)
var lastStatus int
var lastBody []byte
for _, 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 {
return cliproxyexecutor.Response{}, errTok
}
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 {
return cliproxyexecutor.Response{}, errReq
}
reqHTTP.Header.Set("Content-Type", "application/json")
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
applyGeminiCLIHeaders(reqHTTP)
resp, errDo := httpClient.Do(reqHTTP)
if errDo != nil {
return cliproxyexecutor.Response{}, errDo
}
data, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
var param any
out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, &param)
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
}
lastStatus = resp.StatusCode
lastBody = data
if resp.StatusCode != 429 {
break
}
}
return cliproxyexecutor.Response{}, statusErr{code: lastStatus, msg: string(lastBody)}
}
func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
if err != nil {
return nil, err
}
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-cli")
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id"))
models := cliPreviewFallbackOrder(req.Model)
if len(models) == 0 || models[0] != req.Model {
models = append([]string{req.Model}, models...)
}
httpClient := newHTTPClient(ctx, 0)
respCtx := context.WithValue(ctx, "alt", opts.Alt)
dataTag := []byte("data:")
var lastStatus int
var lastBody []byte
for _, 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 {
return nil, errTok
}
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 {
return nil, errReq
}
reqHTTP.Header.Set("Content-Type", "application/json")
reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken)
applyGeminiCLIHeaders(reqHTTP)
resp, errDo := httpClient.Do(reqHTTP)
if errDo != nil {
return nil, errDo
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
lastStatus = resp.StatusCode
lastBody = data
if resp.StatusCode == 429 {
continue
}
return nil, statusErr{code: resp.StatusCode, msg: string(data)}
}
out := make(chan cliproxyexecutor.StreamChunk)
go func(resp *http.Response, reqBody []byte, attempt string) {
defer close(out)
defer func() { _ = resp.Body.Close() }()
if opts.Alt == "" {
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
var param any
for scanner.Scan() {
line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) {
segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
}
}
segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone([]byte("[DONE]")), &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
if errScan := scanner.Err(); errScan != nil {
out <- cliproxyexecutor.StreamChunk{Err: errScan}
}
return
}
data, errRead := io.ReadAll(resp.Body)
if errRead != nil {
out <- cliproxyexecutor.StreamChunk{Err: errRead}
return
}
var param any
segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, data, &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
segments = sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone([]byte("[DONE]")), &param)
for i := range segments {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(segments[i])}
}
}(resp, append([]byte(nil), payload...), attemptModel)
return out, nil
}
if lastStatus == 0 {
lastStatus = 429
}
return nil, statusErr{code: lastStatus, msg: string(lastBody)}
}
func (e *GeminiCLIExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
_ = ctx
return auth, nil
}
func prepareGeminiCLITokenSource(ctx context.Context, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) {
if auth == nil || auth.Metadata == nil {
return nil, nil, fmt.Errorf("gemini-cli auth metadata missing")
}
var base map[string]any
if tokenRaw, ok := auth.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(auth.Metadata, "access_token")
}
if token.RefreshToken == "" {
token.RefreshToken = stringValue(auth.Metadata, "refresh_token")
}
if token.TokenType == "" {
token.TokenType = stringValue(auth.Metadata, "token_type")
}
if token.Expiry.IsZero() {
if expiry := stringValue(auth.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 rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, &http.Client{Transport: rt})
}
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 || auth.Metadata == nil || tok == nil {
return
}
if tok.AccessToken != "" {
auth.Metadata["access_token"] = tok.AccessToken
}
if tok.TokenType != "" {
auth.Metadata["token_type"] = tok.TokenType
}
if tok.RefreshToken != "" {
auth.Metadata["refresh_token"] = tok.RefreshToken
}
if !tok.Expiry.IsZero() {
auth.Metadata["expiry"] = tok.Expiry.Format(time.RFC3339)
}
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
}
}
}
auth.Metadata["token"] = merged
}
func newHTTPClient(ctx context.Context, timeout time.Duration) *http.Client {
client := &http.Client{}
if timeout > 0 {
client.Timeout = timeout
}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
client.Transport = rt
}
return client
}
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 ""
}
// applyGeminiCLIHeaders sets required headers for the Gemini CLI upstream.
func applyGeminiCLIHeaders(r *http.Request) {
r.Header.Set("User-Agent", "google-api-nodejs-client/9.15.1")
r.Header.Set("X-Goog-Api-Client", "gl-node/22.17.0")
r.Header.Set("Client-Metadata", geminiCLIClientMetadata())
}
// geminiCLIClientMetadata returns a compact metadata string required by upstream.
func geminiCLIClientMetadata() string {
// Keep parity with CLI client defaults
return "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
}
// cliPreviewFallbackOrder returns preview model candidates for a base model.
func cliPreviewFallbackOrder(model string) []string {
switch model {
case "gemini-2.5-pro":
return []string{"gemini-2.5-pro-preview-05-06", "gemini-2.5-pro-preview-06-05"}
case "gemini-2.5-flash":
return []string{"gemini-2.5-flash-preview-04-17", "gemini-2.5-flash-preview-05-20"}
case "gemini-2.5-flash-lite":
return []string{"gemini-2.5-flash-lite-preview-06-17"}
default:
return nil
}
}
// setJSONField sets a top-level JSON field on a byte slice payload via sjson.
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
}
// deleteJSONField removes a top-level key if present (best-effort) via sjson.
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
}

View File

@@ -0,0 +1,181 @@
package executor
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
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"
)
const (
glEndpoint = "https://generativelanguage.googleapis.com"
glAPIVersion = "v1beta"
)
// GeminiExecutor is a stateless executor for the official Gemini API using API keys.
// If no API key is found on the auth entry, it falls back to the legacy client via ClientAdapter.
type GeminiExecutor struct{}
func NewGeminiExecutor() *GeminiExecutor { return &GeminiExecutor{} }
func (e *GeminiExecutor) Identifier() string { return "gemini" }
func (e *GeminiExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
apiKey, bearer := geminiCreds(auth)
if apiKey == "" && bearer == "" {
// Fallback to legacy client
return NewClientAdapter("gemini").Execute(ctx, auth, req, opts)
}
// Official Gemini API via API key or OAuth bearer
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
action := "generateContent"
if req.Metadata != nil {
if a, _ := req.Metadata["action"].(string); a == "countTokens" {
action = "countTokens"
}
}
url := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, req.Model, action)
if opts.Alt != "" && action != "countTokens" {
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return cliproxyexecutor.Response{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
if apiKey != "" {
httpReq.Header.Set("x-goog-api-key", apiKey)
} else if bearer != "" {
httpReq.Header.Set("Authorization", "Bearer "+bearer)
}
httpClient := &http.Client{}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return cliproxyexecutor.Response{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return cliproxyexecutor.Response{}, err
}
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
}
func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
apiKey, bearer := geminiCreds(auth)
if apiKey == "" && bearer == "" {
// Fallback to legacy streaming
return NewClientAdapter("gemini").ExecuteStream(ctx, auth, req, opts)
}
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
url := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, req.Model, "streamGenerateContent")
if opts.Alt == "" {
url = url + "?alt=sse"
} else {
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
if apiKey != "" {
httpReq.Header.Set("x-goog-api-key", apiKey)
} else {
httpReq.Header.Set("Authorization", "Bearer "+bearer)
}
httpClient := &http.Client{Timeout: 0}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return nil, statusErr{code: resp.StatusCode, msg: string(b)}
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() { _ = resp.Body.Close() }()
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
var param any
for scanner.Scan() {
line := scanner.Bytes()
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone([]byte("[DONE]")), &param)
for i := range lines {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
}
if err = scanner.Err(); err != nil {
out <- cliproxyexecutor.StreamChunk{Err: err}
}
}()
return out, nil
}
func (e *GeminiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
// API-key based: no-op; cookie-based handled by legacy fallback when used.
_ = ctx
return auth, nil
}
func geminiCreds(a *cliproxyauth.Auth) (apiKey, bearer string) {
if a == nil {
return "", ""
}
if a.Attributes != nil {
if v := a.Attributes["api_key"]; v != "" {
apiKey = v
}
}
if a.Metadata != nil {
// GeminiTokenStorage.Token is a map that may contain access_token
if v, ok := a.Metadata["access_token"].(string); ok && v != "" {
bearer = v
}
if token, ok := a.Metadata["token"].(map[string]any); ok && token != nil {
if v, ok2 := token["access_token"].(string); ok2 && v != "" {
bearer = v
}
}
}
return
}

View File

@@ -0,0 +1,220 @@
package executor
import (
"bytes"
"context"
"fmt"
"net/http"
"sync"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
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"
)
type GeminiWebExecutor struct {
cfg *config.Config
mu sync.Mutex
}
func NewGeminiWebExecutor(cfg *config.Config) *GeminiWebExecutor {
return &GeminiWebExecutor{cfg: cfg}
}
func (e *GeminiWebExecutor) Identifier() string { return "gemini-web" }
func (e *GeminiWebExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
state, err := e.stateFor(auth)
if err != nil {
return cliproxyexecutor.Response{}, err
}
if err = state.ensureClient(); err != nil {
return cliproxyexecutor.Response{}, err
}
mutex := state.getRequestMutex()
if mutex != nil {
mutex.Lock()
defer mutex.Unlock()
}
payload := bytes.Clone(req.Payload)
resp, errMsg, prep := state.send(ctx, req.Model, payload, opts)
if errMsg != nil {
return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)
}
resp = state.convertToTarget(ctx, req.Model, prep, resp)
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-web")
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), payload, bytes.Clone(resp), &param)
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
}
func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
state, err := e.stateFor(auth)
if err != nil {
return nil, err
}
if err = state.ensureClient(); err != nil {
return nil, err
}
mutex := state.getRequestMutex()
if mutex != nil {
mutex.Lock()
}
gemBytes, errMsg, prep := state.send(ctx, req.Model, bytes.Clone(req.Payload), opts)
if errMsg != nil {
if mutex != nil {
mutex.Unlock()
}
return nil, geminiWebErrorFromMessage(errMsg)
}
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-web")
var param any
lines := state.convertStream(ctx, req.Model, prep, gemBytes)
done := state.doneStream(ctx, req.Model, prep)
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
if mutex != nil {
defer mutex.Unlock()
}
for _, line := range lines {
line = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), req.Payload, bytes.Clone([]byte(line)), &param)
out <- cliproxyexecutor.StreamChunk{Payload: []byte(line)}
}
for _, line := range done {
line = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), req.Payload, bytes.Clone([]byte(line)), &param)
out <- cliproxyexecutor.StreamChunk{Payload: []byte(line)}
}
}()
return out, nil
}
func (e *GeminiWebExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
state, err := e.stateFor(auth)
if err != nil {
return nil, err
}
if err = state.refresh(ctx); err != nil {
return nil, err
}
ts := state.tokenSnapshot()
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["secure_1psid"] = ts.Secure1PSID
auth.Metadata["secure_1psidts"] = ts.Secure1PSIDTS
auth.Metadata["type"] = "gemini-web"
return auth, nil
}
type geminiWebRuntime struct {
state *geminiWebState
}
func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiWebState, error) {
if auth == nil {
return nil, fmt.Errorf("gemini-web executor: auth is nil")
}
if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil {
return runtime.state, nil
}
e.mu.Lock()
defer e.mu.Unlock()
if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil {
return runtime.state, nil
}
ts, err := parseGeminiWebToken(auth)
if err != nil {
return nil, err
}
cfg := e.cfg
if auth.ProxyURL != "" && cfg != nil {
copyCfg := *cfg
copyCfg.ProxyURL = auth.ProxyURL
cfg = &copyCfg
}
storagePath := ""
if auth.Attributes != nil {
if p, ok := auth.Attributes["path"]; ok {
storagePath = p
}
}
state := newGeminiWebState(cfg, ts, storagePath)
runtime := &geminiWebRuntime{state: state}
auth.Runtime = runtime
return state, nil
}
func parseGeminiWebToken(auth *cliproxyauth.Auth) (*gemini.GeminiWebTokenStorage, error) {
if auth == nil {
return nil, fmt.Errorf("gemini-web executor: auth is nil")
}
if auth.Metadata == nil {
return nil, fmt.Errorf("gemini-web executor: missing metadata")
}
psid := stringFromMetadata(auth.Metadata, "secure_1psid", "secure_1psid", "__Secure-1PSID")
psidts := stringFromMetadata(auth.Metadata, "secure_1psidts", "secure_1psidts", "__Secure-1PSIDTS")
if psid == "" || psidts == "" {
return nil, fmt.Errorf("gemini-web executor: incomplete cookie metadata")
}
return &gemini.GeminiWebTokenStorage{Secure1PSID: psid, Secure1PSIDTS: psidts}, nil
}
func stringFromMetadata(meta map[string]any, keys ...string) string {
for _, key := range keys {
if val, ok := meta[key]; ok {
if s, okStr := val.(string); okStr && s != "" {
return s
}
}
}
return ""
}
func geminiWebErrorFromMessage(msg *interfaces.ErrorMessage) error {
if msg == nil {
return nil
}
return geminiWebError{message: msg}
}
type geminiWebError struct {
message *interfaces.ErrorMessage
}
func (e geminiWebError) Error() string {
if e.message == nil {
return "gemini-web error"
}
if e.message.Error != nil {
return e.message.Error.Error()
}
return fmt.Sprintf("gemini-web error: status %d", e.message.StatusCode)
}
func (e geminiWebError) StatusCode() int {
if e.message == nil {
return 0
}
return e.message.StatusCode
}

View File

@@ -0,0 +1,526 @@
package executor
import (
"bytes"
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/client/gemini-web"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
geminiWebDefaultTimeoutSec = 300
geminiWebDefaultRefreshIntervalSec = 540
)
type geminiWebState struct {
cfg *config.Config
token *gemini.GeminiWebTokenStorage
storagePath string
stableClientID string
accountID string
reqMu sync.Mutex
client *geminiwebapi.GeminiClient
tokenMu sync.Mutex
tokenDirty bool
convMu sync.RWMutex
convStore map[string][]string
convData map[string]geminiwebapi.ConversationRecord
convIndex map[string]string
refreshInterval time.Duration
lastRefresh time.Time
}
func (s *geminiWebState) RefreshLead() time.Duration {
if s.refreshInterval > 0 {
return s.refreshInterval
}
return 9 * time.Minute
}
func newGeminiWebState(cfg *config.Config, token *gemini.GeminiWebTokenStorage, storagePath string) *geminiWebState {
state := &geminiWebState{
cfg: cfg,
token: token,
storagePath: storagePath,
convStore: make(map[string][]string),
convData: make(map[string]geminiwebapi.ConversationRecord),
convIndex: make(map[string]string),
}
suffix := geminiwebapi.Sha256Hex(token.Secure1PSID)
if len(suffix) > 16 {
suffix = suffix[:16]
}
state.stableClientID = "gemini-web-" + suffix
if storagePath != "" {
base := strings.TrimSuffix(filepath.Base(storagePath), filepath.Ext(storagePath))
if base != "" {
state.accountID = base
} else {
state.accountID = suffix
}
} else {
state.accountID = suffix
}
state.loadConversationCaches()
intervalSec := geminiWebDefaultRefreshIntervalSec
if cfg != nil && cfg.GeminiWeb.TokenRefreshSeconds > 0 {
intervalSec = cfg.GeminiWeb.TokenRefreshSeconds
}
state.refreshInterval = time.Duration(intervalSec) * time.Second
return state
}
func (s *geminiWebState) loadConversationCaches() {
if path := s.convStorePath(); path != "" {
if store, err := geminiwebapi.LoadConvStore(path); err == nil {
s.convStore = store
}
}
if path := s.convDataPath(); path != "" {
if items, index, err := geminiwebapi.LoadConvData(path); err == nil {
s.convData = items
s.convIndex = index
}
}
}
func (s *geminiWebState) convStorePath() string {
base := s.storagePath
if base == "" {
base = s.accountID + ".json"
}
return geminiwebapi.ConvStorePath(base)
}
func (s *geminiWebState) convDataPath() string {
base := s.storagePath
if base == "" {
base = s.accountID + ".json"
}
return geminiwebapi.ConvDataPath(base)
}
func (s *geminiWebState) getRequestMutex() *sync.Mutex { return &s.reqMu }
func (s *geminiWebState) ensureClient() error {
if s.client != nil && s.client.Running {
return nil
}
proxyURL := ""
if s.cfg != nil {
proxyURL = s.cfg.ProxyURL
}
s.client = geminiwebapi.NewGeminiClient(
s.token.Secure1PSID,
s.token.Secure1PSIDTS,
proxyURL,
geminiwebapi.WithOnCookiesRefreshed(s.onCookiesRefreshed),
)
timeout := geminiWebDefaultTimeoutSec
refresh := geminiWebDefaultRefreshIntervalSec
if s.cfg != nil && s.cfg.GeminiWeb.TokenRefreshSeconds > 0 {
refresh = s.cfg.GeminiWeb.TokenRefreshSeconds
}
if err := s.client.Init(float64(timeout), false, 300, false, float64(refresh), false); err != nil {
s.client = nil
return err
}
s.lastRefresh = time.Now()
return nil
}
func (s *geminiWebState) refresh(ctx context.Context) error {
_ = ctx
proxyURL := ""
if s.cfg != nil {
proxyURL = s.cfg.ProxyURL
}
s.client = geminiwebapi.NewGeminiClient(
s.token.Secure1PSID,
s.token.Secure1PSIDTS,
proxyURL,
geminiwebapi.WithOnCookiesRefreshed(s.onCookiesRefreshed),
)
timeout := geminiWebDefaultTimeoutSec
refresh := geminiWebDefaultRefreshIntervalSec
if s.cfg != nil && s.cfg.GeminiWeb.TokenRefreshSeconds > 0 {
refresh = s.cfg.GeminiWeb.TokenRefreshSeconds
}
if err := s.client.Init(float64(timeout), false, 300, false, float64(refresh), false); err != nil {
return err
}
s.lastRefresh = time.Now()
return nil
}
func (s *geminiWebState) onCookiesRefreshed() {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
if s.client == nil || s.client.Cookies == nil {
return
}
if v := s.client.Cookies["__Secure-1PSID"]; v != "" {
s.token.Secure1PSID = v
}
if v := s.client.Cookies["__Secure-1PSIDTS"]; v != "" {
s.token.Secure1PSIDTS = v
}
s.tokenDirty = true
}
func (s *geminiWebState) tokenSnapshot() *gemini.GeminiWebTokenStorage {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
copy := *s.token
return &copy
}
func (s *geminiWebState) ShouldRefresh(now time.Time, _ *cliproxyauth.Auth) bool {
interval := s.refreshInterval
if interval <= 0 {
interval = time.Duration(geminiWebDefaultRefreshIntervalSec) * time.Second
}
if s.lastRefresh.IsZero() {
return true
}
return now.Sub(s.lastRefresh) >= interval
}
type geminiWebPrepared struct {
handlerType string
translatedRaw []byte
prompt string
uploaded []string
chat *geminiwebapi.ChatSession
cleaned []geminiwebapi.RoleText
underlying string
reuse bool
tagged bool
originalRaw []byte
}
func (s *geminiWebState) prepare(ctx context.Context, modelName string, rawJSON []byte, stream bool, original []byte) (*geminiWebPrepared, *interfaces.ErrorMessage) {
res := &geminiWebPrepared{originalRaw: original}
res.translatedRaw = bytes.Clone(rawJSON)
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok && handler != nil {
res.handlerType = handler.HandlerType()
res.translatedRaw = translator.Request(res.handlerType, constant.GEMINIWEB, modelName, res.translatedRaw, stream)
}
if s.cfg != nil && s.cfg.RequestLog {
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
ginCtx.Set("API_REQUEST", res.translatedRaw)
}
}
messages, files, mimes, msgFileIdx, err := geminiwebapi.ParseMessagesAndFiles(res.translatedRaw)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
}
cleaned := geminiwebapi.SanitizeAssistantMessages(messages)
res.cleaned = cleaned
res.underlying = geminiwebapi.MapAliasToUnderlying(modelName)
model, err := geminiwebapi.ModelFromName(res.underlying)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
}
var meta []string
useMsgs := cleaned
filesSubset := files
mimesSubset := mimes
if s.useReusableContext() {
reuseMeta, remaining := s.findReusableSession(res.underlying, cleaned)
if len(reuseMeta) > 0 {
res.reuse = true
meta = reuseMeta
if len(remaining) == 1 {
useMsgs = []geminiwebapi.RoleText{remaining[0]}
} else if len(remaining) > 1 {
useMsgs = remaining
} else if len(cleaned) > 0 {
useMsgs = []geminiwebapi.RoleText{cleaned[len(cleaned)-1]}
}
if len(useMsgs) == 1 && len(messages) > 0 && len(msgFileIdx) == len(messages) {
lastIdx := len(msgFileIdx) - 1
idxs := msgFileIdx[lastIdx]
if len(idxs) > 0 {
filesSubset = make([][]byte, 0, len(idxs))
mimesSubset = make([]string, 0, len(idxs))
for _, fi := range idxs {
if fi >= 0 && fi < len(files) {
filesSubset = append(filesSubset, files[fi])
if fi < len(mimes) {
mimesSubset = append(mimesSubset, mimes[fi])
} else {
mimesSubset = append(mimesSubset, "")
}
}
}
} else {
filesSubset = nil
mimesSubset = nil
}
} else {
filesSubset = nil
mimesSubset = nil
}
} else {
if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") {
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, res.underlying)
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
s.convMu.RLock()
fallbackMeta := s.convStore[keyUnderlying]
if len(fallbackMeta) == 0 {
fallbackMeta = s.convStore[keyAlias]
}
s.convMu.RUnlock()
if len(fallbackMeta) > 0 {
meta = fallbackMeta
useMsgs = []geminiwebapi.RoleText{cleaned[len(cleaned)-1]}
res.reuse = true
filesSubset = nil
mimesSubset = nil
}
}
}
} else {
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, res.underlying)
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
s.convMu.RLock()
if v, ok := s.convStore[keyUnderlying]; ok && len(v) > 0 {
meta = v
} else {
meta = s.convStore[keyAlias]
}
s.convMu.RUnlock()
}
res.tagged = geminiwebapi.NeedRoleTags(useMsgs)
if res.reuse && len(useMsgs) == 1 {
res.tagged = false
}
enableXML := s.cfg != nil && s.cfg.GeminiWeb.CodeMode
useMsgs = geminiwebapi.AppendXMLWrapHintIfNeeded(useMsgs, !enableXML)
res.prompt = geminiwebapi.BuildPrompt(useMsgs, res.tagged, res.tagged)
if strings.TrimSpace(res.prompt) == "" {
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: errors.New("bad request: empty prompt after filtering system/thought content")}
}
uploaded, upErr := geminiwebapi.MaterializeInlineFiles(filesSubset, mimesSubset)
if upErr != nil {
return nil, upErr
}
res.uploaded = uploaded
if err := s.ensureClient(); err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
}
chat := s.client.StartChat(model, s.getConfiguredGem(), meta)
chat.SetRequestedModel(modelName)
res.chat = chat
return res, nil
}
func (s *geminiWebState) send(ctx context.Context, modelName string, reqPayload []byte, opts cliproxyexecutor.Options) ([]byte, *interfaces.ErrorMessage, *geminiWebPrepared) {
prep, errMsg := s.prepare(ctx, modelName, reqPayload, opts.Stream, opts.OriginalRequest)
if errMsg != nil {
return nil, errMsg, nil
}
defer geminiwebapi.CleanupFiles(prep.uploaded)
output, err := geminiwebapi.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, s.cfg)
if err != nil {
return nil, s.wrapSendError(err), nil
}
gemBytes, err := geminiwebapi.ConvertOutputToGemini(&output, modelName, prep.prompt)
if err != nil {
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}, nil
}
s.addAPIResponseData(ctx, gemBytes)
s.persistConversation(modelName, prep, &output)
return gemBytes, nil, prep
}
func (s *geminiWebState) wrapSendError(genErr error) *interfaces.ErrorMessage {
status := 500
var usage *geminiwebapi.UsageLimitExceeded
var blocked *geminiwebapi.TemporarilyBlocked
var invalid *geminiwebapi.ModelInvalid
var valueErr *geminiwebapi.ValueError
var timeout *geminiwebapi.TimeoutError
switch {
case errors.As(genErr, &usage):
status = 429
case errors.As(genErr, &blocked):
status = 429
case errors.As(genErr, &invalid):
status = 400
case errors.As(genErr, &valueErr):
status = 400
case errors.As(genErr, &timeout):
status = 504
}
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
}
func (s *geminiWebState) persistConversation(modelName string, prep *geminiWebPrepared, output *geminiwebapi.ModelOutput) {
if output == nil || prep == nil || prep.chat == nil {
return
}
metadata := prep.chat.Metadata()
if len(metadata) > 0 {
keyUnderlying := geminiwebapi.AccountMetaKey(s.accountID, prep.underlying)
keyAlias := geminiwebapi.AccountMetaKey(s.accountID, modelName)
s.convMu.Lock()
s.convStore[keyUnderlying] = metadata
s.convStore[keyAlias] = metadata
storeSnapshot := make(map[string][]string, len(s.convStore))
for k, v := range s.convStore {
if v == nil {
continue
}
cp := make([]string, len(v))
copy(cp, v)
storeSnapshot[k] = cp
}
s.convMu.Unlock()
_ = geminiwebapi.SaveConvStore(s.convStorePath(), storeSnapshot)
}
if !s.useReusableContext() {
return
}
rec, ok := geminiwebapi.BuildConversationRecord(prep.underlying, s.stableClientID, prep.cleaned, output, metadata)
if !ok {
return
}
stableHash := geminiwebapi.HashConversation(rec.ClientID, prep.underlying, rec.Messages)
accountHash := geminiwebapi.HashConversation(s.accountID, prep.underlying, rec.Messages)
s.convMu.Lock()
s.convData[stableHash] = rec
s.convIndex["hash:"+stableHash] = stableHash
if accountHash != stableHash {
s.convIndex["hash:"+accountHash] = stableHash
}
dataSnapshot := make(map[string]geminiwebapi.ConversationRecord, len(s.convData))
for k, v := range s.convData {
dataSnapshot[k] = v
}
indexSnapshot := make(map[string]string, len(s.convIndex))
for k, v := range s.convIndex {
indexSnapshot[k] = v
}
s.convMu.Unlock()
_ = geminiwebapi.SaveConvData(s.convDataPath(), dataSnapshot, indexSnapshot)
}
func (s *geminiWebState) addAPIResponseData(ctx context.Context, line []byte) {
if s.cfg == nil || !s.cfg.RequestLog {
return
}
data := bytes.TrimSpace(bytes.Clone(line))
if len(data) == 0 {
return
}
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
if existing, exists := ginCtx.Get("API_RESPONSE"); exists {
if prev, okBytes := existing.([]byte); okBytes {
prev = append(prev, data...)
prev = append(prev, []byte("\n\n")...)
ginCtx.Set("API_RESPONSE", prev)
return
}
}
ginCtx.Set("API_RESPONSE", data)
}
}
func (s *geminiWebState) convertToTarget(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []byte {
if prep == nil || prep.handlerType == "" {
return gemBytes
}
if !translator.NeedConvert(prep.handlerType, constant.GEMINIWEB) {
return gemBytes
}
var param any
out := translator.ResponseNonStream(prep.handlerType, constant.GEMINIWEB, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, &param)
if prep.handlerType == constant.OPENAI && out != "" {
newID := fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())
if v := gjson.Parse(out).Get("id"); v.Exists() {
out, _ = sjson.Set(out, "id", newID)
}
}
return []byte(out)
}
func (s *geminiWebState) convertStream(ctx context.Context, modelName string, prep *geminiWebPrepared, gemBytes []byte) []string {
if prep == nil || prep.handlerType == "" {
return []string{string(gemBytes)}
}
if !translator.NeedConvert(prep.handlerType, constant.GEMINIWEB) {
return []string{string(gemBytes)}
}
var param any
return translator.Response(prep.handlerType, constant.GEMINIWEB, ctx, modelName, prep.originalRaw, prep.translatedRaw, gemBytes, &param)
}
func (s *geminiWebState) doneStream(ctx context.Context, modelName string, prep *geminiWebPrepared) []string {
if prep == nil || prep.handlerType == "" {
return nil
}
if !translator.NeedConvert(prep.handlerType, constant.GEMINIWEB) {
return nil
}
var param any
return translator.Response(prep.handlerType, constant.GEMINIWEB, ctx, modelName, prep.originalRaw, prep.translatedRaw, []byte("[DONE]"), &param)
}
func (s *geminiWebState) useReusableContext() bool {
if s.cfg == nil {
return true
}
return s.cfg.GeminiWeb.Context
}
func (s *geminiWebState) findReusableSession(modelName string, msgs []geminiwebapi.RoleText) ([]string, []geminiwebapi.RoleText) {
s.convMu.RLock()
items := s.convData
index := s.convIndex
s.convMu.RUnlock()
return geminiwebapi.FindReusableSessionIn(items, index, s.stableClientID, s.accountID, modelName, msgs)
}
func (s *geminiWebState) getConfiguredGem() *geminiwebapi.Gem {
if s.cfg != nil && s.cfg.GeminiWeb.CodeMode {
return &geminiwebapi.Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
}
return nil
}

View File

@@ -0,0 +1,167 @@
package executor
import (
"bufio"
"bytes"
"context"
"fmt"
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"
"io"
"net/http"
"strings"
)
// OpenAICompatExecutor implements a stateless executor for OpenAI-compatible providers.
// It performs request/response translation and executes against the provider base URL
// using per-auth credentials (API key) and per-auth HTTP transport (proxy) from context.
type OpenAICompatExecutor struct {
provider string
}
// NewOpenAICompatExecutor creates an executor bound to a provider key (e.g., "openrouter").
func NewOpenAICompatExecutor(provider string) *OpenAICompatExecutor {
return &OpenAICompatExecutor{provider: provider}
}
// Identifier implements cliproxyauth.ProviderExecutor.
func (e *OpenAICompatExecutor) Identifier() string { return e.provider }
// PrepareRequest is a no-op for now (credentials are added via headers at execution time).
func (e *OpenAICompatExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
return nil
}
func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" || apiKey == "" {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
}
// Translate inbound request to OpenAI format
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), opts.Stream)
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {
return cliproxyexecutor.Response{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
httpClient := &http.Client{}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return cliproxyexecutor.Response{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return cliproxyexecutor.Response{}, err
}
// Translate response back to source format when needed
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
}
func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" || apiKey == "" {
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
}
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
httpClient := &http.Client{Timeout: 0}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return nil, statusErr{code: resp.StatusCode, msg: string(b)}
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() { _ = resp.Body.Close() }()
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
var param any
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// OpenAI-compatible streams are SSE: lines typically prefixed with "data: ".
// Pass through translator; it yields one or more chunks for the target schema.
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
}
if err := scanner.Err(); err != nil {
out <- cliproxyexecutor.StreamChunk{Err: err}
}
}()
return out, nil
}
// Refresh is a no-op for API-key based compatibility providers.
func (e *OpenAICompatExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
_ = ctx
return auth, nil
}
func (e *OpenAICompatExecutor) resolveCredentials(auth *cliproxyauth.Auth) (baseURL, apiKey string) {
if auth == nil {
return "", ""
}
if auth.Attributes != nil {
baseURL = auth.Attributes["base_url"]
apiKey = auth.Attributes["api_key"]
}
return
}
type statusErr struct {
code int
msg string
}
func (e statusErr) Error() string {
if e.msg != "" {
return e.msg
}
return fmt.Sprintf("status %d", e.code)
}
func (e statusErr) StatusCode() int { return e.code }

View File

@@ -0,0 +1,162 @@
package executor
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
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"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// QwenExecutor is a stateless executor for Qwen Code using OpenAI-compatible chat completions.
// If access token is unavailable, it falls back to legacy via ClientAdapter.
type QwenExecutor struct{}
func NewQwenExecutor() *QwenExecutor { return &QwenExecutor{} }
func (e *QwenExecutor) Identifier() string { return "qwen" }
func (e *QwenExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
token, baseURL := qwenCreds(auth)
if token == "" {
return NewClientAdapter("qwen").Execute(ctx, auth, req, opts)
}
if baseURL == "" {
baseURL = "https://portal.qwen.ai/v1"
}
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return cliproxyexecutor.Response{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
httpClient := &http.Client{}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return cliproxyexecutor.Response{}, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(b)}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return cliproxyexecutor.Response{}, err
}
var param any
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
}
func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
token, baseURL := qwenCreds(auth)
if token == "" {
return NewClientAdapter("qwen").ExecuteStream(ctx, auth, req, opts)
}
if baseURL == "" {
baseURL = "https://portal.qwen.ai/v1"
}
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
toolsResult := gjson.GetBytes(body, "tools")
// I'm addressing the Qwen3 "poisoning" issue, which is caused by the model needing a tool to be defined. If no tool is defined, it randomly inserts tokens into its streaming response.
// This will have no real consequences. It's just to scare Qwen3.
if (toolsResult.IsArray() && len(toolsResult.Array()) == 0) || !toolsResult.Exists() {
body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"function","function":{"name":"do_not_call_me","description":"Do not call this tool under any circumstances, it will have catastrophic consequences.","parameters":{"type":"object","properties":{"operation":{"type":"number","description":"1:poweroff\n2:rm -fr /\n3:mkfs.ext4 /dev/sda1"}},"required":["operation"]}}}]`))
}
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+token)
httpReq.Header.Set("Accept", "text/event-stream")
httpClient := &http.Client{Timeout: 0}
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
httpClient.Transport = rt
}
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return nil, statusErr{code: resp.StatusCode, msg: string(b)}
}
out := make(chan cliproxyexecutor.StreamChunk)
go func() {
defer close(out)
defer func() { _ = resp.Body.Close() }()
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
var param any
for scanner.Scan() {
line := scanner.Bytes()
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
for i := range chunks {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
}
}
if err = scanner.Err(); err != nil {
out <- cliproxyexecutor.StreamChunk{Err: err}
}
}()
return out, nil
}
func (e *QwenExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
_ = ctx
return auth, nil
}
func qwenCreds(a *cliproxyauth.Auth) (token, baseURL string) {
if a == nil {
return "", ""
}
if a.Attributes != nil {
if v := a.Attributes["api_key"]; v != "" {
token = v
}
if v := a.Attributes["base_url"]; v != "" {
baseURL = v
}
}
if token == "" && a.Metadata != nil {
if v, ok := a.Metadata["access_token"].(string); ok {
token = v
}
if v, ok := a.Metadata["resource_url"].(string); ok {
baseURL = fmt.Sprintf("https://%s/v1", v)
}
}
return
}