Compare commits
5 Commits
v6.6.104
...
v6.6.109-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7248f65c36 | ||
|
|
086eb3df7a | ||
|
|
5a7e5bd870 | ||
|
|
6f8a8f8136 | ||
|
|
b163f8ed9e |
@@ -242,6 +242,10 @@ type ClaudeKey struct {
|
|||||||
// APIKey is the authentication key for accessing Claude API services.
|
// APIKey is the authentication key for accessing Claude API services.
|
||||||
APIKey string `yaml:"api-key" json:"api-key"`
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
|
// Priority controls selection preference when multiple credentials match.
|
||||||
|
// Higher values are preferred; defaults to 0.
|
||||||
|
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||||
|
|
||||||
// Prefix optionally namespaces models for this credential (e.g., "teamA/claude-sonnet-4").
|
// Prefix optionally namespaces models for this credential (e.g., "teamA/claude-sonnet-4").
|
||||||
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
||||||
|
|
||||||
@@ -280,6 +284,10 @@ type CodexKey struct {
|
|||||||
// APIKey is the authentication key for accessing Codex API services.
|
// APIKey is the authentication key for accessing Codex API services.
|
||||||
APIKey string `yaml:"api-key" json:"api-key"`
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
|
// Priority controls selection preference when multiple credentials match.
|
||||||
|
// Higher values are preferred; defaults to 0.
|
||||||
|
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||||
|
|
||||||
// Prefix optionally namespaces models for this credential (e.g., "teamA/gpt-5-codex").
|
// Prefix optionally namespaces models for this credential (e.g., "teamA/gpt-5-codex").
|
||||||
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
||||||
|
|
||||||
@@ -318,6 +326,10 @@ type GeminiKey struct {
|
|||||||
// APIKey is the authentication key for accessing Gemini API services.
|
// APIKey is the authentication key for accessing Gemini API services.
|
||||||
APIKey string `yaml:"api-key" json:"api-key"`
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
|
// Priority controls selection preference when multiple credentials match.
|
||||||
|
// Higher values are preferred; defaults to 0.
|
||||||
|
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||||
|
|
||||||
// Prefix optionally namespaces models for this credential (e.g., "teamA/gemini-3-pro-preview").
|
// Prefix optionally namespaces models for this credential (e.g., "teamA/gemini-3-pro-preview").
|
||||||
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
||||||
|
|
||||||
@@ -355,6 +367,10 @@ type OpenAICompatibility struct {
|
|||||||
// Name is the identifier for this OpenAI compatibility configuration.
|
// Name is the identifier for this OpenAI compatibility configuration.
|
||||||
Name string `yaml:"name" json:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
|
|
||||||
|
// Priority controls selection preference when multiple providers or credentials match.
|
||||||
|
// Higher values are preferred; defaults to 0.
|
||||||
|
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||||
|
|
||||||
// Prefix optionally namespaces model aliases for this provider (e.g., "teamA/kimi-k2").
|
// Prefix optionally namespaces model aliases for this provider (e.g., "teamA/kimi-k2").
|
||||||
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type VertexCompatKey struct {
|
|||||||
// Maps to the x-goog-api-key header.
|
// Maps to the x-goog-api-key header.
|
||||||
APIKey string `yaml:"api-key" json:"api-key"`
|
APIKey string `yaml:"api-key" json:"api-key"`
|
||||||
|
|
||||||
|
// Priority controls selection preference when multiple credentials match.
|
||||||
|
// Higher values are preferred; defaults to 0.
|
||||||
|
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||||
|
|
||||||
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
|
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
|
||||||
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
@@ -1104,12 +1105,49 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
|
|||||||
auth.Metadata["refresh_token"] = tokenResp.RefreshToken
|
auth.Metadata["refresh_token"] = tokenResp.RefreshToken
|
||||||
}
|
}
|
||||||
auth.Metadata["expires_in"] = tokenResp.ExpiresIn
|
auth.Metadata["expires_in"] = tokenResp.ExpiresIn
|
||||||
auth.Metadata["timestamp"] = time.Now().UnixMilli()
|
now := time.Now()
|
||||||
auth.Metadata["expired"] = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
|
auth.Metadata["timestamp"] = now.UnixMilli()
|
||||||
|
auth.Metadata["expired"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
|
||||||
auth.Metadata["type"] = antigravityAuthType
|
auth.Metadata["type"] = antigravityAuthType
|
||||||
|
if errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil {
|
||||||
|
log.Warnf("antigravity executor: ensure project id failed: %v", errProject)
|
||||||
|
}
|
||||||
return auth, nil
|
return auth, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) error {
|
||||||
|
if auth == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.Metadata["project_id"] != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(accessToken)
|
||||||
|
if token == "" {
|
||||||
|
token = metaStringValue(auth.Metadata, "access_token")
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
projectID, errFetch := sdkAuth.FetchAntigravityProjectID(ctx, token, httpClient)
|
||||||
|
if errFetch != nil {
|
||||||
|
return errFetch
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(projectID) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if auth.Metadata == nil {
|
||||||
|
auth.Metadata = make(map[string]any)
|
||||||
|
}
|
||||||
|
auth.Metadata["project_id"] = strings.TrimSpace(projectID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) {
|
func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
|||||||
itemDone, _ = sjson.Set(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
|
itemDone, _ = sjson.Set(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
|
||||||
itemDone, _ = sjson.Set(itemDone, "item.arguments", args)
|
itemDone, _ = sjson.Set(itemDone, "item.arguments", args)
|
||||||
itemDone, _ = sjson.Set(itemDone, "item.call_id", st.CurrentFCID)
|
itemDone, _ = sjson.Set(itemDone, "item.call_id", st.CurrentFCID)
|
||||||
|
itemDone, _ = sjson.Set(itemDone, "item.name", st.FuncNames[idx])
|
||||||
out = append(out, emitEvent("response.output_item.done", itemDone))
|
out = append(out, emitEvent("response.output_item.done", itemDone))
|
||||||
st.InFuncBlock = false
|
st.InFuncBlock = false
|
||||||
} else if st.ReasoningActive {
|
} else if st.ReasoningActive {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package synthesizer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
|
||||||
@@ -59,6 +60,9 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea
|
|||||||
"source": fmt.Sprintf("config:gemini[%s]", token),
|
"source": fmt.Sprintf("config:gemini[%s]", token),
|
||||||
"api_key": key,
|
"api_key": key,
|
||||||
}
|
}
|
||||||
|
if entry.Priority != 0 {
|
||||||
|
attrs["priority"] = strconv.Itoa(entry.Priority)
|
||||||
|
}
|
||||||
if base != "" {
|
if base != "" {
|
||||||
attrs["base_url"] = base
|
attrs["base_url"] = base
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,9 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
|
|||||||
"source": fmt.Sprintf("config:claude[%s]", token),
|
"source": fmt.Sprintf("config:claude[%s]", token),
|
||||||
"api_key": key,
|
"api_key": key,
|
||||||
}
|
}
|
||||||
|
if ck.Priority != 0 {
|
||||||
|
attrs["priority"] = strconv.Itoa(ck.Priority)
|
||||||
|
}
|
||||||
if base != "" {
|
if base != "" {
|
||||||
attrs["base_url"] = base
|
attrs["base_url"] = base
|
||||||
}
|
}
|
||||||
@@ -147,6 +154,9 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau
|
|||||||
"source": fmt.Sprintf("config:codex[%s]", token),
|
"source": fmt.Sprintf("config:codex[%s]", token),
|
||||||
"api_key": key,
|
"api_key": key,
|
||||||
}
|
}
|
||||||
|
if ck.Priority != 0 {
|
||||||
|
attrs["priority"] = strconv.Itoa(ck.Priority)
|
||||||
|
}
|
||||||
if ck.BaseURL != "" {
|
if ck.BaseURL != "" {
|
||||||
attrs["base_url"] = ck.BaseURL
|
attrs["base_url"] = ck.BaseURL
|
||||||
}
|
}
|
||||||
@@ -202,6 +212,9 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
|
|||||||
"compat_name": compat.Name,
|
"compat_name": compat.Name,
|
||||||
"provider_key": providerName,
|
"provider_key": providerName,
|
||||||
}
|
}
|
||||||
|
if compat.Priority != 0 {
|
||||||
|
attrs["priority"] = strconv.Itoa(compat.Priority)
|
||||||
|
}
|
||||||
if key != "" {
|
if key != "" {
|
||||||
attrs["api_key"] = key
|
attrs["api_key"] = key
|
||||||
}
|
}
|
||||||
@@ -233,6 +246,9 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
|
|||||||
"compat_name": compat.Name,
|
"compat_name": compat.Name,
|
||||||
"provider_key": providerName,
|
"provider_key": providerName,
|
||||||
}
|
}
|
||||||
|
if compat.Priority != 0 {
|
||||||
|
attrs["priority"] = strconv.Itoa(compat.Priority)
|
||||||
|
}
|
||||||
if hash := diff.ComputeOpenAICompatModelsHash(compat.Models); hash != "" {
|
if hash := diff.ComputeOpenAICompatModelsHash(compat.Models); hash != "" {
|
||||||
attrs["models_hash"] = hash
|
attrs["models_hash"] = hash
|
||||||
}
|
}
|
||||||
@@ -275,6 +291,9 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
|
|||||||
"base_url": base,
|
"base_url": base,
|
||||||
"provider_key": providerName,
|
"provider_key": providerName,
|
||||||
}
|
}
|
||||||
|
if compat.Priority != 0 {
|
||||||
|
attrs["priority"] = strconv.Itoa(compat.Priority)
|
||||||
|
}
|
||||||
if key != "" {
|
if key != "" {
|
||||||
attrs["api_key"] = key
|
attrs["api_key"] = key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ func fetchAntigravityProjectID(ctx context.Context, accessToken string, httpClie
|
|||||||
// Call loadCodeAssist to get the project
|
// Call loadCodeAssist to get the project
|
||||||
loadReqBody := map[string]any{
|
loadReqBody := map[string]any{
|
||||||
"metadata": map[string]string{
|
"metadata": map[string]string{
|
||||||
"ideType": "IDE_UNSPECIFIED",
|
"ideType": "ANTIGRAVITY",
|
||||||
"platform": "PLATFORM_UNSPECIFIED",
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
"pluginType": "GEMINI",
|
"pluginType": "GEMINI",
|
||||||
},
|
},
|
||||||
@@ -442,8 +442,134 @@ func fetchAntigravityProjectID(ctx context.Context, accessToken string, httpClie
|
|||||||
}
|
}
|
||||||
|
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
return "", fmt.Errorf("no cloudaicompanionProject in response")
|
tierID := "legacy-tier"
|
||||||
|
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
|
||||||
|
for _, rawTier := range tiers {
|
||||||
|
tier, okTier := rawTier.(map[string]any)
|
||||||
|
if !okTier {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
|
||||||
|
if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
|
||||||
|
tierID = strings.TrimSpace(id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, err = antigravityOnboardUser(ctx, accessToken, tierID, httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return projectID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectID, nil
|
return projectID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// antigravityOnboardUser attempts to fetch the project ID via onboardUser by polling for completion.
|
||||||
|
// It returns an empty string when the operation times out or completes without a project ID.
|
||||||
|
func antigravityOnboardUser(ctx context.Context, accessToken, tierID string, httpClient *http.Client) (string, error) {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
fmt.Println("Antigravity: onboarding user...", tierID)
|
||||||
|
requestBody := map[string]any{
|
||||||
|
"tierId": tierID,
|
||||||
|
"metadata": map[string]string{
|
||||||
|
"ideType": "ANTIGRAVITY",
|
||||||
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
|
"pluginType": "GEMINI",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rawBody, errMarshal := json.Marshal(requestBody)
|
||||||
|
if errMarshal != nil {
|
||||||
|
return "", fmt.Errorf("marshal request body: %w", errMarshal)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxAttempts := 5
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
log.Debugf("Polling attempt %d/%d", attempt, maxAttempts)
|
||||||
|
|
||||||
|
reqCtx := ctx
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
if reqCtx == nil {
|
||||||
|
reqCtx = context.Background()
|
||||||
|
}
|
||||||
|
reqCtx, cancel = context.WithTimeout(reqCtx, 30*time.Second)
|
||||||
|
|
||||||
|
endpointURL := fmt.Sprintf("%s/%s:onboardUser", antigravityAPIEndpoint, antigravityAPIVersion)
|
||||||
|
req, errRequest := http.NewRequestWithContext(reqCtx, http.MethodPost, endpointURL, strings.NewReader(string(rawBody)))
|
||||||
|
if errRequest != nil {
|
||||||
|
cancel()
|
||||||
|
return "", fmt.Errorf("create request: %w", errRequest)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", antigravityAPIUserAgent)
|
||||||
|
req.Header.Set("X-Goog-Api-Client", antigravityAPIClient)
|
||||||
|
req.Header.Set("Client-Metadata", antigravityClientMetadata)
|
||||||
|
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
cancel()
|
||||||
|
return "", fmt.Errorf("execute request: %w", errDo)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, errRead := io.ReadAll(resp.Body)
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if errRead != nil {
|
||||||
|
return "", fmt.Errorf("read response: %w", errRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
var data map[string]any
|
||||||
|
if errDecode := json.Unmarshal(bodyBytes, &data); errDecode != nil {
|
||||||
|
return "", fmt.Errorf("decode response: %w", errDecode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if done, okDone := data["done"].(bool); okDone && done {
|
||||||
|
projectID := ""
|
||||||
|
if responseData, okResp := data["response"].(map[string]any); okResp {
|
||||||
|
switch projectValue := responseData["cloudaicompanionProject"].(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if id, okID := projectValue["id"].(string); okID {
|
||||||
|
projectID = strings.TrimSpace(id)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
projectID = strings.TrimSpace(projectValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectID != "" {
|
||||||
|
log.Infof("Successfully fetched project_id: %s", projectID)
|
||||||
|
return projectID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no project_id in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
responsePreview := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if len(responsePreview) > 500 {
|
||||||
|
responsePreview = responsePreview[:500]
|
||||||
|
}
|
||||||
|
|
||||||
|
responseErr := responsePreview
|
||||||
|
if len(responseErr) > 200 {
|
||||||
|
responseErr = responseErr[:200]
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("http %d: %s", resp.StatusCode, responseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -77,15 +79,23 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
|
|||||||
if metadataEqualIgnoringTimestamps(existing, raw) {
|
if metadataEqualIgnoringTimestamps(existing, raw) {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
} else if errRead != nil && !os.IsNotExist(errRead) {
|
file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if errOpen != nil {
|
||||||
|
return "", fmt.Errorf("auth filestore: open existing failed: %w", errOpen)
|
||||||
|
}
|
||||||
|
if _, errWrite := file.Write(raw); errWrite != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return "", fmt.Errorf("auth filestore: write existing failed: %w", errWrite)
|
||||||
|
}
|
||||||
|
if errClose := file.Close(); errClose != nil {
|
||||||
|
return "", fmt.Errorf("auth filestore: close existing failed: %w", errClose)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
} else if !os.IsNotExist(errRead) {
|
||||||
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
||||||
}
|
}
|
||||||
tmp := path + ".tmp"
|
if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil {
|
||||||
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
|
return "", fmt.Errorf("auth filestore: write file failed: %w", errWrite)
|
||||||
return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite)
|
|
||||||
}
|
|
||||||
if errRename := os.Rename(tmp, path); errRename != nil {
|
|
||||||
return "", fmt.Errorf("auth filestore: rename failed: %w", errRename)
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
|
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
|
||||||
@@ -178,6 +188,30 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
|
|||||||
if provider == "" {
|
if provider == "" {
|
||||||
provider = "unknown"
|
provider = "unknown"
|
||||||
}
|
}
|
||||||
|
if provider == "antigravity" {
|
||||||
|
projectID := ""
|
||||||
|
if pid, ok := metadata["project_id"].(string); ok {
|
||||||
|
projectID = strings.TrimSpace(pid)
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
accessToken := ""
|
||||||
|
if token, ok := metadata["access_token"].(string); ok {
|
||||||
|
accessToken = strings.TrimSpace(token)
|
||||||
|
}
|
||||||
|
if accessToken != "" {
|
||||||
|
fetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient)
|
||||||
|
if errFetch == nil && strings.TrimSpace(fetchedProjectID) != "" {
|
||||||
|
metadata["project_id"] = strings.TrimSpace(fetchedProjectID)
|
||||||
|
if raw, errMarshal := json.Marshal(metadata); errMarshal == nil {
|
||||||
|
if file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil {
|
||||||
|
_, _ = file.Write(raw)
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stat file: %w", err)
|
return nil, fmt.Errorf("stat file: %w", err)
|
||||||
@@ -266,92 +300,28 @@ func (s *FileTokenStore) baseDirSnapshot() string {
|
|||||||
return s.baseDir
|
return s.baseDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEPRECATED: Use metadataEqualIgnoringTimestamps for comparing auth metadata.
|
// metadataEqualIgnoringTimestamps compares two metadata JSON blobs, ignoring volatile fields that
|
||||||
// This function is kept for backward compatibility but can cause refresh loops.
|
// change on every refresh but don't affect authentication logic.
|
||||||
func jsonEqual(a, b []byte) bool {
|
|
||||||
var objA any
|
|
||||||
var objB any
|
|
||||||
if err := json.Unmarshal(a, &objA); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, &objB); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return deepEqualJSON(objA, objB)
|
|
||||||
}
|
|
||||||
|
|
||||||
// metadataEqualIgnoringTimestamps compares two metadata JSON blobs,
|
|
||||||
// ignoring fields that change on every refresh but don't affect functionality.
|
|
||||||
// This prevents unnecessary file writes that would trigger watcher events and
|
|
||||||
// create refresh loops.
|
|
||||||
func metadataEqualIgnoringTimestamps(a, b []byte) bool {
|
func metadataEqualIgnoringTimestamps(a, b []byte) bool {
|
||||||
var objA, objB map[string]any
|
var objA map[string]any
|
||||||
if err := json.Unmarshal(a, &objA); err != nil {
|
var objB map[string]any
|
||||||
|
if errUnmarshalA := json.Unmarshal(a, &objA); errUnmarshalA != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(b, &objB); err != nil {
|
if errUnmarshalB := json.Unmarshal(b, &objB); errUnmarshalB != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
stripVolatileMetadataFields(objA)
|
||||||
// Fields to ignore: these change on every refresh but don't affect authentication logic.
|
stripVolatileMetadataFields(objB)
|
||||||
// - timestamp, expired, expires_in, last_refresh: time-related fields that change on refresh
|
return reflect.DeepEqual(objA, objB)
|
||||||
// - access_token: Google OAuth returns a new access_token on each refresh, this is expected
|
|
||||||
// and shouldn't trigger file writes (the new token will be fetched again when needed)
|
|
||||||
ignoredFields := []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"}
|
|
||||||
for _, field := range ignoredFields {
|
|
||||||
delete(objA, field)
|
|
||||||
delete(objB, field)
|
|
||||||
}
|
|
||||||
|
|
||||||
return deepEqualJSON(objA, objB)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deepEqualJSON(a, b any) bool {
|
func stripVolatileMetadataFields(metadata map[string]any) {
|
||||||
switch valA := a.(type) {
|
if metadata == nil {
|
||||||
case map[string]any:
|
return
|
||||||
valB, ok := b.(map[string]any)
|
|
||||||
if !ok || len(valA) != len(valB) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
for key, subA := range valA {
|
// These fields change on refresh and would otherwise trigger watcher reload loops.
|
||||||
subB, ok1 := valB[key]
|
for _, field := range []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"} {
|
||||||
if !ok1 || !deepEqualJSON(subA, subB) {
|
delete(metadata, field)
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case []any:
|
|
||||||
sliceB, ok := b.([]any)
|
|
||||||
if !ok || len(valA) != len(sliceB) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range valA {
|
|
||||||
if !deepEqualJSON(valA[i], sliceB[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
case float64:
|
|
||||||
valB, ok := b.(float64)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return valA == valB
|
|
||||||
case string:
|
|
||||||
valB, ok := b.(string)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return valA == valB
|
|
||||||
case bool:
|
|
||||||
valB, ok := b.(bool)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return valA == valB
|
|
||||||
case nil:
|
|
||||||
return b == nil
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,7 +271,6 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye
|
|||||||
if len(normalized) == 0 {
|
if len(normalized) == 0 {
|
||||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
}
|
}
|
||||||
rotated := m.rotateProviders(req.Model, normalized)
|
|
||||||
|
|
||||||
retryTimes, maxWait := m.retrySettings()
|
retryTimes, maxWait := m.retrySettings()
|
||||||
attempts := retryTimes + 1
|
attempts := retryTimes + 1
|
||||||
@@ -281,14 +280,12 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye
|
|||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
resp, errExec := m.executeProvidersOnce(ctx, rotated, func(execCtx context.Context, provider string) (cliproxyexecutor.Response, error) {
|
resp, errExec := m.executeMixedOnce(ctx, normalized, req, opts)
|
||||||
return m.executeWithProvider(execCtx, provider, req, opts)
|
|
||||||
})
|
|
||||||
if errExec == nil {
|
if errExec == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
lastErr = errExec
|
lastErr = errExec
|
||||||
wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, attempts, rotated, req.Model, maxWait)
|
wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, attempts, normalized, req.Model, maxWait)
|
||||||
if !shouldRetry {
|
if !shouldRetry {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -309,7 +306,6 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip
|
|||||||
if len(normalized) == 0 {
|
if len(normalized) == 0 {
|
||||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
}
|
}
|
||||||
rotated := m.rotateProviders(req.Model, normalized)
|
|
||||||
|
|
||||||
retryTimes, maxWait := m.retrySettings()
|
retryTimes, maxWait := m.retrySettings()
|
||||||
attempts := retryTimes + 1
|
attempts := retryTimes + 1
|
||||||
@@ -319,14 +315,12 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip
|
|||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
resp, errExec := m.executeProvidersOnce(ctx, rotated, func(execCtx context.Context, provider string) (cliproxyexecutor.Response, error) {
|
resp, errExec := m.executeCountMixedOnce(ctx, normalized, req, opts)
|
||||||
return m.executeCountWithProvider(execCtx, provider, req, opts)
|
|
||||||
})
|
|
||||||
if errExec == nil {
|
if errExec == nil {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
lastErr = errExec
|
lastErr = errExec
|
||||||
wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, attempts, rotated, req.Model, maxWait)
|
wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, attempts, normalized, req.Model, maxWait)
|
||||||
if !shouldRetry {
|
if !shouldRetry {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -347,7 +341,6 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli
|
|||||||
if len(normalized) == 0 {
|
if len(normalized) == 0 {
|
||||||
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
}
|
}
|
||||||
rotated := m.rotateProviders(req.Model, normalized)
|
|
||||||
|
|
||||||
retryTimes, maxWait := m.retrySettings()
|
retryTimes, maxWait := m.retrySettings()
|
||||||
attempts := retryTimes + 1
|
attempts := retryTimes + 1
|
||||||
@@ -357,14 +350,12 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli
|
|||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
chunks, errStream := m.executeStreamProvidersOnce(ctx, rotated, func(execCtx context.Context, provider string) (<-chan cliproxyexecutor.StreamChunk, error) {
|
chunks, errStream := m.executeStreamMixedOnce(ctx, normalized, req, opts)
|
||||||
return m.executeStreamWithProvider(execCtx, provider, req, opts)
|
|
||||||
})
|
|
||||||
if errStream == nil {
|
if errStream == nil {
|
||||||
return chunks, nil
|
return chunks, nil
|
||||||
}
|
}
|
||||||
lastErr = errStream
|
lastErr = errStream
|
||||||
wait, shouldRetry := m.shouldRetryAfterError(errStream, attempt, attempts, rotated, req.Model, maxWait)
|
wait, shouldRetry := m.shouldRetryAfterError(errStream, attempt, attempts, normalized, req.Model, maxWait)
|
||||||
if !shouldRetry {
|
if !shouldRetry {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -378,6 +369,167 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli
|
|||||||
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
|
}
|
||||||
|
routeModel := req.Model
|
||||||
|
tried := make(map[string]struct{})
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
|
||||||
|
if errPick != nil {
|
||||||
|
if lastErr != nil {
|
||||||
|
return cliproxyexecutor.Response{}, lastErr
|
||||||
|
}
|
||||||
|
return cliproxyexecutor.Response{}, errPick
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := logEntryWithRequestID(ctx)
|
||||||
|
debugLogAuthSelection(entry, auth, provider, req.Model)
|
||||||
|
|
||||||
|
tried[auth.ID] = struct{}{}
|
||||||
|
execCtx := ctx
|
||||||
|
if rt := m.roundTripperFor(auth); rt != nil {
|
||||||
|
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
|
||||||
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
|
}
|
||||||
|
execReq := req
|
||||||
|
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
||||||
|
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
||||||
|
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
|
||||||
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
|
if errExec != nil {
|
||||||
|
result.Error = &Error{Message: errExec.Error()}
|
||||||
|
var se cliproxyexecutor.StatusError
|
||||||
|
if errors.As(errExec, &se) && se != nil {
|
||||||
|
result.Error.HTTPStatus = se.StatusCode()
|
||||||
|
}
|
||||||
|
if ra := retryAfterFromError(errExec); ra != nil {
|
||||||
|
result.RetryAfter = ra
|
||||||
|
}
|
||||||
|
m.MarkResult(execCtx, result)
|
||||||
|
lastErr = errExec
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.MarkResult(execCtx, result)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
|
}
|
||||||
|
routeModel := req.Model
|
||||||
|
tried := make(map[string]struct{})
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
|
||||||
|
if errPick != nil {
|
||||||
|
if lastErr != nil {
|
||||||
|
return cliproxyexecutor.Response{}, lastErr
|
||||||
|
}
|
||||||
|
return cliproxyexecutor.Response{}, errPick
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := logEntryWithRequestID(ctx)
|
||||||
|
debugLogAuthSelection(entry, auth, provider, req.Model)
|
||||||
|
|
||||||
|
tried[auth.ID] = struct{}{}
|
||||||
|
execCtx := ctx
|
||||||
|
if rt := m.roundTripperFor(auth); rt != nil {
|
||||||
|
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
|
||||||
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
|
}
|
||||||
|
execReq := req
|
||||||
|
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
||||||
|
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
||||||
|
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
|
||||||
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
|
||||||
|
if errExec != nil {
|
||||||
|
result.Error = &Error{Message: errExec.Error()}
|
||||||
|
var se cliproxyexecutor.StatusError
|
||||||
|
if errors.As(errExec, &se) && se != nil {
|
||||||
|
result.Error.HTTPStatus = se.StatusCode()
|
||||||
|
}
|
||||||
|
if ra := retryAfterFromError(errExec); ra != nil {
|
||||||
|
result.RetryAfter = ra
|
||||||
|
}
|
||||||
|
m.MarkResult(execCtx, result)
|
||||||
|
lastErr = errExec
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.MarkResult(execCtx, result)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
|
}
|
||||||
|
routeModel := req.Model
|
||||||
|
tried := make(map[string]struct{})
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
|
||||||
|
if errPick != nil {
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return nil, errPick
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := logEntryWithRequestID(ctx)
|
||||||
|
debugLogAuthSelection(entry, auth, provider, req.Model)
|
||||||
|
|
||||||
|
tried[auth.ID] = struct{}{}
|
||||||
|
execCtx := ctx
|
||||||
|
if rt := m.roundTripperFor(auth); rt != nil {
|
||||||
|
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
|
||||||
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
||||||
|
}
|
||||||
|
execReq := req
|
||||||
|
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
|
||||||
|
execReq.Model, execReq.Metadata = m.applyOAuthModelMapping(auth, execReq.Model, execReq.Metadata)
|
||||||
|
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
|
||||||
|
if errStream != nil {
|
||||||
|
rerr := &Error{Message: errStream.Error()}
|
||||||
|
var se cliproxyexecutor.StatusError
|
||||||
|
if errors.As(errStream, &se) && se != nil {
|
||||||
|
rerr.HTTPStatus = se.StatusCode()
|
||||||
|
}
|
||||||
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}
|
||||||
|
result.RetryAfter = retryAfterFromError(errStream)
|
||||||
|
m.MarkResult(execCtx, result)
|
||||||
|
lastErr = errStream
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out := make(chan cliproxyexecutor.StreamChunk)
|
||||||
|
go func(streamCtx context.Context, streamAuth *Auth, streamProvider string, streamChunks <-chan cliproxyexecutor.StreamChunk) {
|
||||||
|
defer close(out)
|
||||||
|
var failed bool
|
||||||
|
for chunk := range streamChunks {
|
||||||
|
if chunk.Err != nil && !failed {
|
||||||
|
failed = true
|
||||||
|
rerr := &Error{Message: chunk.Err.Error()}
|
||||||
|
var se cliproxyexecutor.StatusError
|
||||||
|
if errors.As(chunk.Err, &se) && se != nil {
|
||||||
|
rerr.HTTPStatus = se.StatusCode()
|
||||||
|
}
|
||||||
|
m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: routeModel, Success: false, Error: rerr})
|
||||||
|
}
|
||||||
|
out <- chunk
|
||||||
|
}
|
||||||
|
if !failed {
|
||||||
|
m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: routeModel, Success: true})
|
||||||
|
}
|
||||||
|
}(execCtx, auth.Clone(), provider, chunks)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) executeWithProvider(ctx context.Context, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
func (m *Manager) executeWithProvider(ctx context.Context, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"}
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"}
|
||||||
@@ -1191,6 +1343,77 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
|
|||||||
return authCopy, executor, nil
|
return authCopy, executor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
|
||||||
|
providerSet := make(map[string]struct{}, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
p := strings.TrimSpace(strings.ToLower(provider))
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
providerSet[p] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(providerSet) == 0 {
|
||||||
|
return nil, nil, "", &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
candidates := make([]*Auth, 0, len(m.auths))
|
||||||
|
modelKey := strings.TrimSpace(model)
|
||||||
|
registryRef := registry.GetGlobalRegistry()
|
||||||
|
for _, candidate := range m.auths {
|
||||||
|
if candidate == nil || candidate.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
providerKey := strings.TrimSpace(strings.ToLower(candidate.Provider))
|
||||||
|
if providerKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := providerSet[providerKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, used := tried[candidate.ID]; used {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := m.executors[providerKey]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if modelKey != "" && registryRef != nil && !registryRef.ClientSupportsModel(candidate.ID, modelKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"}
|
||||||
|
}
|
||||||
|
selected, errPick := m.selector.Pick(ctx, "mixed", model, opts, candidates)
|
||||||
|
if errPick != nil {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return nil, nil, "", errPick
|
||||||
|
}
|
||||||
|
if selected == nil {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return nil, nil, "", &Error{Code: "auth_not_found", Message: "selector returned no auth"}
|
||||||
|
}
|
||||||
|
providerKey := strings.TrimSpace(strings.ToLower(selected.Provider))
|
||||||
|
executor, okExecutor := m.executors[providerKey]
|
||||||
|
if !okExecutor {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered"}
|
||||||
|
}
|
||||||
|
authCopy := selected.Clone()
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !selected.indexAssigned {
|
||||||
|
m.mu.Lock()
|
||||||
|
if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {
|
||||||
|
current.EnsureIndex()
|
||||||
|
authCopy = current.Clone()
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
return authCopy, executor, providerKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) persist(ctx context.Context, auth *Auth) error {
|
func (m *Manager) persist(ctx context.Context, auth *Auth) error {
|
||||||
if m.store == nil || auth == nil {
|
if m.store == nil || auth == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -103,13 +104,29 @@ func (e *modelCooldownError) Headers() http.Header {
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectAvailable(auths []*Auth, model string, now time.Time) (available []*Auth, cooldownCount int, earliest time.Time) {
|
func authPriority(auth *Auth) int {
|
||||||
available = make([]*Auth, 0, len(auths))
|
if auth == nil || auth.Attributes == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(auth.Attributes["priority"])
|
||||||
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectAvailableByPriority(auths []*Auth, model string, now time.Time) (available map[int][]*Auth, cooldownCount int, earliest time.Time) {
|
||||||
|
available = make(map[int][]*Auth)
|
||||||
for i := 0; i < len(auths); i++ {
|
for i := 0; i < len(auths); i++ {
|
||||||
candidate := auths[i]
|
candidate := auths[i]
|
||||||
blocked, reason, next := isAuthBlockedForModel(candidate, model, now)
|
blocked, reason, next := isAuthBlockedForModel(candidate, model, now)
|
||||||
if !blocked {
|
if !blocked {
|
||||||
available = append(available, candidate)
|
priority := authPriority(candidate)
|
||||||
|
available[priority] = append(available[priority], candidate)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if reason == blockReasonCooldown {
|
if reason == blockReasonCooldown {
|
||||||
@@ -119,9 +136,6 @@ func collectAvailable(auths []*Auth, model string, now time.Time) (available []*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(available) > 1 {
|
|
||||||
sort.Slice(available, func(i, j int) bool { return available[i].ID < available[j].ID })
|
|
||||||
}
|
|
||||||
return available, cooldownCount, earliest
|
return available, cooldownCount, earliest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,18 +144,35 @@ func getAvailableAuths(auths []*Auth, provider, model string, now time.Time) ([]
|
|||||||
return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"}
|
return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"}
|
||||||
}
|
}
|
||||||
|
|
||||||
available, cooldownCount, earliest := collectAvailable(auths, model, now)
|
availableByPriority, cooldownCount, earliest := collectAvailableByPriority(auths, model, now)
|
||||||
if len(available) == 0 {
|
if len(availableByPriority) == 0 {
|
||||||
if cooldownCount == len(auths) && !earliest.IsZero() {
|
if cooldownCount == len(auths) && !earliest.IsZero() {
|
||||||
|
providerForError := provider
|
||||||
|
if providerForError == "mixed" {
|
||||||
|
providerForError = ""
|
||||||
|
}
|
||||||
resetIn := earliest.Sub(now)
|
resetIn := earliest.Sub(now)
|
||||||
if resetIn < 0 {
|
if resetIn < 0 {
|
||||||
resetIn = 0
|
resetIn = 0
|
||||||
}
|
}
|
||||||
return nil, newModelCooldownError(model, provider, resetIn)
|
return nil, newModelCooldownError(model, providerForError, resetIn)
|
||||||
}
|
}
|
||||||
return nil, &Error{Code: "auth_unavailable", Message: "no auth available"}
|
return nil, &Error{Code: "auth_unavailable", Message: "no auth available"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bestPriority := 0
|
||||||
|
found := false
|
||||||
|
for priority := range availableByPriority {
|
||||||
|
if !found || priority > bestPriority {
|
||||||
|
bestPriority = priority
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
available := availableByPriority[bestPriority]
|
||||||
|
if len(available) > 1 {
|
||||||
|
sort.Slice(available, func(i, j int) bool { return available[i].ID < available[j].ID })
|
||||||
|
}
|
||||||
return available, nil
|
return available, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
)
|
)
|
||||||
@@ -56,6 +57,69 @@ func TestRoundRobinSelectorPick_CyclesDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoundRobinSelectorPick_PriorityBuckets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
selector := &RoundRobinSelector{}
|
||||||
|
auths := []*Auth{
|
||||||
|
{ID: "c", Attributes: map[string]string{"priority": "0"}},
|
||||||
|
{ID: "a", Attributes: map[string]string{"priority": "10"}},
|
||||||
|
{ID: "b", Attributes: map[string]string{"priority": "10"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"a", "b", "a", "b"}
|
||||||
|
for i, id := range want {
|
||||||
|
got, err := selector.Pick(context.Background(), "mixed", "", cliproxyexecutor.Options{}, auths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick() #%d error = %v", i, err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("Pick() #%d auth = nil", i)
|
||||||
|
}
|
||||||
|
if got.ID != id {
|
||||||
|
t.Fatalf("Pick() #%d auth.ID = %q, want %q", i, got.ID, id)
|
||||||
|
}
|
||||||
|
if got.ID == "c" {
|
||||||
|
t.Fatalf("Pick() #%d unexpectedly selected lower priority auth", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillFirstSelectorPick_PriorityFallbackCooldown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
selector := &FillFirstSelector{}
|
||||||
|
now := time.Now()
|
||||||
|
model := "test-model"
|
||||||
|
|
||||||
|
high := &Auth{
|
||||||
|
ID: "high",
|
||||||
|
Attributes: map[string]string{"priority": "10"},
|
||||||
|
ModelStates: map[string]*ModelState{
|
||||||
|
model: {
|
||||||
|
Status: StatusActive,
|
||||||
|
Unavailable: true,
|
||||||
|
NextRetryAfter: now.Add(30 * time.Minute),
|
||||||
|
Quota: QuotaState{
|
||||||
|
Exceeded: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
low := &Auth{ID: "low", Attributes: map[string]string{"priority": "0"}}
|
||||||
|
|
||||||
|
got, err := selector.Pick(context.Background(), "mixed", model, cliproxyexecutor.Options{}, []*Auth{high, low})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pick() error = %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("Pick() auth = nil")
|
||||||
|
}
|
||||||
|
if got.ID != "low" {
|
||||||
|
t.Fatalf("Pick() auth.ID = %q, want %q", got.ID, "low")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRoundRobinSelectorPick_Concurrent(t *testing.T) {
|
func TestRoundRobinSelectorPick_Concurrent(t *testing.T) {
|
||||||
selector := &RoundRobinSelector{}
|
selector := &RoundRobinSelector{}
|
||||||
auths := []*Auth{
|
auths := []*Auth{
|
||||||
|
|||||||
Reference in New Issue
Block a user