mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-11 08:15:14 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f27fd5c42 | ||
|
|
a9823ba58a | ||
|
|
8cfe26f10c | ||
|
|
80db2dc254 | ||
|
|
e8e3bc8616 | ||
|
|
ab5f5386e4 | ||
|
|
bc3195c8d8 | ||
|
|
6494330c6b | ||
|
|
89e34bf1e6 | ||
|
|
2574eec2ed | ||
|
|
514b9bf9fc | ||
|
|
4d7f389b69 | ||
|
|
95f87d5669 | ||
|
|
c83365a349 | ||
|
|
6b3604cf2b | ||
|
|
af6bdca14f | ||
|
|
58d45b4d58 | ||
|
|
1906ebcfce | ||
|
|
1c773c428f | ||
|
|
e785bfcd12 | ||
|
|
47dacce6ea | ||
|
|
dcac3407ab | ||
|
|
7004295e1d | ||
|
|
ee62ef4745 | ||
|
|
ef6bafbf7e | ||
|
|
ed28b71e87 | ||
|
|
d47b7dc79a | ||
|
|
49b9709ce5 | ||
|
|
a2eba2cdf5 | ||
|
|
3d01b3cfe8 | ||
|
|
af2efa6f7e | ||
|
|
d73b61d367 | ||
|
|
d3533f81fc | ||
|
|
59a448b645 | ||
|
|
3de7a7f0cd | ||
|
|
4adb9eed77 | ||
|
|
b6a0f7a07f | ||
|
|
b2566368f8 | ||
|
|
1b2f907671 | ||
|
|
bda04eed8a | ||
|
|
e0735977b5 | ||
|
|
67985d8226 | ||
|
|
cbcb061812 | ||
|
|
9fc2e1b3c8 | ||
|
|
3b484aea9e | ||
|
|
963a0950fa | ||
|
|
1fb4f2b12e | ||
|
|
f4ba1ab910 | ||
|
|
2662f91082 | ||
|
|
f5967069f2 | ||
|
|
08e8fddf73 | ||
|
|
414db44c00 | ||
|
|
cb3bdffb43 | ||
|
|
48f19aab51 | ||
|
|
48f6d7abdf | ||
|
|
79fbcb3ec4 | ||
|
|
0e4148b229 | ||
|
|
31bd90c748 | ||
|
|
0b834fcb54 |
11
README.md
11
README.md
@@ -19,17 +19,6 @@ This project only accepts pull requests that relate to third-party provider supp
|
||||
|
||||
If you need to submit any non-third-party provider changes, please open them against the mainline repository.
|
||||
|
||||
## More choices
|
||||
|
||||
Those projects are ports of CLIProxyAPI or inspired by it:
|
||||
|
||||
### [9Router](https://github.com/decolua/9router)
|
||||
|
||||
A Next.js implementation inspired by CLIProxyAPI, easy to install and use, built from scratch with format translation (OpenAI/Claude/Gemini/Ollama), combo system with auto-fallback, multi-account management with exponential backoff, a Next.js web dashboard, and support for CLI tools (Cursor, Claude Code, Cline, RooCode) - no API keys needed.
|
||||
|
||||
> [!NOTE]
|
||||
> If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
11
README_CN.md
11
README_CN.md
@@ -19,17 +19,6 @@
|
||||
|
||||
如果需要提交任何非第三方供应商支持的 Pull Request,请提交到主线版本。
|
||||
|
||||
## 更多选择
|
||||
|
||||
以下项目是 CLIProxyAPI 的移植版或受其启发:
|
||||
|
||||
### [9Router](https://github.com/decolua/9router)
|
||||
|
||||
基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
||||
@@ -215,7 +215,7 @@ ws-auth: false
|
||||
|
||||
# Global OAuth model name mappings (per channel)
|
||||
# These mappings rename model IDs for both model listing and request routing.
|
||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro.
|
||||
# NOTE: Mappings do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
||||
# oauth-model-mappings:
|
||||
# gemini-cli:
|
||||
@@ -243,6 +243,9 @@ ws-auth: false
|
||||
# iflow:
|
||||
# - name: "glm-4.7"
|
||||
# alias: "glm-god"
|
||||
# kiro:
|
||||
# - name: "kiro-claude-opus-4-5"
|
||||
# alias: "op45"
|
||||
|
||||
# OAuth provider excluded models
|
||||
# oauth-excluded-models:
|
||||
|
||||
@@ -152,16 +152,16 @@ case "$choice" in
|
||||
# Build and start the services with a local-only image tag
|
||||
export CLI_PROXY_IMAGE="cli-proxy-api:local"
|
||||
|
||||
if [[ "${WITH_USAGE}" == "true" ]]; then
|
||||
export_stats
|
||||
fi
|
||||
|
||||
echo "Building the Docker image..."
|
||||
docker compose build \
|
||||
--build-arg VERSION="${VERSION}" \
|
||||
--build-arg COMMIT="${COMMIT}" \
|
||||
--build-arg BUILD_DATE="${BUILD_DATE}"
|
||||
|
||||
if [[ "${WITH_USAGE}" == "true" ]]; then
|
||||
export_stats
|
||||
fi
|
||||
|
||||
echo "Starting the services..."
|
||||
docker compose up -d --remove-orphans --pull never
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -122,7 +123,9 @@ func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Re
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Inject credentials via PrepareRequest hook.
|
||||
_ = (MyExecutor{}).PrepareRequest(httpReq, a)
|
||||
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
||||
return clipexec.Response{}, errPrep
|
||||
}
|
||||
|
||||
resp, errDo := client.Do(httpReq)
|
||||
if errDo != nil {
|
||||
@@ -130,13 +133,28 @@ func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Re
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
// Best-effort close; log if needed in real projects.
|
||||
fmt.Fprintf(os.Stderr, "close response body error: %v\n", errClose)
|
||||
}
|
||||
}()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return clipexec.Response{Payload: body}, nil
|
||||
}
|
||||
|
||||
func (MyExecutor) HttpRequest(ctx context.Context, a *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("myprov executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if errPrep := (MyExecutor{}).PrepareRequest(httpReq, a); errPrep != nil {
|
||||
return nil, errPrep
|
||||
}
|
||||
client := buildHTTPClient(a)
|
||||
return client.Do(httpReq)
|
||||
}
|
||||
|
||||
func (MyExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
||||
return clipexec.Response{}, errors.New("count tokens not implemented")
|
||||
}
|
||||
@@ -199,8 +217,8 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
panic(err)
|
||||
if errRun := svc.Run(ctx); errRun != nil && !errors.Is(errRun, context.Canceled) {
|
||||
panic(errRun)
|
||||
}
|
||||
_ = os.Stderr // keep os import used (demo only)
|
||||
_ = time.Second
|
||||
|
||||
140
examples/http-request/main.go
Normal file
140
examples/http-request/main.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package main demonstrates how to use coreauth.Manager.HttpRequest/NewHttpRequest
|
||||
// to execute arbitrary HTTP requests with provider credentials injected.
|
||||
//
|
||||
// This example registers a minimal custom executor that injects an Authorization
|
||||
// header from auth.Attributes["api_key"], then performs two requests against
|
||||
// httpbin.org to show the injected headers.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const providerKey = "echo"
|
||||
|
||||
// EchoExecutor is a minimal provider implementation for demonstration purposes.
|
||||
type EchoExecutor struct{}
|
||||
|
||||
func (EchoExecutor) Identifier() string { return providerKey }
|
||||
|
||||
func (EchoExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {
|
||||
if req == nil || auth == nil {
|
||||
return nil
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
if apiKey := strings.TrimSpace(auth.Attributes["api_key"]); apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (EchoExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("echo executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if errPrep := (EchoExecutor{}).PrepareRequest(httpReq, auth); errPrep != nil {
|
||||
return nil, errPrep
|
||||
}
|
||||
return http.DefaultClient.Do(httpReq)
|
||||
}
|
||||
|
||||
func (EchoExecutor) Execute(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
||||
return clipexec.Response{}, errors.New("echo executor: Execute not implemented")
|
||||
}
|
||||
|
||||
func (EchoExecutor) ExecuteStream(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||
return nil, errors.New("echo executor: ExecuteStream not implemented")
|
||||
}
|
||||
|
||||
func (EchoExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {
|
||||
return nil, errors.New("echo executor: Refresh not implemented")
|
||||
}
|
||||
|
||||
func (EchoExecutor) CountTokens(context.Context, *coreauth.Auth, clipexec.Request, clipexec.Options) (clipexec.Response, error) {
|
||||
return clipexec.Response{}, errors.New("echo executor: CountTokens not implemented")
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetLevel(log.InfoLevel)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
core := coreauth.NewManager(nil, nil, nil)
|
||||
core.RegisterExecutor(EchoExecutor{})
|
||||
|
||||
auth := &coreauth.Auth{
|
||||
ID: "demo-echo",
|
||||
Provider: providerKey,
|
||||
Attributes: map[string]string{
|
||||
"api_key": "demo-api-key",
|
||||
},
|
||||
}
|
||||
|
||||
// Example 1: Build a prepared request and execute it using your own http.Client.
|
||||
reqPrepared, errReqPrepared := core.NewHttpRequest(
|
||||
ctx,
|
||||
auth,
|
||||
http.MethodGet,
|
||||
"https://httpbin.org/anything",
|
||||
nil,
|
||||
http.Header{"X-Example": []string{"prepared"}},
|
||||
)
|
||||
if errReqPrepared != nil {
|
||||
panic(errReqPrepared)
|
||||
}
|
||||
respPrepared, errDoPrepared := http.DefaultClient.Do(reqPrepared)
|
||||
if errDoPrepared != nil {
|
||||
panic(errDoPrepared)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := respPrepared.Body.Close(); errClose != nil {
|
||||
log.Errorf("close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
bodyPrepared, errReadPrepared := io.ReadAll(respPrepared.Body)
|
||||
if errReadPrepared != nil {
|
||||
panic(errReadPrepared)
|
||||
}
|
||||
fmt.Printf("Prepared request status: %d\n%s\n\n", respPrepared.StatusCode, bodyPrepared)
|
||||
|
||||
// Example 2: Execute a raw request via core.HttpRequest (auto inject + do).
|
||||
rawBody := []byte(`{"hello":"world"}`)
|
||||
rawReq, errRawReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/anything", bytes.NewReader(rawBody))
|
||||
if errRawReq != nil {
|
||||
panic(errRawReq)
|
||||
}
|
||||
rawReq.Header.Set("Content-Type", "application/json")
|
||||
rawReq.Header.Set("X-Example", "executed")
|
||||
|
||||
respExec, errDoExec := core.HttpRequest(ctx, auth, rawReq)
|
||||
if errDoExec != nil {
|
||||
panic(errDoExec)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := respExec.Body.Close(); errClose != nil {
|
||||
log.Errorf("close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
bodyExec, errReadExec := io.ReadAll(respExec.Body)
|
||||
if errReadExec != nil {
|
||||
panic(errReadExec)
|
||||
}
|
||||
fmt.Printf("Manager HttpRequest status: %d\n%s\n", respExec.StatusCode, bodyExec)
|
||||
}
|
||||
@@ -464,6 +464,12 @@ func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
|
||||
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
|
||||
result["plan_type"] = v
|
||||
}
|
||||
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
|
||||
result["chatgpt_subscription_active_start"] = v
|
||||
}
|
||||
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
|
||||
result["chatgpt_subscription_active_until"] = v
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -24,8 +24,15 @@ import (
|
||||
type attemptInfo struct {
|
||||
count int
|
||||
blockedUntil time.Time
|
||||
lastActivity time.Time // track last activity for cleanup
|
||||
}
|
||||
|
||||
// attemptCleanupInterval controls how often stale IP entries are purged
|
||||
const attemptCleanupInterval = 1 * time.Hour
|
||||
|
||||
// attemptMaxIdleTime controls how long an IP can be idle before cleanup
|
||||
const attemptMaxIdleTime = 2 * time.Hour
|
||||
|
||||
// Handler aggregates config reference, persistence path and helpers.
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
@@ -47,7 +54,7 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man
|
||||
envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
|
||||
envSecret = strings.TrimSpace(envSecret)
|
||||
|
||||
return &Handler{
|
||||
h := &Handler{
|
||||
cfg: cfg,
|
||||
configFilePath: configFilePath,
|
||||
failedAttempts: make(map[string]*attemptInfo),
|
||||
@@ -57,6 +64,38 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man
|
||||
allowRemoteOverride: envSecret != "",
|
||||
envSecret: envSecret,
|
||||
}
|
||||
h.startAttemptCleanup()
|
||||
return h
|
||||
}
|
||||
|
||||
// startAttemptCleanup launches a background goroutine that periodically
|
||||
// removes stale IP entries from failedAttempts to prevent memory leaks.
|
||||
func (h *Handler) startAttemptCleanup() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(attemptCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
h.purgeStaleAttempts()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// purgeStaleAttempts removes IP entries that have been idle beyond attemptMaxIdleTime
|
||||
// and whose ban (if any) has expired.
|
||||
func (h *Handler) purgeStaleAttempts() {
|
||||
now := time.Now()
|
||||
h.attemptsMu.Lock()
|
||||
defer h.attemptsMu.Unlock()
|
||||
for ip, ai := range h.failedAttempts {
|
||||
// Skip if still banned
|
||||
if !ai.blockedUntil.IsZero() && now.Before(ai.blockedUntil) {
|
||||
continue
|
||||
}
|
||||
// Remove if idle too long
|
||||
if now.Sub(ai.lastActivity) > attemptMaxIdleTime {
|
||||
delete(h.failedAttempts, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewHandler creates a new management handler instance.
|
||||
@@ -149,6 +188,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
h.failedAttempts[clientIP] = aip
|
||||
}
|
||||
aip.count++
|
||||
aip.lastActivity = time.Now()
|
||||
if aip.count >= maxFailures {
|
||||
aip.blockedUntil = time.Now().Add(banDuration)
|
||||
aip.count = 0
|
||||
|
||||
@@ -125,7 +125,30 @@ func (rw *ResponseRewriter) Flush() {
|
||||
var modelFieldPaths = []string{"model", "modelVersion", "response.modelVersion", "message.model"}
|
||||
|
||||
// rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON
|
||||
// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility
|
||||
func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
|
||||
// 1. Amp Compatibility: Suppress thinking blocks if tool use is detected
|
||||
// The Amp client struggles when both thinking and tool_use blocks are present
|
||||
if gjson.GetBytes(data, `content.#(type=="tool_use")`).Exists() {
|
||||
filtered := gjson.GetBytes(data, `content.#(type!="thinking")#`)
|
||||
if filtered.Exists() {
|
||||
originalCount := gjson.GetBytes(data, "content.#").Int()
|
||||
filteredCount := filtered.Get("#").Int()
|
||||
|
||||
if originalCount > filteredCount {
|
||||
var err error
|
||||
data, err = sjson.SetBytes(data, "content", filtered.Value())
|
||||
if err != nil {
|
||||
log.Warnf("Amp ResponseRewriter: failed to suppress thinking blocks: %v", err)
|
||||
} else {
|
||||
log.Debugf("Amp ResponseRewriter: Suppressed %d thinking blocks due to tool usage", originalCount-filteredCount)
|
||||
// Log the result for verification
|
||||
log.Debugf("Amp ResponseRewriter: Resulting content: %s", gjson.GetBytes(data, "content").String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rw.originalModel == "" {
|
||||
return data
|
||||
}
|
||||
|
||||
43
internal/cache/signature_cache.go
vendored
43
internal/cache/signature_cache.go
vendored
@@ -26,11 +26,17 @@ const (
|
||||
|
||||
// MinValidSignatureLen is the minimum length for a signature to be considered valid
|
||||
MinValidSignatureLen = 50
|
||||
|
||||
// SessionCleanupInterval controls how often stale sessions are purged
|
||||
SessionCleanupInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// signatureCache stores signatures by sessionId -> textHash -> SignatureEntry
|
||||
var signatureCache sync.Map
|
||||
|
||||
// sessionCleanupOnce ensures the background cleanup goroutine starts only once
|
||||
var sessionCleanupOnce sync.Once
|
||||
|
||||
// sessionCache is the inner map type
|
||||
type sessionCache struct {
|
||||
mu sync.RWMutex
|
||||
@@ -45,6 +51,9 @@ func hashText(text string) string {
|
||||
|
||||
// getOrCreateSession gets or creates a session cache
|
||||
func getOrCreateSession(sessionID string) *sessionCache {
|
||||
// Start background cleanup on first access
|
||||
sessionCleanupOnce.Do(startSessionCleanup)
|
||||
|
||||
if val, ok := signatureCache.Load(sessionID); ok {
|
||||
return val.(*sessionCache)
|
||||
}
|
||||
@@ -53,6 +62,40 @@ func getOrCreateSession(sessionID string) *sessionCache {
|
||||
return actual.(*sessionCache)
|
||||
}
|
||||
|
||||
// startSessionCleanup launches a background goroutine that periodically
|
||||
// removes sessions where all entries have expired.
|
||||
func startSessionCleanup() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(SessionCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
purgeExpiredSessions()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// purgeExpiredSessions removes sessions with no valid (non-expired) entries.
|
||||
func purgeExpiredSessions() {
|
||||
now := time.Now()
|
||||
signatureCache.Range(func(key, value any) bool {
|
||||
sc := value.(*sessionCache)
|
||||
sc.mu.Lock()
|
||||
// Remove expired entries
|
||||
for k, entry := range sc.entries {
|
||||
if now.Sub(entry.Timestamp) > SignatureCacheTTL {
|
||||
delete(sc.entries, k)
|
||||
}
|
||||
}
|
||||
isEmpty := len(sc.entries) == 0
|
||||
sc.mu.Unlock()
|
||||
// Remove session if empty
|
||||
if isEmpty {
|
||||
signatureCache.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// CacheSignature stores a thinking signature for a given session and text.
|
||||
// Used for Claude models that require signed thinking blocks in multi-turn conversations.
|
||||
func CacheSignature(sessionID, text, signature string) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -50,6 +51,64 @@ func (e *AIStudioExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest forwards an arbitrary HTTP request through the websocket relay.
|
||||
func (e *AIStudioExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("aistudio executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
if e.relay == nil {
|
||||
return nil, fmt.Errorf("aistudio executor: ws relay is nil")
|
||||
}
|
||||
if auth == nil || auth.ID == "" {
|
||||
return nil, fmt.Errorf("aistudio executor: missing auth")
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if httpReq.URL == nil || strings.TrimSpace(httpReq.URL.String()) == "" {
|
||||
return nil, fmt.Errorf("aistudio executor: request URL is empty")
|
||||
}
|
||||
|
||||
var body []byte
|
||||
if httpReq.Body != nil {
|
||||
b, errRead := io.ReadAll(httpReq.Body)
|
||||
if errRead != nil {
|
||||
return nil, errRead
|
||||
}
|
||||
body = b
|
||||
httpReq.Body = io.NopCloser(bytes.NewReader(b))
|
||||
}
|
||||
|
||||
wsReq := &wsrelay.HTTPRequest{
|
||||
Method: httpReq.Method,
|
||||
URL: httpReq.URL.String(),
|
||||
Headers: httpReq.Header.Clone(),
|
||||
Body: body,
|
||||
}
|
||||
wsResp, errRelay := e.relay.NonStream(ctx, auth.ID, wsReq)
|
||||
if errRelay != nil {
|
||||
return nil, errRelay
|
||||
}
|
||||
if wsResp == nil {
|
||||
return nil, fmt.Errorf("aistudio executor: ws response is nil")
|
||||
}
|
||||
|
||||
statusText := http.StatusText(wsResp.Status)
|
||||
if statusText == "" {
|
||||
statusText = "Unknown"
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: wsResp.Status,
|
||||
Status: fmt.Sprintf("%d %s", wsResp.Status, statusText),
|
||||
Header: wsResp.Headers.Clone(),
|
||||
Body: io.NopCloser(bytes.NewReader(wsResp.Body)),
|
||||
ContentLength: int64(len(wsResp.Body)),
|
||||
Request: httpReq,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Execute performs a non-streaming request to the AI Studio API.
|
||||
func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
@@ -45,7 +46,7 @@ const (
|
||||
defaultAntigravityAgent = "antigravity/1.104.0 darwin/arm64"
|
||||
antigravityAuthType = "antigravity"
|
||||
refreshSkew = 3000 * time.Second
|
||||
tokenRefreshTimeout = 30 * time.Second
|
||||
systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -72,13 +73,42 @@ func NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor {
|
||||
// Identifier returns the executor identifier.
|
||||
func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType }
|
||||
|
||||
// PrepareRequest prepares the HTTP request for execution (no-op for Antigravity).
|
||||
func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
// PrepareRequest injects Antigravity credentials into the outgoing HTTP request.
|
||||
func (e *AntigravityExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
token, _, errToken := e.ensureAccessToken(req.Context(), auth)
|
||||
if errToken != nil {
|
||||
return errToken
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Antigravity credentials into the request and executes it.
|
||||
func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("antigravity executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// Execute performs a non-streaming request to the Antigravity API.
|
||||
func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
isClaude := strings.Contains(strings.ToLower(req.Model), "claude")
|
||||
if isClaude {
|
||||
if isClaude || strings.Contains(req.Model, "gemini-3-pro") {
|
||||
return e.executeClaudeNonStream(ctx, auth, req, opts)
|
||||
}
|
||||
|
||||
@@ -125,6 +155,9 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
recordAPIResponseError(ctx, e.cfg, errDo)
|
||||
if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
|
||||
return resp, errDo
|
||||
}
|
||||
lastStatus = 0
|
||||
lastBody = nil
|
||||
lastErr = errDo
|
||||
@@ -157,7 +190,13 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
||||
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
||||
continue
|
||||
}
|
||||
err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
if httpResp.StatusCode == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
err = sErr
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -171,7 +210,13 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
||||
|
||||
switch {
|
||||
case lastStatus != 0:
|
||||
err = statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
sErr := statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
if lastStatus == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
err = sErr
|
||||
case lastErr != nil:
|
||||
err = lastErr
|
||||
default:
|
||||
@@ -225,6 +270,9 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
recordAPIResponseError(ctx, e.cfg, errDo)
|
||||
if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
|
||||
return resp, errDo
|
||||
}
|
||||
lastStatus = 0
|
||||
lastBody = nil
|
||||
lastErr = errDo
|
||||
@@ -243,6 +291,14 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
||||
}
|
||||
if errRead != nil {
|
||||
recordAPIResponseError(ctx, e.cfg, errRead)
|
||||
if errors.Is(errRead, context.Canceled) || errors.Is(errRead, context.DeadlineExceeded) {
|
||||
err = errRead
|
||||
return resp, err
|
||||
}
|
||||
if errCtx := ctx.Err(); errCtx != nil {
|
||||
err = errCtx
|
||||
return resp, err
|
||||
}
|
||||
lastStatus = 0
|
||||
lastBody = nil
|
||||
lastErr = errRead
|
||||
@@ -261,7 +317,13 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
||||
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
||||
continue
|
||||
}
|
||||
err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
if httpResp.StatusCode == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
err = sErr
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -326,7 +388,13 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
|
||||
|
||||
switch {
|
||||
case lastStatus != 0:
|
||||
err = statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
sErr := statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
if lastStatus == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
err = sErr
|
||||
case lastErr != nil:
|
||||
err = lastErr
|
||||
default:
|
||||
@@ -566,6 +634,9 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
recordAPIResponseError(ctx, e.cfg, errDo)
|
||||
if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
|
||||
return nil, errDo
|
||||
}
|
||||
lastStatus = 0
|
||||
lastBody = nil
|
||||
lastErr = errDo
|
||||
@@ -584,6 +655,14 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
||||
}
|
||||
if errRead != nil {
|
||||
recordAPIResponseError(ctx, e.cfg, errRead)
|
||||
if errors.Is(errRead, context.Canceled) || errors.Is(errRead, context.DeadlineExceeded) {
|
||||
err = errRead
|
||||
return nil, err
|
||||
}
|
||||
if errCtx := ctx.Err(); errCtx != nil {
|
||||
err = errCtx
|
||||
return nil, err
|
||||
}
|
||||
lastStatus = 0
|
||||
lastBody = nil
|
||||
lastErr = errRead
|
||||
@@ -602,7 +681,13 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
||||
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
||||
continue
|
||||
}
|
||||
err = statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
if httpResp.StatusCode == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
err = sErr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -657,7 +742,13 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
||||
|
||||
switch {
|
||||
case lastStatus != 0:
|
||||
err = statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
sErr := statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
if lastStatus == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
err = sErr
|
||||
case lastErr != nil:
|
||||
err = lastErr
|
||||
default:
|
||||
@@ -760,6 +851,9 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
recordAPIResponseError(ctx, e.cfg, errDo)
|
||||
if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
|
||||
return cliproxyexecutor.Response{}, errDo
|
||||
}
|
||||
lastStatus = 0
|
||||
lastBody = nil
|
||||
lastErr = errDo
|
||||
@@ -794,12 +888,24 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
|
||||
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
||||
continue
|
||||
}
|
||||
return cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
if httpResp.StatusCode == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
return cliproxyexecutor.Response{}, sErr
|
||||
}
|
||||
|
||||
switch {
|
||||
case lastStatus != 0:
|
||||
return cliproxyexecutor.Response{}, statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
sErr := statusErr{code: lastStatus, msg: string(lastBody)}
|
||||
if lastStatus == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(lastBody); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
return cliproxyexecutor.Response{}, sErr
|
||||
case lastErr != nil:
|
||||
return cliproxyexecutor.Response{}, lastErr
|
||||
default:
|
||||
@@ -836,6 +942,9 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
|
||||
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
if errors.Is(errDo, context.Canceled) || errors.Is(errDo, context.DeadlineExceeded) {
|
||||
return nil
|
||||
}
|
||||
if idx+1 < len(baseURLs) {
|
||||
log.Debugf("antigravity executor: models request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
|
||||
continue
|
||||
@@ -951,7 +1060,7 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
|
||||
httpReq.Header.Set("User-Agent", defaultAntigravityAgent)
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, tokenRefreshTimeout)
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
return auth, errDo
|
||||
@@ -968,7 +1077,13 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
|
||||
}
|
||||
|
||||
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return auth, statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
|
||||
if httpResp.StatusCode == http.StatusTooManyRequests {
|
||||
if retryAfter, parseErr := parseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
|
||||
sErr.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
return auth, sErr
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
@@ -1048,6 +1163,19 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
||||
payload = []byte(strJSON)
|
||||
}
|
||||
|
||||
if strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro-preview") {
|
||||
systemInstructionPartsResult := gjson.GetBytes(payload, "request.systemInstruction.parts")
|
||||
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.role", "user")
|
||||
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.0.text", systemInstruction)
|
||||
payload, _ = sjson.SetBytes(payload, "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
|
||||
|
||||
if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
|
||||
for _, partResult := range systemInstructionPartsResult.Array() {
|
||||
payload, _ = sjson.SetRawBytes(payload, "request.systemInstruction.parts.-1", []byte(partResult.Raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
|
||||
if errReq != nil {
|
||||
return nil, errReq
|
||||
@@ -1182,8 +1310,8 @@ func antigravityBaseURLFallbackOrder(auth *cliproxyauth.Auth) []string {
|
||||
return []string{base}
|
||||
}
|
||||
return []string{
|
||||
antigravityBaseURLDaily,
|
||||
antigravitySandboxBaseURLDaily,
|
||||
antigravityBaseURLDaily,
|
||||
antigravityBaseURLProd,
|
||||
}
|
||||
}
|
||||
@@ -1211,6 +1339,7 @@ func resolveCustomAntigravityBaseURL(auth *cliproxyauth.Auth) string {
|
||||
func geminiToAntigravity(modelName string, payload []byte, projectID string) []byte {
|
||||
template, _ := sjson.Set(string(payload), "model", modelName)
|
||||
template, _ = sjson.Set(template, "userAgent", "antigravity")
|
||||
template, _ = sjson.Set(template, "requestType", "agent")
|
||||
|
||||
// Use real project ID from auth if available, otherwise generate random (legacy fallback)
|
||||
if projectID != "" {
|
||||
|
||||
@@ -10,29 +10,69 @@ type codexCache struct {
|
||||
Expire time.Time
|
||||
}
|
||||
|
||||
// codexCacheMap stores prompt cache IDs keyed by model+user_id.
|
||||
// Protected by codexCacheMu. Entries expire after 1 hour.
|
||||
var (
|
||||
codexCacheMap = map[string]codexCache{}
|
||||
codexCacheMutex sync.RWMutex
|
||||
codexCacheMap = make(map[string]codexCache)
|
||||
codexCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getCodexCache safely retrieves a cache entry
|
||||
// codexCacheCleanupInterval controls how often expired entries are purged.
|
||||
const codexCacheCleanupInterval = 15 * time.Minute
|
||||
|
||||
// codexCacheCleanupOnce ensures the background cleanup goroutine starts only once.
|
||||
var codexCacheCleanupOnce sync.Once
|
||||
|
||||
// startCodexCacheCleanup launches a background goroutine that periodically
|
||||
// removes expired entries from codexCacheMap to prevent memory leaks.
|
||||
func startCodexCacheCleanup() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(codexCacheCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
purgeExpiredCodexCache()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// purgeExpiredCodexCache removes entries that have expired.
|
||||
func purgeExpiredCodexCache() {
|
||||
now := time.Now()
|
||||
|
||||
codexCacheMu.Lock()
|
||||
defer codexCacheMu.Unlock()
|
||||
|
||||
for key, cache := range codexCacheMap {
|
||||
if cache.Expire.Before(now) {
|
||||
delete(codexCacheMap, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getCodexCache retrieves a cached entry, returning ok=false if not found or expired.
|
||||
func getCodexCache(key string) (codexCache, bool) {
|
||||
codexCacheMutex.RLock()
|
||||
defer codexCacheMutex.RUnlock()
|
||||
codexCacheCleanupOnce.Do(startCodexCacheCleanup)
|
||||
codexCacheMu.RLock()
|
||||
cache, ok := codexCacheMap[key]
|
||||
return cache, ok
|
||||
codexCacheMu.RUnlock()
|
||||
if !ok || cache.Expire.Before(time.Now()) {
|
||||
return codexCache{}, false
|
||||
}
|
||||
return cache, true
|
||||
}
|
||||
|
||||
// setCodexCache safely sets a cache entry
|
||||
// setCodexCache stores a cache entry.
|
||||
func setCodexCache(key string, cache codexCache) {
|
||||
codexCacheMutex.Lock()
|
||||
defer codexCacheMutex.Unlock()
|
||||
codexCacheCleanupOnce.Do(startCodexCacheCleanup)
|
||||
codexCacheMu.Lock()
|
||||
codexCacheMap[key] = cache
|
||||
codexCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// deleteCodexCache safely deletes a cache entry
|
||||
// deleteCodexCache deletes a cache entry.
|
||||
func deleteCodexCache(key string) {
|
||||
codexCacheMutex.Lock()
|
||||
defer codexCacheMutex.Unlock()
|
||||
codexCacheMu.Lock()
|
||||
delete(codexCacheMap, key)
|
||||
codexCacheMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -35,11 +35,53 @@ type ClaudeExecutor struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
const claudeToolPrefix = "proxy_"
|
||||
|
||||
func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} }
|
||||
|
||||
func (e *ClaudeExecutor) Identifier() string { return "claude" }
|
||||
|
||||
func (e *ClaudeExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
// PrepareRequest injects Claude credentials into the outgoing HTTP request.
|
||||
func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
apiKey, _ := claudeCreds(auth)
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil
|
||||
}
|
||||
useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != ""
|
||||
isAnthropicBase := req.URL != nil && strings.EqualFold(req.URL.Scheme, "https") && strings.EqualFold(req.URL.Host, "api.anthropic.com")
|
||||
if isAnthropicBase && useAPIKey {
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
} else {
|
||||
req.Header.Del("x-api-key")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
var attrs map[string]string
|
||||
if auth != nil {
|
||||
attrs = auth.Attributes
|
||||
}
|
||||
util.ApplyCustomHeadersFromAttrs(req, attrs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Claude credentials into the request and executes it.
|
||||
func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("claude executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
apiKey, baseURL := claudeCreds(auth)
|
||||
@@ -81,9 +123,14 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
// Extract betas from body and convert to header
|
||||
var extraBetas []string
|
||||
extraBetas, body = extractAndRemoveBetas(body)
|
||||
bodyForTranslation := body
|
||||
bodyForUpstream := body
|
||||
if isClaudeOAuthToken(apiKey) {
|
||||
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -98,7 +145,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
URL: url,
|
||||
Method: http.MethodPost,
|
||||
Headers: httpReq.Header.Clone(),
|
||||
Body: body,
|
||||
Body: bodyForUpstream,
|
||||
Provider: e.Identifier(),
|
||||
AuthID: authID,
|
||||
AuthLabel: authLabel,
|
||||
@@ -152,8 +199,20 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
} else {
|
||||
reporter.publish(ctx, parseClaudeUsage(data))
|
||||
}
|
||||
if isClaudeOAuthToken(apiKey) {
|
||||
data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix)
|
||||
}
|
||||
var param any
|
||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
||||
out := sdktranslator.TranslateNonStream(
|
||||
ctx,
|
||||
to,
|
||||
from,
|
||||
req.Model,
|
||||
bytes.Clone(opts.OriginalRequest),
|
||||
bodyForTranslation,
|
||||
data,
|
||||
¶m,
|
||||
)
|
||||
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -193,9 +252,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
// Extract betas from body and convert to header
|
||||
var extraBetas []string
|
||||
extraBetas, body = extractAndRemoveBetas(body)
|
||||
bodyForTranslation := body
|
||||
bodyForUpstream := body
|
||||
if isClaudeOAuthToken(apiKey) {
|
||||
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -210,7 +274,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
URL: url,
|
||||
Method: http.MethodPost,
|
||||
Headers: httpReq.Header.Clone(),
|
||||
Body: body,
|
||||
Body: bodyForUpstream,
|
||||
Provider: e.Identifier(),
|
||||
AuthID: authID,
|
||||
AuthLabel: authLabel,
|
||||
@@ -263,6 +327,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
if detail, ok := parseClaudeStreamUsage(line); ok {
|
||||
reporter.publish(ctx, detail)
|
||||
}
|
||||
if isClaudeOAuthToken(apiKey) {
|
||||
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
||||
}
|
||||
// Forward the line as-is to preserve SSE format
|
||||
cloned := make([]byte, len(line)+1)
|
||||
copy(cloned, line)
|
||||
@@ -287,7 +354,19 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
if detail, ok := parseClaudeStreamUsage(line); ok {
|
||||
reporter.publish(ctx, detail)
|
||||
}
|
||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m)
|
||||
if isClaudeOAuthToken(apiKey) {
|
||||
line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
|
||||
}
|
||||
chunks := sdktranslator.TranslateStream(
|
||||
ctx,
|
||||
to,
|
||||
from,
|
||||
req.Model,
|
||||
bytes.Clone(opts.OriginalRequest),
|
||||
bodyForTranslation,
|
||||
bytes.Clone(line),
|
||||
¶m,
|
||||
)
|
||||
for i := range chunks {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
||||
}
|
||||
@@ -326,6 +405,9 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
||||
// Extract betas from body and convert to header (for count_tokens too)
|
||||
var extraBetas []string
|
||||
extraBetas, body = extractAndRemoveBetas(body)
|
||||
if isClaudeOAuthToken(apiKey) {
|
||||
body = applyClaudeToolPrefix(body, claudeToolPrefix)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
@@ -770,3 +852,107 @@ func checkSystemInstructions(payload []byte) []byte {
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func isClaudeOAuthToken(apiKey string) bool {
|
||||
return strings.Contains(apiKey, "sk-ant-oat")
|
||||
}
|
||||
|
||||
func applyClaudeToolPrefix(body []byte, prefix string) []byte {
|
||||
if prefix == "" {
|
||||
return body
|
||||
}
|
||||
|
||||
if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() {
|
||||
tools.ForEach(func(index, tool gjson.Result) bool {
|
||||
name := tool.Get("name").String()
|
||||
if name == "" || strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
path := fmt.Sprintf("tools.%d.name", index.Int())
|
||||
body, _ = sjson.SetBytes(body, path, prefix+name)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if gjson.GetBytes(body, "tool_choice.type").String() == "tool" {
|
||||
name := gjson.GetBytes(body, "tool_choice.name").String()
|
||||
if name != "" && !strings.HasPrefix(name, prefix) {
|
||||
body, _ = sjson.SetBytes(body, "tool_choice.name", prefix+name)
|
||||
}
|
||||
}
|
||||
|
||||
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||
messages.ForEach(func(msgIndex, msg gjson.Result) bool {
|
||||
content := msg.Get("content")
|
||||
if !content.Exists() || !content.IsArray() {
|
||||
return true
|
||||
}
|
||||
content.ForEach(func(contentIndex, part gjson.Result) bool {
|
||||
if part.Get("type").String() != "tool_use" {
|
||||
return true
|
||||
}
|
||||
name := part.Get("name").String()
|
||||
if name == "" || strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
|
||||
body, _ = sjson.SetBytes(body, path, prefix+name)
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte {
|
||||
if prefix == "" {
|
||||
return body
|
||||
}
|
||||
content := gjson.GetBytes(body, "content")
|
||||
if !content.Exists() || !content.IsArray() {
|
||||
return body
|
||||
}
|
||||
content.ForEach(func(index, part gjson.Result) bool {
|
||||
if part.Get("type").String() != "tool_use" {
|
||||
return true
|
||||
}
|
||||
name := part.Get("name").String()
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
path := fmt.Sprintf("content.%d.name", index.Int())
|
||||
body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(name, prefix))
|
||||
return true
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
|
||||
if prefix == "" {
|
||||
return line
|
||||
}
|
||||
payload := jsonPayload(line)
|
||||
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
||||
return line
|
||||
}
|
||||
contentBlock := gjson.GetBytes(payload, "content_block")
|
||||
if !contentBlock.Exists() || contentBlock.Get("type").String() != "tool_use" {
|
||||
return line
|
||||
}
|
||||
name := contentBlock.Get("name").String()
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return line
|
||||
}
|
||||
updated, err := sjson.SetBytes(payload, "content_block.name", strings.TrimPrefix(name, prefix))
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if bytes.HasPrefix(trimmed, []byte("data:")) {
|
||||
return append([]byte("data: "), updated...)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
51
internal/runtime/executor/claude_executor_test.go
Normal file
51
internal/runtime/executor/claude_executor_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestApplyClaudeToolPrefix(t *testing.T) {
|
||||
input := []byte(`{"tools":[{"name":"alpha"},{"name":"proxy_bravo"}],"tool_choice":{"type":"tool","name":"charlie"},"messages":[{"role":"assistant","content":[{"type":"tool_use","name":"delta","id":"t1","input":{}}]}]}`)
|
||||
out := applyClaudeToolPrefix(input, "proxy_")
|
||||
|
||||
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_alpha" {
|
||||
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_alpha")
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_bravo" {
|
||||
t.Fatalf("tools.1.name = %q, want %q", got, "proxy_bravo")
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "proxy_charlie" {
|
||||
t.Fatalf("tool_choice.name = %q, want %q", got, "proxy_charlie")
|
||||
}
|
||||
if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_delta" {
|
||||
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_delta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripClaudeToolPrefixFromResponse(t *testing.T) {
|
||||
input := []byte(`{"content":[{"type":"tool_use","name":"proxy_alpha","id":"t1","input":{}},{"type":"tool_use","name":"bravo","id":"t2","input":{}}]}`)
|
||||
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
|
||||
|
||||
if got := gjson.GetBytes(out, "content.0.name").String(); got != "alpha" {
|
||||
t.Fatalf("content.0.name = %q, want %q", got, "alpha")
|
||||
}
|
||||
if got := gjson.GetBytes(out, "content.1.name").String(); got != "bravo" {
|
||||
t.Fatalf("content.1.name = %q, want %q", got, "bravo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripClaudeToolPrefixFromStreamLine(t *testing.T) {
|
||||
line := []byte(`data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"proxy_alpha","id":"t1"},"index":0}`)
|
||||
out := stripClaudeToolPrefixFromStreamLine(line, "proxy_")
|
||||
|
||||
payload := bytes.TrimSpace(out)
|
||||
if bytes.HasPrefix(payload, []byte("data:")) {
|
||||
payload = bytes.TrimSpace(payload[len("data:"):])
|
||||
}
|
||||
if got := gjson.GetBytes(payload, "content_block.name").String(); got != "alpha" {
|
||||
t.Fatalf("content_block.name = %q, want %q", got, "alpha")
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,38 @@ func NewCodexExecutor(cfg *config.Config) *CodexExecutor { return &CodexExecutor
|
||||
|
||||
func (e *CodexExecutor) Identifier() string { return "codex" }
|
||||
|
||||
func (e *CodexExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
// PrepareRequest injects Codex credentials into the outgoing HTTP request.
|
||||
func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
apiKey, _ := codexCreds(auth)
|
||||
if strings.TrimSpace(apiKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
var attrs map[string]string
|
||||
if auth != nil {
|
||||
attrs = auth.Attributes
|
||||
}
|
||||
util.ApplyCustomHeadersFromAttrs(req, attrs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Codex credentials into the request and executes it.
|
||||
func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("codex executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
apiKey, baseURL := codexCreds(auth)
|
||||
@@ -457,9 +488,9 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
|
||||
if from == "claude" {
|
||||
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
|
||||
if userIDResult.Exists() {
|
||||
var hasKey bool
|
||||
key := fmt.Sprintf("%s-%s", req.Model, userIDResult.String())
|
||||
if cache, hasKey = getCodexCache(key); !hasKey || cache.Expire.Before(time.Now()) {
|
||||
var ok bool
|
||||
if cache, ok = getCodexCache(key); !ok {
|
||||
cache = codexCache{
|
||||
ID: uuid.New().String(),
|
||||
Expire: time.Now().Add(1 * time.Hour),
|
||||
|
||||
@@ -63,8 +63,42 @@ func NewGeminiCLIExecutor(cfg *config.Config) *GeminiCLIExecutor {
|
||||
// Identifier returns the executor identifier.
|
||||
func (e *GeminiCLIExecutor) Identifier() string { return "gemini-cli" }
|
||||
|
||||
// PrepareRequest prepares the HTTP request for execution (no-op for Gemini CLI).
|
||||
func (e *GeminiCLIExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
// PrepareRequest injects Gemini CLI credentials into the outgoing HTTP request.
|
||||
func (e *GeminiCLIExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
tokenSource, _, errSource := prepareGeminiCLITokenSource(req.Context(), e.cfg, auth)
|
||||
if errSource != nil {
|
||||
return errSource
|
||||
}
|
||||
tok, errTok := tokenSource.Token()
|
||||
if errTok != nil {
|
||||
return errTok
|
||||
}
|
||||
if strings.TrimSpace(tok.AccessToken) == "" {
|
||||
return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
||||
applyGeminiCLIHeaders(req)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Gemini CLI credentials into the request and executes it.
|
||||
func (e *GeminiCLIExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("gemini-cli executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// Execute performs a non-streaming request to the Gemini CLI API.
|
||||
func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
|
||||
@@ -55,8 +55,38 @@ func NewGeminiExecutor(cfg *config.Config) *GeminiExecutor {
|
||||
// Identifier returns the executor identifier.
|
||||
func (e *GeminiExecutor) Identifier() string { return "gemini" }
|
||||
|
||||
// PrepareRequest prepares the HTTP request for execution (no-op for Gemini).
|
||||
func (e *GeminiExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
// PrepareRequest injects Gemini credentials into the outgoing HTTP request.
|
||||
func (e *GeminiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
apiKey, bearer := geminiCreds(auth)
|
||||
if apiKey != "" {
|
||||
req.Header.Set("x-goog-api-key", apiKey)
|
||||
req.Header.Del("Authorization")
|
||||
} else if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
req.Header.Del("x-goog-api-key")
|
||||
}
|
||||
applyGeminiHeaders(req, auth)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Gemini credentials into the request and executes it.
|
||||
func (e *GeminiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("gemini executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// Execute performs a non-streaming request to the Gemini API.
|
||||
// It translates the request to Gemini format, sends it to the API, and translates
|
||||
|
||||
@@ -50,11 +50,49 @@ func NewGeminiVertexExecutor(cfg *config.Config) *GeminiVertexExecutor {
|
||||
// Identifier returns the executor identifier.
|
||||
func (e *GeminiVertexExecutor) Identifier() string { return "vertex" }
|
||||
|
||||
// PrepareRequest prepares the HTTP request for execution (no-op for Vertex).
|
||||
func (e *GeminiVertexExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
|
||||
// PrepareRequest injects Vertex credentials into the outgoing HTTP request.
|
||||
func (e *GeminiVertexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
apiKey, _ := vertexAPICreds(auth)
|
||||
if strings.TrimSpace(apiKey) != "" {
|
||||
req.Header.Set("x-goog-api-key", apiKey)
|
||||
req.Header.Del("Authorization")
|
||||
return nil
|
||||
}
|
||||
_, _, saJSON, errCreds := vertexCreds(auth)
|
||||
if errCreds != nil {
|
||||
return errCreds
|
||||
}
|
||||
token, errToken := vertexAccessToken(req.Context(), e.cfg, auth, saJSON)
|
||||
if errToken != nil {
|
||||
return errToken
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Del("x-goog-api-key")
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Vertex credentials into the request and executes it.
|
||||
func (e *GeminiVertexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("vertex executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// Execute performs a non-streaming request to the Vertex AI API.
|
||||
func (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
// Try API key authentication first
|
||||
|
||||
@@ -63,10 +63,38 @@ func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
|
||||
func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
|
||||
|
||||
// PrepareRequest implements ProviderExecutor.
|
||||
func (e *GitHubCopilotExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error {
|
||||
func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := req.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
apiToken, errToken := e.ensureAPIToken(ctx, auth)
|
||||
if errToken != nil {
|
||||
return errToken
|
||||
}
|
||||
e.applyHeaders(req, apiToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects GitHub Copilot credentials into the request and executes it.
|
||||
func (e *GitHubCopilotExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("github-copilot executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if errPrepare := e.PrepareRequest(httpReq, auth); errPrepare != nil {
|
||||
return nil, errPrepare
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// Execute handles non-streaming requests to GitHub Copilot.
|
||||
func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
apiToken, errToken := e.ensureAPIToken(ctx, auth)
|
||||
|
||||
@@ -37,8 +37,33 @@ func NewIFlowExecutor(cfg *config.Config) *IFlowExecutor { return &IFlowExecutor
|
||||
// Identifier returns the provider key.
|
||||
func (e *IFlowExecutor) Identifier() string { return "iflow" }
|
||||
|
||||
// PrepareRequest implements ProviderExecutor but requires no preprocessing.
|
||||
func (e *IFlowExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
// PrepareRequest injects iFlow credentials into the outgoing HTTP request.
|
||||
func (e *IFlowExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
apiKey, _ := iflowCreds(auth)
|
||||
if strings.TrimSpace(apiKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects iFlow credentials into the request and executes it.
|
||||
func (e *IFlowExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("iflow executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// Execute performs a non-streaming chat completion request.
|
||||
func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -218,7 +217,48 @@ func NewKiroExecutor(cfg *config.Config) *KiroExecutor {
|
||||
func (e *KiroExecutor) Identifier() string { return "kiro" }
|
||||
|
||||
// PrepareRequest prepares the HTTP request before execution.
|
||||
func (e *KiroExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
func (e *KiroExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
accessToken, _ := kiroCredentials(auth)
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||
}
|
||||
if isIDCAuth(auth) {
|
||||
req.Header.Set("User-Agent", kiroIDEUserAgent)
|
||||
req.Header.Set("X-Amz-User-Agent", kiroIDEAmzUserAgent)
|
||||
req.Header.Set("x-amzn-kiro-agent-mode", kiroIDEAgentModeSpec)
|
||||
} else {
|
||||
req.Header.Set("User-Agent", kiroUserAgent)
|
||||
req.Header.Set("X-Amz-User-Agent", kiroFullUserAgent)
|
||||
}
|
||||
req.Header.Set("Amz-Sdk-Request", "attempt=1; max=3")
|
||||
req.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String())
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
var attrs map[string]string
|
||||
if auth != nil {
|
||||
attrs = auth.Attributes
|
||||
}
|
||||
util.ApplyCustomHeadersFromAttrs(req, attrs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Kiro credentials into the request and executes it.
|
||||
func (e *KiroExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("kiro executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if errPrepare := e.PrepareRequest(httpReq, auth); errPrepare != nil {
|
||||
return nil, errPrepare
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// Execute sends the request to Kiro API and returns the response.
|
||||
// Supports automatic token refresh on 401/403 errors.
|
||||
@@ -1004,7 +1044,7 @@ func findRealThinkingEndTag(content string, alreadyInCodeBlock, alreadyInInlineC
|
||||
discussionPatterns := []string{
|
||||
"标签", "返回", "输出", "包含", "使用", "解析", "转换", "生成", // Chinese
|
||||
"tag", "return", "output", "contain", "use", "parse", "emit", "convert", "generate", // English
|
||||
"<thinking>", // discussing both tags together
|
||||
"<thinking>", // discussing both tags together
|
||||
"`</thinking>`", // explicitly in inline code
|
||||
}
|
||||
isDiscussion := false
|
||||
@@ -1852,7 +1892,6 @@ func (e *KiroExecutor) extractEventTypeFromBytes(headers []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// NOTE: Response building functions moved to internal/translator/kiro/claude/kiro_claude_response.go
|
||||
// The executor now uses kiroclaude.BuildClaudeResponse() and kiroclaude.ExtractThinkingFromContent() instead
|
||||
|
||||
@@ -1889,18 +1928,18 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
||||
var lastReportedOutputTokens int64 // Last reported output token count
|
||||
|
||||
// Upstream usage tracking - Kiro API returns credit usage and context percentage
|
||||
var upstreamCreditUsage float64 // Credit usage from upstream (e.g., 1.458)
|
||||
var upstreamContextPercentage float64 // Context usage percentage from upstream (e.g., 78.56)
|
||||
var hasUpstreamUsage bool // Whether we received usage from upstream
|
||||
var upstreamCreditUsage float64 // Credit usage from upstream (e.g., 1.458)
|
||||
var upstreamContextPercentage float64 // Context usage percentage from upstream (e.g., 78.56)
|
||||
var hasUpstreamUsage bool // Whether we received usage from upstream
|
||||
|
||||
// Translator param for maintaining tool call state across streaming events
|
||||
// IMPORTANT: This must persist across all TranslateStream calls
|
||||
var translatorParam any
|
||||
|
||||
// Thinking mode state tracking - tag-based parsing for <thinking> tags in content
|
||||
inThinkBlock := false // Whether we're currently inside a <thinking> block
|
||||
isThinkingBlockOpen := false // Track if thinking content block SSE event is open
|
||||
thinkingBlockIndex := -1 // Index of the thinking content block
|
||||
inThinkBlock := false // Whether we're currently inside a <thinking> block
|
||||
isThinkingBlockOpen := false // Track if thinking content block SSE event is open
|
||||
thinkingBlockIndex := -1 // Index of the thinking content block
|
||||
var accumulatedThinkingContent strings.Builder // Accumulate thinking content for token counting
|
||||
|
||||
// Buffer for handling partial tag matches at chunk boundaries
|
||||
@@ -2319,16 +2358,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
||||
|
||||
lastUsageUpdateLen = accumulatedContent.Len()
|
||||
lastUsageUpdateTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// TAG-BASED THINKING PARSING: Parse <thinking> tags from content
|
||||
// Combine pending content with new content for processing
|
||||
pendingContent.WriteString(contentDelta)
|
||||
processContent := pendingContent.String()
|
||||
pendingContent.Reset()
|
||||
// TAG-BASED THINKING PARSING: Parse <thinking> tags from content
|
||||
// Combine pending content with new content for processing
|
||||
pendingContent.WriteString(contentDelta)
|
||||
processContent := pendingContent.String()
|
||||
pendingContent.Reset()
|
||||
|
||||
// Process content looking for thinking tags
|
||||
for len(processContent) > 0 {
|
||||
// Process content looking for thinking tags
|
||||
for len(processContent) > 0 {
|
||||
if inThinkBlock {
|
||||
// We're inside a thinking block, look for </thinking>
|
||||
endIdx := strings.Index(processContent, kirocommon.ThinkingEndTag)
|
||||
@@ -2503,7 +2542,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
||||
processContent = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool uses in response (with deduplication)
|
||||
@@ -2927,7 +2966,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
||||
// Calculate input tokens from context percentage
|
||||
// Using 200k as the base since that's what Kiro reports against
|
||||
calculatedInputTokens := int64(upstreamContextPercentage * 200000 / 100)
|
||||
|
||||
|
||||
// Only use calculated value if it's significantly different from local estimate
|
||||
// This provides more accurate token counts based on upstream data
|
||||
if calculatedInputTokens > 0 {
|
||||
|
||||
@@ -304,11 +304,7 @@ func formatAuthInfo(info upstreamRequestLog) string {
|
||||
parts = append(parts, "type=api_key")
|
||||
}
|
||||
case "oauth":
|
||||
if authValue != "" {
|
||||
parts = append(parts, fmt.Sprintf("type=oauth account=%s", authValue))
|
||||
} else {
|
||||
parts = append(parts, "type=oauth")
|
||||
}
|
||||
parts = append(parts, "type=oauth")
|
||||
default:
|
||||
if authType != "" {
|
||||
if authValue != "" {
|
||||
|
||||
@@ -35,11 +35,39 @@ func NewOpenAICompatExecutor(provider string, cfg *config.Config) *OpenAICompatE
|
||||
// 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 {
|
||||
// PrepareRequest injects OpenAI-compatible credentials into the outgoing HTTP request.
|
||||
func (e *OpenAICompatExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
_, apiKey := e.resolveCredentials(auth)
|
||||
if strings.TrimSpace(apiKey) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
var attrs map[string]string
|
||||
if auth != nil {
|
||||
attrs = auth.Attributes
|
||||
}
|
||||
util.ApplyCustomHeadersFromAttrs(req, attrs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects OpenAI-compatible credentials into the request and executes it.
|
||||
func (e *OpenAICompatExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("openai compat executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
defer reporter.trackFailure(ctx, &err)
|
||||
@@ -241,6 +269,11 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(line, []byte("data:")) {
|
||||
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), ¶m)
|
||||
|
||||
@@ -36,7 +36,33 @@ func NewQwenExecutor(cfg *config.Config) *QwenExecutor { return &QwenExecutor{cf
|
||||
|
||||
func (e *QwenExecutor) Identifier() string { return "qwen" }
|
||||
|
||||
func (e *QwenExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
// PrepareRequest injects Qwen credentials into the outgoing HTTP request.
|
||||
func (e *QwenExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
token, _ := qwenCreds(auth)
|
||||
if strings.TrimSpace(token) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Qwen credentials into the request and executes it.
|
||||
func (e *QwenExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("qwen executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
token, baseURL := qwenCreds(auth)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
@@ -136,14 +135,14 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
if sessionID != "" && thinkingText != "" {
|
||||
if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" {
|
||||
signature = cachedSig
|
||||
log.Debugf("Using cached signature for thinking block")
|
||||
// log.Debugf("Using cached signature for thinking block")
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to client signature only if cache miss and client signature is valid
|
||||
if signature == "" && cache.HasValidSignature(clientSignature) {
|
||||
signature = clientSignature
|
||||
log.Debugf("Using client-provided signature for thinking block")
|
||||
// log.Debugf("Using client-provided signature for thinking block")
|
||||
}
|
||||
|
||||
// Store for subsequent tool_use in the same message
|
||||
@@ -158,8 +157,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
||||
// Converting to text would break this requirement
|
||||
if isUnsigned {
|
||||
// TypeScript plugin approach: drop unsigned thinking blocks entirely
|
||||
log.Debugf("Dropping unsigned thinking block (no valid signature)")
|
||||
// log.Debugf("Dropping unsigned thinking block (no valid signature)")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -183,7 +181,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
||||
// NOTE: Do NOT inject dummy thinking blocks here.
|
||||
// Antigravity API validates signatures, so dummy values are rejected.
|
||||
// The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies.
|
||||
|
||||
functionName := contentResult.Get("name").String()
|
||||
argsResult := contentResult.Get("input")
|
||||
|
||||
@@ -136,11 +136,11 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
||||
// Process thinking content (internal reasoning)
|
||||
if partResult.Get("thought").Bool() {
|
||||
if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
|
||||
log.Debug("Branch: signature_delta")
|
||||
// log.Debug("Branch: signature_delta")
|
||||
|
||||
if params.SessionID != "" && params.CurrentThinkingText.Len() > 0 {
|
||||
cache.CacheSignature(params.SessionID, params.CurrentThinkingText.String(), thoughtSignature.String())
|
||||
log.Debugf("Cached signature for thinking block (sessionID=%s, textLen=%d)", params.SessionID, params.CurrentThinkingText.Len())
|
||||
// log.Debugf("Cached signature for thinking block (sessionID=%s, textLen=%d)", params.SessionID, params.CurrentThinkingText.Len())
|
||||
params.CurrentThinkingText.Reset()
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,16 @@ type claudeToResponsesState struct {
|
||||
|
||||
var dataTag = []byte("data:")
|
||||
|
||||
func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
|
||||
if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
|
||||
return originalRequestRawJSON
|
||||
}
|
||||
if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
|
||||
return requestRawJSON
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func emitEvent(event string, payload string) string {
|
||||
return fmt.Sprintf("event: %s\ndata: %s", event, payload)
|
||||
}
|
||||
@@ -279,8 +289,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
completed, _ = sjson.Set(completed, "response.created_at", st.CreatedAt)
|
||||
// Inject original request fields into response as per docs/response.completed.json
|
||||
|
||||
if requestRawJSON != nil {
|
||||
req := gjson.ParseBytes(requestRawJSON)
|
||||
reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
|
||||
if len(reqBytes) > 0 {
|
||||
req := gjson.ParseBytes(reqBytes)
|
||||
if v := req.Get("instructions"); v.Exists() {
|
||||
completed, _ = sjson.Set(completed, "response.instructions", v.String())
|
||||
}
|
||||
@@ -549,8 +560,9 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
|
||||
out, _ = sjson.Set(out, "created_at", createdAt)
|
||||
|
||||
// Inject request echo fields as top-level (similar to streaming variant)
|
||||
if requestRawJSON != nil {
|
||||
req := gjson.ParseBytes(requestRawJSON)
|
||||
reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
|
||||
if len(reqBytes) > 0 {
|
||||
req := gjson.ParseBytes(reqBytes)
|
||||
if v := req.Get("instructions"); v.Exists() {
|
||||
out, _ = sjson.Set(out, "instructions", v.String())
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ var (
|
||||
dataTag = []byte("data:")
|
||||
)
|
||||
|
||||
// ConvertCodexResponseToClaudeParams holds parameters for response conversion.
|
||||
type ConvertCodexResponseToClaudeParams struct {
|
||||
HasToolCall bool
|
||||
BlockIndex int
|
||||
}
|
||||
|
||||
// ConvertCodexResponseToClaude performs sophisticated streaming response format conversion.
|
||||
// This function implements a complex state machine that translates Codex API responses
|
||||
// into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types
|
||||
@@ -38,8 +44,10 @@ var (
|
||||
// - []string: A slice of strings, each containing a Claude Code-compatible JSON response
|
||||
func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
|
||||
if *param == nil {
|
||||
hasToolCall := false
|
||||
*param = &hasToolCall
|
||||
*param = &ConvertCodexResponseToClaudeParams{
|
||||
HasToolCall: false,
|
||||
BlockIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// log.Debugf("rawJSON: %s", string(rawJSON))
|
||||
@@ -62,46 +70,49 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
} else if typeStr == "response.reasoning_summary_part.added" {
|
||||
template = `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
|
||||
output = "event: content_block_start\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
} else if typeStr == "response.reasoning_summary_text.delta" {
|
||||
template = `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
template, _ = sjson.Set(template, "delta.thinking", rootResult.Get("delta").String())
|
||||
|
||||
output = "event: content_block_delta\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
} else if typeStr == "response.reasoning_summary_part.done" {
|
||||
template = `{"type":"content_block_stop","index":0}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
(*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++
|
||||
|
||||
output = "event: content_block_stop\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
|
||||
} else if typeStr == "response.content_part.added" {
|
||||
template = `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
|
||||
output = "event: content_block_start\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
} else if typeStr == "response.output_text.delta" {
|
||||
template = `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
template, _ = sjson.Set(template, "delta.text", rootResult.Get("delta").String())
|
||||
|
||||
output = "event: content_block_delta\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
} else if typeStr == "response.content_part.done" {
|
||||
template = `{"type":"content_block_stop","index":0}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
(*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++
|
||||
|
||||
output = "event: content_block_stop\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
} else if typeStr == "response.completed" {
|
||||
template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
||||
p := (*param).(*bool)
|
||||
if *p {
|
||||
p := (*param).(*ConvertCodexResponseToClaudeParams).HasToolCall
|
||||
if p {
|
||||
template, _ = sjson.Set(template, "delta.stop_reason", "tool_use")
|
||||
} else {
|
||||
template, _ = sjson.Set(template, "delta.stop_reason", "end_turn")
|
||||
@@ -118,10 +129,9 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
itemResult := rootResult.Get("item")
|
||||
itemType := itemResult.Get("type").String()
|
||||
if itemType == "function_call" {
|
||||
p := true
|
||||
*param = &p
|
||||
(*param).(*ConvertCodexResponseToClaudeParams).HasToolCall = true
|
||||
template = `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
template, _ = sjson.Set(template, "content_block.id", itemResult.Get("call_id").String())
|
||||
{
|
||||
// Restore original tool name if shortened
|
||||
@@ -137,7 +147,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
|
||||
template = `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
|
||||
output += "event: content_block_delta\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
@@ -147,14 +157,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
itemType := itemResult.Get("type").String()
|
||||
if itemType == "function_call" {
|
||||
template = `{"type":"content_block_stop","index":0}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
(*param).(*ConvertCodexResponseToClaudeParams).BlockIndex++
|
||||
|
||||
output = "event: content_block_stop\n"
|
||||
output += fmt.Sprintf("data: %s\n\n", template)
|
||||
}
|
||||
} else if typeStr == "response.function_call_arguments.delta" {
|
||||
template = `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
||||
template, _ = sjson.Set(template, "index", rootResult.Get("output_index").Int())
|
||||
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
|
||||
template, _ = sjson.Set(template, "delta.partial_json", rootResult.Get("delta").String())
|
||||
|
||||
output += "event: content_block_delta\n"
|
||||
|
||||
@@ -275,7 +275,15 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
||||
arr := tools.Array()
|
||||
for i := 0; i < len(arr); i++ {
|
||||
t := arr[i]
|
||||
if t.Get("type").String() == "function" {
|
||||
toolType := t.Get("type").String()
|
||||
// Pass through built-in tools (e.g. {"type":"web_search"}) directly for the Responses API.
|
||||
// Only "function" needs structural conversion because Chat Completions nests details under "function".
|
||||
if toolType != "" && toolType != "function" && t.IsObject() {
|
||||
out, _ = sjson.SetRaw(out, "tools.-1", t.Raw)
|
||||
continue
|
||||
}
|
||||
|
||||
if toolType == "function" {
|
||||
item := `{}`
|
||||
item, _ = sjson.Set(item, "type", "function")
|
||||
fn := t.Get("function")
|
||||
@@ -304,6 +312,37 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
||||
}
|
||||
}
|
||||
|
||||
// Map tool_choice when present.
|
||||
// Chat Completions: "tool_choice" can be a string ("auto"/"none") or an object (e.g. {"type":"function","function":{"name":"..."}}).
|
||||
// Responses API: keep built-in tool choices as-is; flatten function choice to {"type":"function","name":"..."}.
|
||||
if tc := gjson.GetBytes(rawJSON, "tool_choice"); tc.Exists() {
|
||||
switch {
|
||||
case tc.Type == gjson.String:
|
||||
out, _ = sjson.Set(out, "tool_choice", tc.String())
|
||||
case tc.IsObject():
|
||||
tcType := tc.Get("type").String()
|
||||
if tcType == "function" {
|
||||
name := tc.Get("function.name").String()
|
||||
if name != "" {
|
||||
if short, ok := originalToolNameMap[name]; ok {
|
||||
name = short
|
||||
} else {
|
||||
name = shortenNameIfNeeded(name)
|
||||
}
|
||||
}
|
||||
choice := `{}`
|
||||
choice, _ = sjson.Set(choice, "type", "function")
|
||||
if name != "" {
|
||||
choice, _ = sjson.Set(choice, "name", name)
|
||||
}
|
||||
out, _ = sjson.SetRaw(out, "tool_choice", choice)
|
||||
} else if tcType != "" {
|
||||
// Built-in tool choices (e.g. {"type":"web_search"}) are already Responses-compatible.
|
||||
out, _ = sjson.SetRaw(out, "tool_choice", tc.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out, _ = sjson.Set(out, "store", false)
|
||||
return []byte(out)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,14 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
|
||||
var chatCompletionsTools []interface{}
|
||||
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
// Built-in tools (e.g. {"type":"web_search"}) are already compatible with the Chat Completions schema.
|
||||
// Only function tools need structural conversion because Chat Completions nests details under "function".
|
||||
toolType := tool.Get("type").String()
|
||||
if toolType != "" && toolType != "function" && tool.IsObject() {
|
||||
chatCompletionsTools = append(chatCompletionsTools, tool.Value())
|
||||
return true
|
||||
}
|
||||
|
||||
chatTool := `{"type":"function","function":{}}`
|
||||
|
||||
// Convert tool structure from responses format to chat completions format
|
||||
|
||||
@@ -21,6 +21,7 @@ type ManagementTokenRequester interface {
|
||||
RequestIFlowToken(*gin.Context)
|
||||
RequestIFlowCookieToken(*gin.Context)
|
||||
GetAuthStatus(c *gin.Context)
|
||||
PostOAuthCallback(c *gin.Context)
|
||||
}
|
||||
|
||||
type managementTokenRequester struct {
|
||||
@@ -65,3 +66,7 @@ func (m *managementTokenRequester) RequestIFlowCookieToken(c *gin.Context) {
|
||||
func (m *managementTokenRequester) GetAuthStatus(c *gin.Context) {
|
||||
m.handler.GetAuthStatus(c)
|
||||
}
|
||||
|
||||
func (m *managementTokenRequester) PostOAuthCallback(c *gin.Context) {
|
||||
m.handler.PostOAuthCallback(c)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -31,6 +34,9 @@ type ProviderExecutor interface {
|
||||
Refresh(ctx context.Context, auth *Auth) (*Auth, error)
|
||||
// CountTokens returns the token count for the given request.
|
||||
CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error)
|
||||
// HttpRequest injects provider credentials into the supplied HTTP request and executes it.
|
||||
// Callers must close the response body when non-nil.
|
||||
HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// RefreshEvaluator allows runtime state to override refresh decisions.
|
||||
@@ -388,22 +394,8 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req
|
||||
return cliproxyexecutor.Response{}, errPick
|
||||
}
|
||||
|
||||
accountType, accountInfo := auth.AccountInfo()
|
||||
proxyInfo := auth.ProxyInfo()
|
||||
entry := logEntryWithRequestID(ctx)
|
||||
if accountType == "api_key" {
|
||||
if proxyInfo != "" {
|
||||
entry.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo)
|
||||
} else {
|
||||
entry.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
|
||||
}
|
||||
} else if accountType == "oauth" {
|
||||
if proxyInfo != "" {
|
||||
entry.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo)
|
||||
} else {
|
||||
entry.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
|
||||
}
|
||||
}
|
||||
debugLogAuthSelection(entry, auth, provider, req.Model)
|
||||
|
||||
tried[auth.ID] = struct{}{}
|
||||
execCtx := ctx
|
||||
@@ -450,22 +442,8 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string,
|
||||
return cliproxyexecutor.Response{}, errPick
|
||||
}
|
||||
|
||||
accountType, accountInfo := auth.AccountInfo()
|
||||
proxyInfo := auth.ProxyInfo()
|
||||
entry := logEntryWithRequestID(ctx)
|
||||
if accountType == "api_key" {
|
||||
if proxyInfo != "" {
|
||||
entry.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo)
|
||||
} else {
|
||||
entry.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
|
||||
}
|
||||
} else if accountType == "oauth" {
|
||||
if proxyInfo != "" {
|
||||
entry.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo)
|
||||
} else {
|
||||
entry.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
|
||||
}
|
||||
}
|
||||
debugLogAuthSelection(entry, auth, provider, req.Model)
|
||||
|
||||
tried[auth.ID] = struct{}{}
|
||||
execCtx := ctx
|
||||
@@ -512,22 +490,8 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
|
||||
return nil, errPick
|
||||
}
|
||||
|
||||
accountType, accountInfo := auth.AccountInfo()
|
||||
proxyInfo := auth.ProxyInfo()
|
||||
entry := logEntryWithRequestID(ctx)
|
||||
if accountType == "api_key" {
|
||||
if proxyInfo != "" {
|
||||
entry.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo)
|
||||
} else {
|
||||
entry.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
|
||||
}
|
||||
} else if accountType == "oauth" {
|
||||
if proxyInfo != "" {
|
||||
entry.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo)
|
||||
} else {
|
||||
entry.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
|
||||
}
|
||||
}
|
||||
debugLogAuthSelection(entry, auth, provider, req.Model)
|
||||
|
||||
tried[auth.ID] = struct{}{}
|
||||
execCtx := ctx
|
||||
@@ -1615,6 +1579,23 @@ type RequestPreparer interface {
|
||||
PrepareRequest(req *http.Request, auth *Auth) error
|
||||
}
|
||||
|
||||
func executorKeyFromAuth(auth *Auth) string {
|
||||
if auth == nil {
|
||||
return ""
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
providerKey := strings.TrimSpace(auth.Attributes["provider_key"])
|
||||
compatName := strings.TrimSpace(auth.Attributes["compat_name"])
|
||||
if compatName != "" {
|
||||
if providerKey == "" {
|
||||
providerKey = compatName
|
||||
}
|
||||
return strings.ToLower(providerKey)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||
}
|
||||
|
||||
// logEntryWithRequestID returns a logrus entry with request_id field if available in context.
|
||||
func logEntryWithRequestID(ctx context.Context) *log.Entry {
|
||||
if ctx == nil {
|
||||
@@ -1626,6 +1607,59 @@ func logEntryWithRequestID(ctx context.Context) *log.Entry {
|
||||
return log.NewEntry(log.StandardLogger())
|
||||
}
|
||||
|
||||
func debugLogAuthSelection(entry *log.Entry, auth *Auth, provider string, model string) {
|
||||
if !log.IsLevelEnabled(log.DebugLevel) {
|
||||
return
|
||||
}
|
||||
if entry == nil || auth == nil {
|
||||
return
|
||||
}
|
||||
accountType, accountInfo := auth.AccountInfo()
|
||||
proxyInfo := auth.ProxyInfo()
|
||||
suffix := ""
|
||||
if proxyInfo != "" {
|
||||
suffix = " " + proxyInfo
|
||||
}
|
||||
switch accountType {
|
||||
case "api_key":
|
||||
entry.Debugf("Use API key %s for model %s%s", util.HideAPIKey(accountInfo), model, suffix)
|
||||
case "oauth":
|
||||
ident := formatOauthIdentity(auth, provider, accountInfo)
|
||||
entry.Debugf("Use OAuth %s for model %s%s", ident, model, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func formatOauthIdentity(auth *Auth, provider string, accountInfo string) string {
|
||||
if auth == nil {
|
||||
return ""
|
||||
}
|
||||
// Prefer the auth's provider when available.
|
||||
providerName := strings.TrimSpace(auth.Provider)
|
||||
if providerName == "" {
|
||||
providerName = strings.TrimSpace(provider)
|
||||
}
|
||||
// Only log the basename to avoid leaking host paths.
|
||||
// FileName may be unset for some auth backends; fall back to ID.
|
||||
authFile := strings.TrimSpace(auth.FileName)
|
||||
if authFile == "" {
|
||||
authFile = strings.TrimSpace(auth.ID)
|
||||
}
|
||||
if authFile != "" {
|
||||
authFile = filepath.Base(authFile)
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if providerName != "" {
|
||||
parts = append(parts, "provider="+providerName)
|
||||
}
|
||||
if authFile != "" {
|
||||
parts = append(parts, "auth_file="+authFile)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return accountInfo
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// InjectCredentials delegates per-provider HTTP request preparation when supported.
|
||||
// If the registered executor for the auth provider implements RequestPreparer,
|
||||
// it will be invoked to modify the request (e.g., add headers).
|
||||
@@ -1637,7 +1671,7 @@ func (m *Manager) InjectCredentials(req *http.Request, authID string) error {
|
||||
a := m.auths[authID]
|
||||
var exec ProviderExecutor
|
||||
if a != nil {
|
||||
exec = m.executors[a.Provider]
|
||||
exec = m.executors[executorKeyFromAuth(a)]
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if a == nil || exec == nil {
|
||||
@@ -1648,3 +1682,80 @@ func (m *Manager) InjectCredentials(req *http.Request, authID string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrepareHttpRequest injects provider credentials into the supplied HTTP request.
|
||||
func (m *Manager) PrepareHttpRequest(ctx context.Context, auth *Auth, req *http.Request) error {
|
||||
if m == nil {
|
||||
return &Error{Code: "provider_not_found", Message: "manager is nil"}
|
||||
}
|
||||
if auth == nil {
|
||||
return &Error{Code: "auth_not_found", Message: "auth is nil"}
|
||||
}
|
||||
if req == nil {
|
||||
return &Error{Code: "invalid_request", Message: "http request is nil"}
|
||||
}
|
||||
if ctx != nil {
|
||||
*req = *req.WithContext(ctx)
|
||||
}
|
||||
providerKey := executorKeyFromAuth(auth)
|
||||
if providerKey == "" {
|
||||
return &Error{Code: "provider_not_found", Message: "auth provider is empty"}
|
||||
}
|
||||
exec := m.executorFor(providerKey)
|
||||
if exec == nil {
|
||||
return &Error{Code: "provider_not_found", Message: "executor not registered for provider: " + providerKey}
|
||||
}
|
||||
preparer, ok := exec.(RequestPreparer)
|
||||
if !ok || preparer == nil {
|
||||
return &Error{Code: "not_supported", Message: "executor does not support http request preparation"}
|
||||
}
|
||||
return preparer.PrepareRequest(req, auth)
|
||||
}
|
||||
|
||||
// NewHttpRequest constructs a new HTTP request and injects provider credentials into it.
|
||||
func (m *Manager) NewHttpRequest(ctx context.Context, auth *Auth, method, targetURL string, body []byte, headers http.Header) (*http.Request, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
method = strings.TrimSpace(method)
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, targetURL, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if headers != nil {
|
||||
httpReq.Header = headers.Clone()
|
||||
}
|
||||
if errPrepare := m.PrepareHttpRequest(ctx, auth, httpReq); errPrepare != nil {
|
||||
return nil, errPrepare
|
||||
}
|
||||
return httpReq, nil
|
||||
}
|
||||
|
||||
// HttpRequest injects provider credentials into the supplied HTTP request and executes it.
|
||||
func (m *Manager) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {
|
||||
if m == nil {
|
||||
return nil, &Error{Code: "provider_not_found", Message: "manager is nil"}
|
||||
}
|
||||
if auth == nil {
|
||||
return nil, &Error{Code: "auth_not_found", Message: "auth is nil"}
|
||||
}
|
||||
if req == nil {
|
||||
return nil, &Error{Code: "invalid_request", Message: "http request is nil"}
|
||||
}
|
||||
providerKey := executorKeyFromAuth(auth)
|
||||
if providerKey == "" {
|
||||
return nil, &Error{Code: "provider_not_found", Message: "auth provider is empty"}
|
||||
}
|
||||
exec := m.executorFor(providerKey)
|
||||
if exec == nil {
|
||||
return nil, &Error{Code: "provider_not_found", Message: "executor not registered for provider: " + providerKey}
|
||||
}
|
||||
return exec.HttpRequest(ctx, auth, req)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,8 @@ func OAuthModelMappingChannel(provider, authKind string) string {
|
||||
return "codex"
|
||||
case "gemini-cli", "aistudio", "antigravity", "qwen", "iflow":
|
||||
return provider
|
||||
case "kiro":
|
||||
return provider
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
54
test/builtin_tools_translation_test.go
Normal file
54
test/builtin_tools_translation_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
||||
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) {
|
||||
in := []byte(`{
|
||||
"model":"gpt-5",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"tools":[{"type":"web_search","search_context_size":"high"}],
|
||||
"tool_choice":{"type":"web_search"}
|
||||
}`)
|
||||
|
||||
out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAI, sdktranslator.FormatCodex, "gpt-5", in, false)
|
||||
|
||||
if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" {
|
||||
t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "high" {
|
||||
t.Fatalf("expected tools[0].search_context_size=high, got %q: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "web_search" {
|
||||
t.Fatalf("expected tool_choice.type=web_search, got %q: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesToOpenAI_PreservesBuiltinTools(t *testing.T) {
|
||||
in := []byte(`{
|
||||
"model":"gpt-5",
|
||||
"input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}],
|
||||
"tools":[{"type":"web_search","search_context_size":"low"}]
|
||||
}`)
|
||||
|
||||
out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-5", in, false)
|
||||
|
||||
if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" {
|
||||
t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "low" {
|
||||
t.Fatalf("expected tools[0].search_context_size=low, got %q: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user