feat(antigravity): conductor-level credits fallback for Claude models

Move credits handling from executor-level retry to conductor-level
orchestration. When all free-tier auths are exhausted (429/503), the
conductor discovers auths with available Google One AI credits and
retries with enabledCreditTypes injected via context flag.

Key changes:
- Add AntigravityCreditsHint system for tracking per-auth credits state
- Conductor tries credits fallback after all auths fail (Execute/Stream/Count)
- Executor injects enabledCreditTypes only when conductor sets context flag
- Credits fallback respects provider scope (requires antigravity in providers)
- Add context cancellation check in credits fallback to avoid wasted requests
- Remove executor-level attemptCreditsFallback and preferCredits machinery
- Restructure 429 decision logic (parse details first, keyword fallback)
- Expand shouldAbort to cover INVALID_ARGUMENT/FAILED_PRECONDITION/500+UNKNOWN
- Support human-readable retry delay parsing (e.g. "1h43m56s")
This commit is contained in:
sususu98
2026-04-23 13:44:20 +08:00
parent a188159632
commit 14d46a0a5d
9 changed files with 989 additions and 694 deletions
+214 -4
View File
@@ -1202,12 +1202,16 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye
}
}
if lastErr != nil {
if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) {
if resp, ok := m.tryAntigravityCreditsExecute(ctx, req, opts); ok {
return resp, nil
}
}
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
}
// ExecuteCount performs a non-streaming execution using the configured selector and executor.
// It supports multiple providers for the same model and round-robins the starting provider per model.
func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
normalized := m.normalizeProviders(providers)
@@ -1233,6 +1237,11 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip
}
}
if lastErr != nil {
if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) {
if resp, ok := m.tryAntigravityCreditsExecuteCount(ctx, req, opts); ok {
return resp, nil
}
}
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
@@ -1264,6 +1273,11 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli
}
}
if lastErr != nil {
if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) {
if result, ok := m.tryAntigravityCreditsExecuteStream(ctx, req, opts); ok {
return result, nil
}
}
return nil, lastErr
}
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
@@ -2319,7 +2333,8 @@ func retryAfterFromError(err error) *time.Duration {
if retryAfter == nil {
return nil
}
return new(*retryAfter)
value := *retryAfter
return &value
}
func statusCodeFromResult(err *Error) int {
@@ -2409,11 +2424,18 @@ func isRequestInvalidError(err error) bool {
status := statusCodeFromError(err)
switch status {
case http.StatusBadRequest:
return strings.Contains(err.Error(), "invalid_request_error")
msg := err.Error()
return strings.Contains(msg, "invalid_request_error") ||
strings.Contains(msg, "INVALID_ARGUMENT") ||
strings.Contains(msg, "FAILED_PRECONDITION")
case http.StatusNotFound:
return isRequestScopedNotFoundMessage(err.Error())
case http.StatusUnprocessableEntity:
return true
case http.StatusInternalServerError:
msg := err.Error()
return strings.Contains(msg, "\"status\":\"UNKNOWN\"") ||
strings.Contains(msg, "\"status\": \"UNKNOWN\"")
default:
return false
}
@@ -2886,6 +2908,193 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s
return authCopy, executor, providerKey, nil
}
func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string) []creditsCandidateEntry {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
var candidates []creditsCandidateEntry
for _, auth := range m.auths {
if auth == nil || auth.Disabled || auth.Status == StatusDisabled {
continue
}
if !antigravityCreditsAvailableForModel(auth, routeModel) {
continue
}
providerKey := strings.TrimSpace(strings.ToLower(auth.Provider))
executor, ok := m.executors[providerKey]
if !ok {
continue
}
candidates = append(candidates, creditsCandidateEntry{
auth: auth.Clone(),
executor: executor,
provider: providerKey,
})
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].auth.ID < candidates[j].auth.ID
})
return candidates
}
type creditsCandidateEntry struct {
auth *Auth
executor ProviderExecutor
provider string
}
func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, providers []string) bool {
if m == nil || lastErr == nil {
return false
}
if len(providers) > 0 {
hasAntigravity := false
for _, p := range providers {
if strings.EqualFold(strings.TrimSpace(p), "antigravity") {
hasAntigravity = true
break
}
}
if !hasAntigravity {
return false
}
}
cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)
if cfg == nil || !cfg.QuotaExceeded.AntigravityCredits {
return false
}
status := statusCodeFromError(lastErr)
switch status {
case http.StatusTooManyRequests, http.StatusServiceUnavailable:
return true
case 0:
var authErr *Error
if errors.As(lastErr, &authErr) && authErr != nil {
return authErr.Code == "auth_not_found" || authErr.Code == "auth_unavailable" || authErr.Code == "model_cooldown"
}
var cooldownErr *modelCooldownError
if errors.As(lastErr, &cooldownErr) {
return true
}
return false
default:
return false
}
}
func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) {
routeModel := req.Model
candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel)
for _, c := range candidates {
if ctx.Err() != nil {
return cliproxyexecutor.Response{}, false
}
creditsCtx := WithAntigravityCredits(ctx)
if rt := m.roundTripperFor(c.auth); rt != nil {
creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt)
creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt)
}
creditsOpts := ensureRequestedModelMetadata(opts, routeModel)
publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID)
models := m.executionModelCandidates(c.auth, routeModel)
if len(models) == 0 {
continue
}
for _, upstreamModel := range models {
resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1)
execReq := req
execReq.Model = upstreamModel
resp, errExec := c.executor.Execute(creditsCtx, c.auth, execReq, creditsOpts)
result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil}
if errExec != nil {
result.Error = &Error{Message: errExec.Error()}
if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil {
result.Error.HTTPStatus = se.StatusCode()
}
if ra := retryAfterFromError(errExec); ra != nil {
result.RetryAfter = ra
}
m.MarkResult(creditsCtx, result)
continue
}
m.MarkResult(creditsCtx, result)
return resp, true
}
}
return cliproxyexecutor.Response{}, false
}
func (m *Manager) tryAntigravityCreditsExecuteCount(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) {
routeModel := req.Model
candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel)
for _, c := range candidates {
if ctx.Err() != nil {
return cliproxyexecutor.Response{}, false
}
creditsCtx := WithAntigravityCredits(ctx)
if rt := m.roundTripperFor(c.auth); rt != nil {
creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt)
creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt)
}
creditsOpts := ensureRequestedModelMetadata(opts, routeModel)
publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID)
models := m.executionModelCandidates(c.auth, routeModel)
if len(models) == 0 {
continue
}
for _, upstreamModel := range models {
resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1)
execReq := req
execReq.Model = upstreamModel
resp, errExec := c.executor.CountTokens(creditsCtx, c.auth, execReq, creditsOpts)
result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil}
if errExec != nil {
result.Error = &Error{Message: errExec.Error()}
if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil {
result.Error.HTTPStatus = se.StatusCode()
}
if ra := retryAfterFromError(errExec); ra != nil {
result.RetryAfter = ra
}
m.MarkResult(creditsCtx, result)
continue
}
m.MarkResult(creditsCtx, result)
return resp, true
}
}
return cliproxyexecutor.Response{}, false
}
func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) {
routeModel := req.Model
candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel)
for _, c := range candidates {
if ctx.Err() != nil {
return nil, false
}
creditsCtx := WithAntigravityCredits(ctx)
if rt := m.roundTripperFor(c.auth); rt != nil {
creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt)
creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt)
}
creditsOpts := ensureRequestedModelMetadata(opts, routeModel)
publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID)
models := m.executionModelCandidates(c.auth, routeModel)
if len(models) == 0 {
continue
}
result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, models, len(models) > 1)
if errStream != nil {
continue
}
return result, true
}
return nil, false
}
func (m *Manager) persist(ctx context.Context, auth *Auth) error {
if m.store == nil || auth == nil {
return nil
@@ -3200,14 +3409,15 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) {
m.mu.RLock()
auth := m.auths[id]
var exec ProviderExecutor
var cloned *Auth
if auth != nil {
exec = m.executors[auth.Provider]
cloned = auth.Clone()
}
m.mu.RUnlock()
if auth == nil || exec == nil {
return
}
cloned := auth.Clone()
updated, err := exec.Refresh(ctx, cloned)
if err != nil && errors.Is(err, context.Canceled) {
log.Debugf("refresh canceled for %s, %s", auth.Provider, auth.ID)