Files
CLIProxyAPI/internal/runtime/executor/antigravity_executor_stream.go
sususu 20784c67ff fix(home): use Home's CAS command instead of EVAL for replay compare-and-swap
Client.KVCompareAndSwap sent Redis EVAL with a Lua script, but the Home RESP
subset does not implement EVAL, so Home replied "ERR unknown command 'eval'".
That broke the Antigravity and Codex reasoning replay caches in Home mode.

Switch the transport to Home's dedicated CAS command:

    CAS <key> <expected-exists 0|1> <expected-value> <new-value> [PX <ttl-ms>]

The semantics match the old script argument for argument, so KVCompareAndSwap's
signature and all its callers are unchanged. Omitting PX when ttl <= 0 mirrors
the script's SET-without-PX branch, which clears the TTL.

Deployments that predate CAS reject the command. Detect that by matching the
unsupported-command error, latch ErrCompareAndSwapUnsupported for the client
lifetime so later calls skip the round trip, and warn exactly once. The latch is
deliberately not carried across NewLifetime, so a Home upgrade takes effect on
the next reconnect rather than requiring a CPA restart.

Also stop replay-state failures from failing the request. A bare replay error has
no HTTP status, so resultErrorFromError does not classify it as request-scoped and
MarkResult marks the credential unavailable for that model, walking every
candidate credential until alias resolution has nothing left and returns 503. A
ledger miss is already a tolerated outcome, so degrade to "no replay this turn"
instead. The pairing failure still returns its 400.

Verified end to end against a real Home over RESP/mTLS on PostgreSQL with
Antigravity OAuth credentials: patched Home recreates the replay row through CAS
with its TTL, while a pre-CAS Home latches once, keeps returning 200 instead of
503, and records no credential error attributable to the replay path.

Refs router-for-me/CLIProxyAPIHome#79
2026-07-29 09:44:05 +08:00

320 lines
12 KiB
Go

package executor
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/sjson"
)
// ExecuteStream performs a streaming request to the Antigravity API.
func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
ctx = context.WithValue(ctx, "alt", "")
if inCooldown, remaining, errCooldown := antigravityIsInShortCooldownRequired(ctx, auth, baseModel, time.Now()); errCooldown != nil {
return nil, homeKVUnavailableStatusErr(errCooldown)
} else if inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) {
log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining)
d := remaining
return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
}
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("antigravity")
originalPayloadSource := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayloadSource = opts.OriginalRequest
}
originalPayload := originalPayloadSource
originalPayload, errValidate := validateAntigravityRequestSignatures(ctx, baseModel, from, originalPayload)
if errValidate != nil {
return nil, errValidate
}
req.Payload = originalPayload
token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth)
if errToken != nil {
return nil, errToken
}
if updatedAuth != nil {
auth = updatedAuth
}
originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true)
translated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true)
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
return nil, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated)
translated, _ = sjson.DeleteBytes(translated, "request.stream")
reporter.SetTranslatedReasoningEffort(translated, to.String())
useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
baseURLs := antigravityBaseURLFallbackOrder(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
attempts := antigravityRetryAttempts(auth, e.cfg)
attemptLoop:
for attempt := 0; attempt < attempts; attempt++ {
var lastStatus int
var lastBody []byte
var lastErr error
for idx, baseURL := range baseURLs {
requestPayload := translated
if useCredits {
if cp := injectEnabledCreditTypes(translated); len(cp) > 0 {
requestPayload = cp
helps.MarkCreditsUsed(ctx)
}
}
replayScope := antigravityReasoningReplayScope{}
if antigravityUsesReasoningReplayCache(baseModel) {
var errReplay error
requestPayload, replayScope, errReplay = prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, requestPayload)
if errReplay != nil {
err = errReplay
return nil, err
}
}
httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata))
if errReq != nil {
err = errReq
return nil, err
}
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.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
if idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: request error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
err = errDo
return nil, err
}
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
bodyBytes, errRead := io.ReadAll(httpResp.Body)
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("antigravity executor: close response body error: %v", errClose)
}
if errRead != nil {
helps.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
if idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: read error on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
err = errRead
return nil, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, bodyBytes)
if httpResp.StatusCode == http.StatusTooManyRequests {
decision := decideAntigravity429(bodyBytes)
switch decision.kind {
case antigravity429DecisionInstantRetrySameAuth:
if attempt+1 < attempts {
if decision.retryAfter != nil && *decision.retryAfter > 0 {
wait := antigravityInstantRetryDelay(*decision.retryAfter)
log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait)
if errWait := antigravityWait(ctx, wait); errWait != nil {
return nil, errWait
}
}
continue attemptLoop
}
case antigravity429DecisionShortCooldownSwitchAuth:
if decision.retryAfter != nil && *decision.retryAfter > 0 {
if errMarkCooldown := markAntigravityShortCooldownRequired(ctx, auth, baseModel, time.Now(), *decision.retryAfter); errMarkCooldown != nil {
err = homeKVUnavailableStatusErr(errMarkCooldown)
return nil, err
}
log.Debugf("antigravity executor: short quota cooldown (%s) for model %s recorded", *decision.retryAfter, baseModel)
}
case antigravity429DecisionFullQuotaExhausted:
if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) {
markAntigravityCreditsPermanentlyDisabled(auth)
}
// No credits logic - just fall through to error return below
}
}
lastStatus = httpResp.StatusCode
lastBody = append([]byte(nil), bodyBytes...)
lastErr = nil
if httpResp.StatusCode == http.StatusTooManyRequests && idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
if antigravityShouldRetryTransientResourceExhausted429(httpResp.StatusCode, bodyBytes) && attempt+1 < attempts {
delay := antigravityTransient429RetryDelay(attempt)
log.Debugf("antigravity executor: transient 429 resource exhausted for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
if errWait := antigravityWait(ctx, delay); errWait != nil {
return nil, errWait
}
continue attemptLoop
}
if antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {
if idx+1 < len(baseURLs) {
log.Debugf("antigravity executor: no capacity on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
continue
}
if attempt+1 < attempts {
delay := antigravityNoCapacityRetryDelay(attempt)
log.Debugf("antigravity executor: no capacity for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
if errWait := antigravityWait(ctx, delay); errWait != nil {
return nil, errWait
}
continue attemptLoop
}
}
if antigravityShouldRetrySoftRateLimit(httpResp.StatusCode, bodyBytes) {
if attempt+1 < attempts {
delay := antigravitySoftRateLimitDelay(attempt)
log.Debugf("antigravity executor: soft rate limit for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
if errWait := antigravityWait(ctx, delay); errWait != nil {
return nil, errWait
}
continue attemptLoop
}
}
if errClear := clearAntigravityReasoningReplayOnInvalidSignature(ctx, replayScope, httpResp.StatusCode, bodyBytes); errClear != nil {
// Report the upstream failure rather than the cleanup failure.
logAntigravityReasoningReplayDegraded(replayScope, "invalidate", errClear)
}
err = newAntigravityStatusErr(httpResp.StatusCode, bodyBytes)
return nil, err
}
// Stream success
if useCredits {
clearAntigravityCreditsFailureState(auth)
}
replayAccumulator := newAntigravityReasoningReplayAccumulator(replayScope, requestPayload)
out := make(chan cliproxyexecutor.StreamChunk)
go func(resp *http.Response) {
defer close(out)
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("antigravity executor: close response line error: %v", errClose)
}
}()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(nil, streamScannerBuffer)
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
if replayAccumulator != nil {
replayAccumulator.ObserveSSELine(line)
}
// Filter usage metadata for all models
// Only retain usage statistics in the terminal chunk
line = helps.FilterSSEUsageMetadata(line)
payload := helps.JSONPayload(line)
if payload == nil {
continue
}
if detail, ok := helps.ParseAntigravityStreamUsage(payload); ok {
reporter.Publish(ctx, detail)
}
payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, payload)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), &param, claudeInputTokens)
for i := range chunks {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
case <-ctx.Done():
return
}
}
}
tail := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), &param, claudeInputTokens)
for i := range tail {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}:
case <-ctx.Done():
return
}
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
reporter.PublishFailure(ctx, errScan)
select {
case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
case <-ctx.Done():
}
} else {
if replayAccumulator != nil {
replayAccumulator.Commit(ctx)
}
reporter.EnsurePublished(ctx)
}
}(httpResp)
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
switch {
case lastStatus != 0:
err = newAntigravityStatusErr(lastStatus, lastBody)
case lastErr != nil:
err = lastErr
default:
err = statusErr{code: http.StatusServiceUnavailable, msg: "antigravity executor: no base url available"}
}
return nil, err
}
return nil, err
}