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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user