mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
* chore(release): open v3.8.27 development cycle * fix(security): polynomial ReDoS in comboAgentMiddleware regex (#3982) * fix(security): eliminate polynomial ReDoS in comboAgentMiddleware <omniModel> regex (CodeQL js/polynomial-redos) CACHE_TAG_PATTERN wrapped the tag in an unbounded `(?:\\n|\n|\r)*` prefix/suffix. On an unanchored `.test()`/`.exec()` that is O(n²) on inputs with many newlines (CodeQL js/polynomial-redos, alerts #612/#613). The surrounding runs are irrelevant to detecting/capturing the tag, so the detection pattern now matches only the core `<omniModel>([^<]+)</omniModel>`; the global strip pattern still consumes the wrapping newlines (combo.ts streaming, #531) but BOUNDED ({0,16}) so it stays linear. Behavior preserved: detection, model extraction, multi-tag stripping (#454) and blank-line cleanup all unchanged (107 related tests green). Adds ReDoS-safety regression tests (50k-newline inputs complete in <1ms). * docs(changelog): add #3982 ReDoS fix to [3.8.27] * ci(security): harden workflows — artipacked persist-credentials + cache-poisoning + SC2086 (#3965) * Refine provider quota card display (#3969) Integrated into release/v3.8.27 * feat: add sidebar group separator toggles (#3971) Integrated into release/v3.8.27 * Gate control-plane proxy direct fallback (#3963) Integrated into release/v3.8.27 * Capture actual upstream provider requests (#3941) Integrated into release/v3.8.27 * ci(quality): flip require-tighten + osv + Trivy to blocking (v3.8.27 cycle-end) (#3984) * fix(resilience): respect connection cooldown stored as numeric epoch (#3954) (#3995) rate_limited_until is a TEXT column, but setConnectionRateLimitUntil (Antigravity full-quota path) persists a raw epoch number that SQLite coerces to a numeric string ("1781696905131.0"). The selection predicate isAccountUnavailable then did new Date("1781696905131.0") -> NaN, so the cooling connection was never skipped and the router kept dispatching to rate-limited accounts. Normalize numeric-epoch strings (and number/Date/ISO) via a shared cooldownUntilMs() helper in isAccountUnavailable / getEarliestRateLimitedUntil / filterAvailableAccounts / parseFutureDateMs. ISO behavior preserved. * fix(providers): fetch live /models for LLM7 and BytePlus (#3976) (#3996) llm7 and byteplus carry a real modelsUrl but were not classified by any live-fetch branch of the model-import route, so their hardcoded 4-entry registry catalog was served (source local_catalog) instead of the upstream catalog. Add both to NAMED_OPENAI_STYLE_PROVIDERS so the route probes <baseUrl>/models and serves the live list, falling back to the local catalog only on fetch failure. * fix(dashboard): logs auto-refresh reads live visibility, not a stale mount ref (#3972) (#3997) The auto-refresh interval gated each tick on visibleRef, seeded once at mount and updated only by a visibilitychange event. A tab mounted while document.visibilityState is 'hidden' (background load, bfcache, embedded/proxied webviews) with no later visibilitychange left the ref false forever, so the interval ticked but never fetched — only the manual button worked. Read the live document.visibilityState in the tick instead. * feat(compression): add Indonesian caveman rules and language pack (#3975) Integrated into release/v3.8.27 (cherry picked from commitc9b5b1a892) * fix(combo): shuffle strict-random fallback remainder to spread load (#3959) (#3998) strict-random shuffled only the deck-selected slot 0 and left the fallback remainder in fixed priority order, so after a failing deck pick the chain always fell through to the same top-priority model — a persistently-failing model was retried on essentially every request and fallback load never spread across peers. Shuffle the remainder too (like the random strategy). * Add provider auth visibility controls (#3953) Integrated into release/v3.8.27 * fix(claude): forward client tool-search-tool anthropic-beta on the Claude OAuth path (#3974) (#3999) The client-negotiated anthropic-beta: tool-search-tool-2025-10-19 was dropped on both Claude code paths (default executor rebuilt from static ANTHROPIC_BETA_CLAUDE_OAUTH; selectBetaFlags only read the client beta to gate thinking/effort), so claude.ai rejected deferred-tool requests with 400 'Tool reference not found'. Add an allowlist-merge (mergeClientAnthropicBeta) that unions the client's allowlisted betas into the outbound set on both paths, preserving #3415 (no forced thinking/effort). * feat(providers): add model search filter to provider dashboard (#3950) Integrated into release/v3.8.27 * fix(vision-bridge): force bridge for tokenrouter deepseek models (#3946) Integrated into release/v3.8.27 * fix(executor): strip stream_options on non-streaming requests (#3884) (#4000) Clients that send stream_options:{include_usage:true} regardless of stream (e.g. the OpenAI Python SDK) had it passed through on non-streaming calls; NVIDIA NIM rejected it with 400 'Stream options can only be defined when stream=True'. DefaultExecutor.transformRequest only injected/cleared stream_options on the streaming branch and never stripped a client-sent value when stream=false. Add a !stream strip branch; the streaming injection path is unchanged. Global to openai-compat providers. * fix(qwen-web): cookie validation false-positive - check response body for user object (#3958) Integrated into release/v3.8.27 * fix(db): persist backup retention days (#3970) Integrated into release/v3.8.27 * 大量UI显示和i18n优化 (#3973) Integrated into release/v3.8.27 * deps: bump the npm_and_yarn group across 1 directory with 2 updates (#3943) Integrated into release/v3.8.27 * deps: bump form-data from 4.0.5 to 4.0.6 (#3944) Integrated into release/v3.8.27 * deps: bump vite from 8.0.5 to 8.0.16 (#3942) Integrated into release/v3.8.27 * chore(quality): re-baseline validation.ts 4407->4428 (#3958 qwen body-check) The qwen-web validation body-check merged in #3958 pushed validation.ts past its frozen size on the integrated release tip. Bump the baseline with justification; no logic is separately extractable from the existing qwen-web validation branch. * deps: bump the production group with 13 updates (#3915) Integrated into release/v3.8.27 — low-risk group (playwright 1.60→1.61 minor + transitive patches; fumadocs-core 16.9→16.10 minor). * chore(deps): ignore jscpd major bumps (v5 Rust rewrite breaks the duplication gate) Our duplication ratchet (scripts/check/check-duplication.mjs) is pinned to jscpd@4 and parses jscpd-report.json against a frozen baseline. jscpd v5 is a native Rust binary with no Node.js API and a different report/bin, so a major bump would break the gate. Migrate deliberately, not via dependabot. Closes the noise from #3916. * fix(perplexity-web): parse schematized diff_block stream so answers aren't empty (#4001) Integrated into release/v3.8.27 — schematized diff_block parsing follow-up to #3938. * refactor: modularize providerRegistry.ts into 159 individual provider plugins (#3993) Modularize provider registry (#3594). Integrated into release/v3.8.27 after rebase + behavior-preservation verification (provider-consistency gate 159/232/0, typecheck, registry tests, build 556/556). Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(registry): restore byteplus + mimocode dropped by #3993 modularization The provider-registry modularization (#3993) was cut from a base predating the byteplus (#3877) and mimocode (#3837) registry entries, so merging it silently dropped both providers (getRegistryEntry returned undefined → validation reported 'not supported'). Re-add them as registry modules in the new structure; registered count 159→161, provider-consistency 161/232/0. Also align the pre-existing qwen-web validator test to #3958: since the validator now requires a real `user` object in the 200 body, the mock must carry one. * refactor: modularize schemas (non-stacked) (#3988) Modularize validation schemas (#3594). Integrated into release/v3.8.27 after rebase (reconciled the merged hiddenSidebarGroupLabels #3971 + intelligenceSyncRequestSchema into the new modules) + behavior verification (typecheck, 195 schema/settings/validation tests, build 556/556). Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> * fix(default-executor): honor custom providerSpecificData.baseUrl for OpenAI-format providers (#4002) Integrated into release/v3.8.27 — honor custom providerSpecificData.baseUrl in DefaultExecutor (openai-format), tested. * feat(openai): honor custom base URL in model discovery + complete openai/codex pricing (#4005) Integrated into release/v3.8.27 — openai model-discovery honors custom base URL (SSRF-guarded) + pricing rows for new openai/codex models. Tested + baselines bumped. * fix(live-ws): bridge sidecar events to dashboard (#4004) Integrated into release/v3.8.27 — repair LiveWS sidecar (startup, same-origin /live-ws, main→sidecar compression.completed bridge, early-msg queue). Fixed the cookie-parse regex (\s) + added a focused unit test; baseline bumped for the non-blocking chatCore bridge. * docs(troubleshooting): note MITM proxy cannot intercept Windows-host apps under WSL (#4003) Integrated into release/v3.8.27 — MITM/WSL troubleshooting note. * fix(repo): untrack accidentally-committed root node_modules symlink + gitignore it A worktree node_modules symlink (-> the main checkout's node_modules) was staged by a `git add -A` during the #3988 merge and committed into05213ac6a. The symlink points at the repo's own node_modules path, so checking it out turns the main checkout's node_modules into a self-referential symlink (breaking tsx/all node ops). Untrack it and add a root-anchored /node_modules ignore so the symlink form can't be re-committed (the existing 'node_modules/' only matches directories). * fix(quality): allowlist socks dep (declared by #4004, never allowlisted) socks@^2.8.7 was added to package.json in #4004 (LiveWS sidecar,02302131f) as a phantom-dep cleanup but never added to dependency-allowlist.json, so check:deps has been red on the release tip ever since. socks is the standard SOCKS proxy client (dep of fetch-socks), legitimate and years old. * feat(sse): real LLMLingua-2 ONNX compression engine (stable) (#4014) Integrated into release/v3.8.27. Adjustments before merge: - Synced with the current release tip (was 11 commits behind). - Added the 3 LLMLingua-2 ONNX optional-runtime deps to dependency-allowlist.json (@atjsh/llmlingua-2, @tensorflow/tfjs, js-tiktoken) — the only gate that was red. - socks was allowlisted directly on release (separate fix d7db5c73d; it was declared by #4004 but never allowlisted, leaving check:deps red release-wide). Verified locally: check:deps OK, file-size OK, public-creds OK, provider-consistency 161/232/0, typecheck:core clean, 24/24 LLMLingua tests pass. The only remaining Fast-QG red is the pre-existing #3972 orphan test (request-logger-autorefresh-visibility-3972.test.tsx), which is release-wide and unrelated to this PR. * test(dashboard): rehome #3972 logs auto-refresh test so a runner collects it tests/unit/request-logger-autorefresh-visibility-3972.test.tsx (added by #3972 via #3997) sat at the top level of tests/unit/ as a .tsx vitest test, which NO runner collects: the node runner only globs *.test.ts, and test:vitest:ui only runs tests/unit/ui. So the #3972 regression guard never executed in CI and check:test-discovery was red release-wide. Move it under tests/unit/ui/ (the collected vitest:ui path) and fix the relative import depth. Verified: the test now runs and passes (2/2), and check:test-discovery is green. * feat(compression): capture per-engine analytics (#3960) + Lite schema fix (#3952) (#4018) Captures the net-new value from #3960 (per-engine breakdown analytics) and #3952 (Lite engine schema fix) onto release/v3.8.27. Fast QG green; 622/622 compression+analytics tests pass. * fix(sse): guard model-less registry entries in getUnsupportedParams (mimocode) (#4015) Real bugfix: guard model-less registry entries (mimocode) in getUnsupportedParams so handleChatCore no longer throws 'entry.models is not iterable' / reports 'All models failed' for unrelated requests. Includes a regression test. Fast QG green. * feat(ci): Quality Gate v2 — Onda 0 + Onda 1 (gate flips, TIA, SAST, DAST-smoke, mutation infra) (#4016) * docs(ops): add quality-gate assessment + replication playbook (Fase 9 foundation) * feat(ci): flip oasdiff breaking-change gate to blocking (ratchet) * docs(ops): deliver main branch-protection ruleset for owner to apply * fix(ci): run typecheck:core in PR->release fast-gates (close fast-gates hole, part 1) * perf(mutation): enable Stryker incremental mode + cache (scales the 60/80 rollout) * feat(ci): commit CodeQL advanced config (security-extended), replacing default-setup * feat(ci): version semgrep SAST workflow (owasp/secrets), advisory * feat(quality): TIA test-impact map builder (import-graph; map built at runtime, gitignored) * feat(quality): TIA impacted-test selector with run-all fail-safe * fix(ci): run TIA-impacted unit tests in PR->release fast-gates (build map at runtime, fail-safe full) * feat(ci): DAST-smoke per-PR (schemathesis subset + promptfoo injection-guard, blocking) * fix(ci): unbreak Fase 9 PR CI (MDX frontmatter, CodeQL conflict, dast-smoke advisory) - Add MDX frontmatter to docs/ops/{BRANCH_PROTECTION_MAIN,QUALITY_GATE_PLAYBOOK}.md. fumadocs rejects frontmatter-less docs -> 'npm run build' failed -> broke dast-smoke's build step (the release fast-gates never runs build, so this only surfaced on the PR). - codeql.yml: workflow_dispatch-only until the owner switches repo CodeQL Default->Advanced (advanced configs cannot be processed while default setup is enabled; documented inline). - dast-smoke.yml: job-level continue-on-error (advisory) so this brand-new gate matures before it blocks (repo convention: advisory -> blocking). * ci(quality): make TIA unit-test step advisory until release test-debt is cleared release/v3.8.27 carries ~17 pre-existing failing unit tests (budget #3537, apiKey #3552, several Zod schemas, Puter/Qwen executors, mimocode entry, etc.) unrelated to this PR — the new 'run tests on PR->release' gate surfaced them. Per the repo's advisory->blocking convention, this step enters advisory (it still runs + reports) so pre-existing debt doesn't block the gate program. typecheck:core stays blocking. Flip to blocking (remove continue-on-error) once the release suite is green. * fix(sse): preserve Kiro streaming finish_reason tool_calls (#3980) (#4025) * fix(guardrails): preserve original image when vision-bridge describe fails (#4012) (#4026) * feat(api): advertise combo capabilities on import surfaces (#3979) (#4027) * feat(sse): delegated Anthropic Context Editing for Claude (clear_tool_uses) (#4021) Opt-in Claude-only delegated compression: injects context_management.clear_tool_uses_20250919 at the Claude pre-serialization chokepoint (composes with clear_thinking, thinking first), threaded via ExecuteInput from handleChatCore. Pure edit-builder + 11 tests (7 unit + 4 e2e fetch-capture). Beta context-management-2025-06-27 already advertised; allowlist done. Telemetry/400-fallback/claude-web coverage deferred. * fix(opencode): map x-session-affinity to x-opencode-session for custom providers (#4022) (#4028) * fix(dashboard): Playground Compare tab loading + HTTP method guard (#4024) randomUUID non-HTTPS fallback + static CompareTab import; raw HTTP TRACE->405 method guard wired into dev + standalone servers. Integrated into release/v3.8.27. * refactor(dashboard): settings UI layout + API Keys naming (#4020) Presentation/relabel refactor of the Settings dashboard (API Manager -> API Keys), card relocations, Toggle adoption, present-but-disabled engine steps. Auth-file changes are string/comment-only (no behavior change). Integrated into release/v3.8.27. * fix: restore unit regressions dropped by lossy schema/registry modularizations (#4030) Restores schema fields (combo reasoningTokenBuffer, budget-0 #3537, openrouter preset, proxy family #3777, resilience degradation/providerCooldown), qwen-web v2 endpoint+catalog, mimocode models key — all dropped by #3988/#3993 — and aligns 3 tests to #3941/#3993. Verified: 8 failing regression tests on release tip -> 131/131 green on this branch. Integrated into release/v3.8.27. * fix(api): return 400 (not 500) for malformed JSON on /api/auth/login (#4031) Wrap request.json() so a malformed/non-JSON login body returns a structured 400 instead of falling through to the 500 catch. Fixes the schemathesis high-risk-endpoint DAST finding (verified: schemathesis step now passes). +TDD test. Integrated into release/v3.8.27. * feat(dashboard): real circuit-breaker state in the Combo Live cascade (U1b) (#4029) Overlays real provider circuit-breaker state (GET /api/monitoring/health) onto the Combo Live cascade as a 'CB: OPEN · 41s' badge. Pure enrichRunWithBreakers + fail-soft useProviderBreakerHealth poll; graceful when health is absent. +13 tests. Integrated into release/v3.8.27. * Fix promptfoo security assertion parsing (#4032) * chore(deps): dependabot security bumps + drop unused gray-matter (#4036) Integrated into release/v3.8.27 — dependabot security bumps (form-data/js-yaml/protobufjs/dompurify/hono) + drop unused gray-matter. Unblocks the npm audit:deps gate (Lint) branch-wide. * fix(ci): scope TIA to node:test unit files only (mirror test:unit glob) (#4035) Integrated into release/v3.8.27 — scopes the advisory TIA step to the test:unit node:test glob, fixing the 99 false failures. +4 TDD. * Refine compression settings, storage labels, and sidebar grouping (#4033) Integrated into release/v3.8.27 — relocate Token Saver into Compression Settings (controlled component), reorder Security/Authz tabs, storage labels + i18n relabel. Thanks @rdself! * [codex] add per-key local usage command (#4034) Integrated into release/v3.8.27 — per-key local @@om-usage command (cached quota, no upstream routing). Rebased onto modularized schemas/keys.ts + file-size rebaseline. Thanks @Witroch4! * chore(release): reconcile v3.8.27 CHANGELOG + i18n mirrors * ci(quality): unblock v3.8.27 release gates (zizmor pin + test-masking allowlist) - zizmor ratchet (151→139, no regression): SHA-pin every action ref ADDED this cycle — codeql/dast-smoke/semgrep (3 new workflows) + trivy-action (docker-publish) + actions/cache (nightly-mutation). Pre-existing tag refs keep the repo convention. - test-masking: add config/quality/test-masking-allowlist.json + allowlist support in check-test-masking.mjs (exempts ONLY the net-assert-reduction signal; tautology/skip/ deletion still fire). Allowlists 2 verified-legitimate reductions: appearance-widget-settings-schema (#4033 removed showTokenSaverOnEndpoint field) and dashboard-shell-tabs (#3973 tabs→redirect refactor, asserts replaced). +4 gate tests. * test(quality): reword test-masking self-test comments to avoid literal masking patterns The added allowlist-test comments contained the literal strings 'assert.ok(true)' and '.skip' which the masking detector's own regexes match as text — making the gate flag its own test file (net +1 tautology/skip/extended-tautology vs main). Reworded to plain prose ('a new tautology', 'a new skip marker'); test logic unchanged (24/24 pass). * fix(quality): unblock v3.8.27 release — align 3 stale tests + restore modularized settings-schema parity Release-PR full CI surfaced 3 deterministic test failures (no live product regression), all stale vs legitimate cycle changes: - settings-schema parity (#3988): the modularized updateSettingsSchema barrel (schemas/settings.ts) had diverged from the canonical settingsSchemas.ts (45 vs 85 fields — 40 dropped + 6 extra), a lossy-modularization dead-code copy. Re-export from the canonical source so the barrel can never diverge again (runtime already uses canonical). Parity test now passes. - api-manager permissions modal: #4034 added a 4th self-service switch (per-key usage allowance); a11y invariant (every switch type="button") still holds. Updated the static count 3 -> 4. - pack-artifact policy: dist/http-method-guard.cjs became a required runtime path; added it to the test's expected missing-paths list. Also documents the gate gap for Fase 9 (QUALITY_GATE_PLAYBOOK Parte 6): G1 run the deterministic unit layer + test-masking on PR->release (not just PR->main), G2 a modularization-parity gate (would have caught the #3988 drop at its PR), G3 flake quarantine. Env flakes (LiveWS startup timeout, integration server-startup cascade) are pre-existing/CI-env, triaged separately. --------- Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Veier04 <118300867+Veier04@users.noreply.github.com> Co-authored-by: Felipe Sartori <felipesartori.ti@gmail.com> Co-authored-by: WormAlien <164898390+WormAlien@users.noreply.github.com> Co-authored-by: thezukiru <121331256+thezukiru@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: NOXX - Commiter <artur1992123@mail.ru> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Demiurge The Single <megamen932@gmail.com> Co-authored-by: Witroch4 <witalo_rocha@hotmail.com>
1655 lines
62 KiB
TypeScript
1655 lines
62 KiB
TypeScript
import crypto, { randomUUID } from "crypto";
|
|
import {
|
|
BaseExecutor,
|
|
mergeAbortSignals,
|
|
mergeUpstreamExtraHeaders,
|
|
type ExecuteInput,
|
|
type ExecutorLog,
|
|
type ProviderCredentials,
|
|
} from "./base.ts";
|
|
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
|
import { buildAntigravityUpstreamError } from "./antigravityUpstreamError.ts";
|
|
import {
|
|
PROVIDERS,
|
|
OAUTH_ENDPOINTS,
|
|
HTTP_STATUS,
|
|
STREAM_READINESS_TIMEOUT_MS,
|
|
ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE,
|
|
} from "../config/constants.ts";
|
|
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
|
|
import {
|
|
antigravityNativeOAuthUserAgent,
|
|
antigravityUserAgent,
|
|
} from "../services/antigravityHeaders.ts";
|
|
import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts";
|
|
import {
|
|
injectCreditsField,
|
|
shouldRetryWithCredits,
|
|
shouldUseCreditsFirst,
|
|
getCreditsMode,
|
|
handleCreditsFailure,
|
|
} from "../services/antigravityCredits.ts";
|
|
import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance";
|
|
import { setConnectionRateLimitUntil } from "@/lib/db/providers";
|
|
import { getMitmAlias } from "@/lib/db/models";
|
|
import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts";
|
|
import { resolveAntigravityVersion } from "../services/antigravityVersion.ts";
|
|
import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts";
|
|
import {
|
|
resolveAntigravityModelId,
|
|
getAntigravityModelFallbacks,
|
|
} from "../config/antigravityModelAliases.ts";
|
|
import { cloakAntigravityToolPayload } from "../config/toolCloaking.ts";
|
|
import {
|
|
shouldStripCloudCodeThinking,
|
|
stripCloudCodeThinkingConfig,
|
|
} from "../services/cloudCodeThinking.ts";
|
|
import { buildGeminiTools } from "../translator/helpers/geminiToolsSanitizer.ts";
|
|
import {
|
|
applyAntigravityClientProfileHeaders,
|
|
removeHeaderCaseInsensitive,
|
|
} from "../services/antigravityClientProfile.ts";
|
|
import {
|
|
generateAntigravityRequestId,
|
|
getAntigravityEnvelopeUserAgent,
|
|
getAntigravitySessionId,
|
|
} from "../services/antigravityIdentity.ts";
|
|
import * as prl from "../utils/providerRequestLogging.ts";
|
|
|
|
const MAX_RETRY_AFTER_MS = 60_000;
|
|
const LONG_RETRY_THRESHOLD_MS = 60_000;
|
|
const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours
|
|
// The upstream API uses plain model IDs (no -high/-low suffix).
|
|
// Tier suffixes were speculative and caused 404 for gemini-3.x models — the
|
|
// bare-Pro→Low normalization was retired (the set stayed empty, making the guard
|
|
// dead code). Only keep models that are live-proven via streamGenerateContent.
|
|
|
|
interface AntigravityContent {
|
|
role: string;
|
|
parts: unknown[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
type AntigravityCredentials = ProviderCredentials & {
|
|
projectId?: string | null;
|
|
expiresIn?: number;
|
|
};
|
|
|
|
type AntigravityChunkContent = Record<string, unknown> & {
|
|
role?: string;
|
|
parts?: Array<
|
|
Record<string, unknown> & {
|
|
text?: unknown;
|
|
functionCall?: Record<string, unknown>;
|
|
functionResponse?: unknown;
|
|
thought?: unknown;
|
|
thoughtSignature?: unknown;
|
|
}
|
|
>;
|
|
};
|
|
|
|
type AntigravityCreditEntry = {
|
|
creditType?: string;
|
|
creditAmount?: string;
|
|
};
|
|
|
|
function getChunkedOrFixedBody(bodyStr: string, stream: boolean): BodyInit {
|
|
if (stream) {
|
|
return new ReadableStream(
|
|
{
|
|
async start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(bodyStr));
|
|
controller.close();
|
|
},
|
|
},
|
|
{ highWaterMark: 16384 }
|
|
);
|
|
}
|
|
return bodyStr;
|
|
}
|
|
|
|
function cloneAntigravityRequestBody(body: unknown): unknown {
|
|
if (!body || typeof body !== "object") {
|
|
return body;
|
|
}
|
|
|
|
try {
|
|
return structuredClone(body);
|
|
} catch {
|
|
return JSON.parse(JSON.stringify(body));
|
|
}
|
|
}
|
|
|
|
function serializeAntigravityRequest(
|
|
provider: string,
|
|
headers: Record<string, string>,
|
|
body: unknown
|
|
): { headers: Record<string, string>; bodyString: string } {
|
|
const serializedBody = cloneAntigravityRequestBody(body);
|
|
|
|
if (!isCliCompatEnabled(provider)) {
|
|
return { headers, bodyString: JSON.stringify(serializedBody) };
|
|
}
|
|
return applyFingerprint(provider, { ...headers }, serializedBody);
|
|
}
|
|
|
|
type AntigravityCollectedStream = {
|
|
textContent: string;
|
|
finishReason: string;
|
|
toolCalls: Array<{
|
|
id: string;
|
|
index: number;
|
|
type: "function";
|
|
function: { name: string; arguments: string };
|
|
}>;
|
|
usage: Record<string, unknown> | null;
|
|
remainingCredits: Array<{ creditType: string; creditAmount: string }> | null;
|
|
};
|
|
|
|
function stripZeroWidth(value: unknown): unknown {
|
|
if (typeof value === "string") {
|
|
return value.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => stripZeroWidth(item));
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
|
key,
|
|
stripZeroWidth(item),
|
|
])
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseAntigravityTextualToolCall(text: unknown): { name: string; args: unknown } | null {
|
|
if (typeof text !== "string") return null;
|
|
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
|
const match = normalized.match(
|
|
/^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/
|
|
);
|
|
if (!match) return null;
|
|
const name = match[1]?.trim();
|
|
const rawArgs = match[2]?.trim();
|
|
if (!name || !rawArgs) return null;
|
|
try {
|
|
return { name, args: stripZeroWidth(JSON.parse(rawArgs)) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function addAntigravityTextualToolCall(
|
|
collected: AntigravityCollectedStream,
|
|
parsed: { name: string; args: unknown }
|
|
): void {
|
|
collected.toolCalls.push({
|
|
id: `${parsed.name}-${Date.now()}-${collected.toolCalls.length}`,
|
|
index: collected.toolCalls.length,
|
|
type: "function",
|
|
function: {
|
|
name: parsed.name,
|
|
arguments: JSON.stringify(parsed.args || {}),
|
|
},
|
|
});
|
|
collected.finishReason = "tool_calls";
|
|
}
|
|
|
|
type AntigravityRequestEnvelope = Record<string, unknown> & {
|
|
project: string;
|
|
model?: string;
|
|
userAgent: "antigravity" | "jetski";
|
|
requestType: "agent" | "image_gen";
|
|
requestId: string;
|
|
request: Record<string, unknown>;
|
|
enabledCreditTypes?: string[];
|
|
};
|
|
|
|
class AntigravityPreResponseTimeoutError extends Error {
|
|
code = ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE;
|
|
status = HTTP_STATUS.GATEWAY_TIMEOUT;
|
|
|
|
constructor(timeoutMs: number, url: string) {
|
|
super(`Antigravity upstream did not return response headers within ${timeoutMs}ms: ${url}`);
|
|
this.name = "TimeoutError";
|
|
}
|
|
}
|
|
|
|
function getAbortErrorCode(error: unknown): string | null {
|
|
if (!error || typeof error !== "object") return null;
|
|
const value = (error as { code?: unknown }).code;
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function isAntigravityPreResponseTimeout(error: unknown): boolean {
|
|
return getAbortErrorCode(error) === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE;
|
|
}
|
|
|
|
/**
|
|
* Per-account GOOGLE_ONE_AI credits-exhausted tracker.
|
|
* Key: accountId (OAuth subject / email). Value: expiry timestamp.
|
|
* When credits hit 0 we skip the credit retry for CREDITS_EXHAUSTED_TTL_MS.
|
|
*/
|
|
const MAX_CREDITS_EXHAUSTED_ENTRIES = 50;
|
|
const creditsExhaustedUntil = new Map<string, number>();
|
|
|
|
const _creditsExhaustedSweep = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, until] of creditsExhaustedUntil) {
|
|
if (now >= until) creditsExhaustedUntil.delete(key);
|
|
}
|
|
}, 60_000);
|
|
if (typeof _creditsExhaustedSweep === "object" && "unref" in _creditsExhaustedSweep) {
|
|
(_creditsExhaustedSweep as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
const MAX_CREDIT_BALANCE_ENTRIES = 50;
|
|
const CREDIT_BALANCE_TTL_MS = 5 * 60 * 1000;
|
|
const creditBalanceCache = new Map<string, { balance: number; updatedAt: number }>();
|
|
let creditCacheHydrated = false;
|
|
|
|
function hydrateCreditCacheFromDb(): void {
|
|
if (creditCacheHydrated) return;
|
|
creditCacheHydrated = true;
|
|
try {
|
|
const persisted = getAllPersistedCreditBalances();
|
|
for (const [accountId, balance] of persisted) {
|
|
if (!creditBalanceCache.has(accountId)) {
|
|
creditBalanceCache.set(accountId, { balance, updatedAt: Date.now() });
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function evictStaleCreditBalanceEntries(): void {
|
|
const now = Date.now();
|
|
for (const [key, entry] of creditBalanceCache) {
|
|
if (now - entry.updatedAt > CREDIT_BALANCE_TTL_MS) {
|
|
creditBalanceCache.delete(key);
|
|
}
|
|
}
|
|
while (creditBalanceCache.size > MAX_CREDIT_BALANCE_ENTRIES) {
|
|
const oldestKey = creditBalanceCache.keys().next().value;
|
|
if (oldestKey !== undefined) creditBalanceCache.delete(oldestKey);
|
|
else break;
|
|
}
|
|
}
|
|
|
|
const _creditBalanceSweep = setInterval(evictStaleCreditBalanceEntries, 60_000);
|
|
if (typeof _creditBalanceSweep === "object" && "unref" in _creditBalanceSweep) {
|
|
(_creditBalanceSweep as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
export function getAntigravityRemainingCredits(accountId: string): number | null {
|
|
hydrateCreditCacheFromDb();
|
|
const entry = creditBalanceCache.get(accountId);
|
|
if (!entry) return null;
|
|
if (Date.now() - entry.updatedAt > CREDIT_BALANCE_TTL_MS) {
|
|
creditBalanceCache.delete(accountId);
|
|
return null;
|
|
}
|
|
return entry.balance;
|
|
}
|
|
|
|
export function updateAntigravityRemainingCredits(accountId: string, balance: number): void {
|
|
if (creditBalanceCache.size >= MAX_CREDIT_BALANCE_ENTRIES && !creditBalanceCache.has(accountId)) {
|
|
const oldestKey = creditBalanceCache.keys().next().value;
|
|
if (oldestKey !== undefined) creditBalanceCache.delete(oldestKey);
|
|
}
|
|
creditBalanceCache.set(accountId, { balance, updatedAt: Date.now() });
|
|
try {
|
|
persistCreditBalance(accountId, balance);
|
|
} catch {}
|
|
}
|
|
|
|
function isCreditsExhausted(accountId: string): boolean {
|
|
const until = creditsExhaustedUntil.get(accountId);
|
|
if (!until) return false;
|
|
if (Date.now() >= until) {
|
|
creditsExhaustedUntil.delete(accountId);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function markCreditsExhausted(accountId: string): void {
|
|
if (
|
|
creditsExhaustedUntil.size >= MAX_CREDITS_EXHAUSTED_ENTRIES &&
|
|
!creditsExhaustedUntil.has(accountId)
|
|
) {
|
|
const now = Date.now();
|
|
for (const [key, until] of creditsExhaustedUntil) {
|
|
if (now >= until) {
|
|
creditsExhaustedUntil.delete(key);
|
|
}
|
|
}
|
|
if (creditsExhaustedUntil.size >= MAX_CREDITS_EXHAUSTED_ENTRIES) {
|
|
const oldestKey = creditsExhaustedUntil.keys().next().value;
|
|
if (oldestKey !== undefined) creditsExhaustedUntil.delete(oldestKey);
|
|
}
|
|
}
|
|
creditsExhaustedUntil.set(accountId, Date.now() + CREDITS_EXHAUSTED_TTL_MS);
|
|
}
|
|
|
|
/**
|
|
* Persist a quota-exhausted cooldown to the DB for `connectionId` so that
|
|
* cross-request and post-restart routing skips this connection until the
|
|
* cooldown expires. Exported for unit testing. @internal
|
|
*/
|
|
export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: number): void {
|
|
try {
|
|
setConnectionRateLimitUntil(connectionId, Date.now() + retryAfterMs);
|
|
} catch {
|
|
// DB write failure must never crash the request path
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accumulate one Antigravity SSE `data:` payload into `collected`. Exported for unit
|
|
* tests (the markdown / candidate-parts extraction branches). @internal
|
|
*/
|
|
export function processAntigravitySSEPayload(
|
|
payload: string,
|
|
collected: AntigravityCollectedStream,
|
|
log?: { debug?: (scope: string, message: string) => void }
|
|
) {
|
|
if (!payload || payload === "[DONE]") return;
|
|
try {
|
|
const parsed = JSON.parse(payload);
|
|
const markdown =
|
|
typeof parsed?.markdown === "string"
|
|
? parsed.markdown
|
|
: typeof parsed?.response?.markdown === "string"
|
|
? parsed.response.markdown
|
|
: null;
|
|
if (markdown) {
|
|
collected.textContent += markdown;
|
|
}
|
|
const candidate = parsed?.response?.candidates?.[0];
|
|
if (candidate?.content?.parts) {
|
|
for (const part of candidate.content.parts) {
|
|
if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) {
|
|
const textualToolCall = parseAntigravityTextualToolCall(part.text);
|
|
if (textualToolCall) {
|
|
addAntigravityTextualToolCall(collected, textualToolCall);
|
|
} else {
|
|
collected.textContent += part.text;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (candidate?.finishReason) {
|
|
collected.finishReason =
|
|
candidate.finishReason.toLowerCase() === "stop"
|
|
? "stop"
|
|
: candidate.finishReason.toLowerCase();
|
|
}
|
|
if (parsed?.response?.usageMetadata) {
|
|
const um = parsed.response.usageMetadata;
|
|
collected.usage = {
|
|
prompt_tokens: um.promptTokenCount || 0,
|
|
completion_tokens: um.candidatesTokenCount || 0,
|
|
total_tokens: um.totalTokenCount || 0,
|
|
};
|
|
}
|
|
if (Array.isArray(parsed?.remainingCredits)) {
|
|
collected.remainingCredits = parsed.remainingCredits;
|
|
}
|
|
} catch {
|
|
log?.debug?.("SSE_PARSE", `Skipping malformed SSE line: ${payload.slice(0, 80)}`);
|
|
}
|
|
}
|
|
|
|
function processAntigravitySSEText(
|
|
text: string,
|
|
partialLine: { value: string },
|
|
collected: AntigravityCollectedStream,
|
|
log?: { debug?: (scope: string, message: string) => void }
|
|
) {
|
|
partialLine.value += text;
|
|
const lines = partialLine.value.split("\n");
|
|
partialLine.value = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("data:")) continue;
|
|
processAntigravitySSEPayload(trimmed.slice(5).trim(), collected, log);
|
|
}
|
|
}
|
|
|
|
function flushAntigravitySSEText(
|
|
partialLine: { value: string },
|
|
collected: AntigravityCollectedStream,
|
|
log?: { debug?: (scope: string, message: string) => void }
|
|
) {
|
|
const trimmed = partialLine.value.trim();
|
|
partialLine.value = "";
|
|
if (!trimmed.startsWith("data:")) return;
|
|
processAntigravitySSEPayload(trimmed.slice(5).trim(), collected, log);
|
|
}
|
|
|
|
/**
|
|
* Strip provider prefixes (e.g. "antigravity/model" → "model").
|
|
* Ensures the model name sent to the upstream API never contains a routing prefix.
|
|
*
|
|
* `modelIdOverride` (#3786): when the per-request Pro-family fallback chain forces a
|
|
* specific upstream id, pass it here. It is an ALREADY-RESOLVED upstream id, so it bypasses
|
|
* the MITM/static alias resolution and is used verbatim (after prefix stripping).
|
|
*/
|
|
async function cleanModelName(model: string, modelIdOverride?: string): Promise<string> {
|
|
if (modelIdOverride) {
|
|
return modelIdOverride.includes("/") ? modelIdOverride.split("/").pop()! : modelIdOverride;
|
|
}
|
|
if (!model) return model;
|
|
const stripped = model.includes("/") ? model.split("/").pop()! : model;
|
|
let clean = stripped;
|
|
|
|
// 1. Check dynamic MITM aliases first (authoritative after first sync).
|
|
// Built during model sync — contains ONLY currently-available models.
|
|
// Obsolete/removed models are automatically excluded.
|
|
try {
|
|
const mitmAliases = await getMitmAlias("antigravity");
|
|
if (mitmAliases && typeof mitmAliases === "object") {
|
|
const aliases = mitmAliases as Record<string, unknown>;
|
|
const raw = aliases[stripped];
|
|
// Only honor string aliases; corrupted/non-string DB values fall through
|
|
// to the static alias resolution below (never return undefined here).
|
|
if (typeof raw === "string" && raw) {
|
|
// Strip the "antigravity/" prefix if present; use the raw model ID otherwise.
|
|
const PREFIX = "antigravity/";
|
|
clean = raw.startsWith(PREFIX) ? raw.slice(PREFIX.length) : raw;
|
|
}
|
|
}
|
|
} catch {
|
|
// DB not available (build phase, transient error) — fall through to static aliases
|
|
}
|
|
|
|
// 2. Fall back to static aliases if MITM didn't resolve
|
|
if (clean === stripped) {
|
|
clean = resolveAntigravityModelId(clean);
|
|
}
|
|
|
|
return clean;
|
|
}
|
|
|
|
function attachToolNameMap<T>(payload: T, toolNameMap: Map<string, string> | null): T {
|
|
if (!toolNameMap?.size || !payload || typeof payload !== "object") {
|
|
return payload;
|
|
}
|
|
|
|
const copy = Array.isArray(payload) ? ([...payload] as T) : ({ ...(payload as object) } as T);
|
|
Object.defineProperty(copy, "_toolNameMap", {
|
|
value: toolNameMap,
|
|
enumerable: false,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
return copy;
|
|
}
|
|
|
|
function getRequestTargetModel(body: Record<string, unknown>): string {
|
|
const target = body.model;
|
|
return typeof target === "string" && target.length > 0 ? target : "unknown";
|
|
}
|
|
|
|
function applyAntigravityGenerationDefaults(request: Record<string, unknown>): void {
|
|
const generationConfig =
|
|
request.generationConfig && typeof request.generationConfig === "object"
|
|
? (request.generationConfig as Record<string, unknown>)
|
|
: {};
|
|
|
|
if (generationConfig.topK === undefined) {
|
|
generationConfig.topK = 40;
|
|
}
|
|
if (generationConfig.topP === undefined) {
|
|
generationConfig.topP = 1.0;
|
|
}
|
|
|
|
const thinkingConfig =
|
|
generationConfig.thinkingConfig && typeof generationConfig.thinkingConfig === "object"
|
|
? (generationConfig.thinkingConfig as Record<string, unknown>)
|
|
: null;
|
|
const thinkingBudget = Number(thinkingConfig?.thinkingBudget);
|
|
const maxOutputTokens = Number(generationConfig.maxOutputTokens);
|
|
if (
|
|
Number.isFinite(thinkingBudget) &&
|
|
thinkingBudget > 0 &&
|
|
(!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget)
|
|
) {
|
|
generationConfig.maxOutputTokens = Math.floor(thinkingBudget) + 1;
|
|
}
|
|
|
|
request.generationConfig = generationConfig;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function sanitizeAntigravityGeminiRequest(
|
|
request: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
const clean: Record<string, unknown> = {};
|
|
|
|
if (Array.isArray(request.contents)) {
|
|
clean.contents = request.contents;
|
|
}
|
|
|
|
if (asRecord(request.systemInstruction)) {
|
|
clean.systemInstruction = request.systemInstruction;
|
|
}
|
|
|
|
clean.generationConfig = asRecord(request.generationConfig)
|
|
? { ...(request.generationConfig as Record<string, unknown>) }
|
|
: {};
|
|
|
|
const geminiTools = buildGeminiTools(request.tools);
|
|
if (geminiTools) {
|
|
clean.tools = geminiTools;
|
|
clean.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } };
|
|
} else if (asRecord(request.toolConfig)) {
|
|
clean.toolConfig = request.toolConfig;
|
|
}
|
|
|
|
if (typeof request.sessionId === "string") {
|
|
clean.sessionId = request.sessionId;
|
|
}
|
|
|
|
return clean;
|
|
}
|
|
|
|
export class AntigravityExecutor extends BaseExecutor {
|
|
constructor() {
|
|
super("antigravity", PROVIDERS.antigravity);
|
|
}
|
|
|
|
buildUrl(model: string, _stream: boolean, urlIndex = 0): string {
|
|
void model;
|
|
const baseUrls = this.getBaseUrls();
|
|
const baseUrl = baseUrls[urlIndex] || baseUrls[0];
|
|
// Always use streaming endpoint — the non-streaming `generateContent` causes
|
|
// upstream 400 errors for some models (e.g. gpt-oss-120b-medium) because the
|
|
// Cloud Code API internally converts to OpenAI format and injects
|
|
// stream_options without setting stream=true. chatCore already handles
|
|
// SSE→JSON conversion for non-streaming client requests.
|
|
return `${baseUrl}/v1internal:streamGenerateContent?alt=sse`;
|
|
}
|
|
|
|
buildHeaders(credentials: AntigravityCredentials, _stream = true): Record<string, string> {
|
|
const raw = {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${credentials.accessToken}`,
|
|
"User-Agent": antigravityUserAgent(),
|
|
Accept: "text/event-stream",
|
|
"X-OmniRoute-Source": "omniroute",
|
|
};
|
|
// Scrub proxy/fingerprint headers that reveal non-native traffic
|
|
return scrubProxyAndFingerprintHeaders(raw);
|
|
}
|
|
|
|
async transformRequest(
|
|
model: string,
|
|
body: unknown,
|
|
_stream: boolean,
|
|
credentials: AntigravityCredentials,
|
|
modelIdOverride?: string
|
|
): Promise<AntigravityRequestEnvelope | Response> {
|
|
// Project ID resolution: prefer OAuth-stored projectId over incoming body.project
|
|
// to avoid stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
|
|
// Opt-in escape hatch: set OMNIROUTER_ALLOW_BODY_PROJECT_OVERRIDE=1.
|
|
const normalizeProjectId = (value: unknown): string | null => {
|
|
if (typeof value !== "string") return null;
|
|
const trimmedValue = value.trim();
|
|
return trimmedValue ? trimmedValue : null;
|
|
};
|
|
const bodyRecord = asRecord(body) ?? {};
|
|
const bodyProjectId = normalizeProjectId(bodyRecord.project);
|
|
const credentialsProjectId = normalizeProjectId(credentials?.projectId);
|
|
const providerSpecificProjectId = normalizeProjectId(
|
|
(credentials?.providerSpecificData as Record<string, unknown> | undefined)?.projectId
|
|
);
|
|
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
|
|
|
|
// Default: prefer OAuth-stored projectId over incoming body.project to avoid
|
|
// stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
|
|
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
|
|
let projectId =
|
|
allowBodyProjectOverride && bodyProjectId
|
|
? bodyProjectId
|
|
: credentialsProjectId || providerSpecificProjectId || bodyProjectId;
|
|
|
|
// Auto-discover a missing projectId via loadCodeAssist before failing (#2334/#2541).
|
|
// A freshly re-added Antigravity account can have an empty stored projectId even when
|
|
// its Google account already owns a Cloud Code project (the OAuth-time loadCodeAssist
|
|
// returned empty/transiently failed). Mirror gemini-cli.ts's bootstrap to recover it
|
|
// here — the helper memoizes per access-token, so this is a one-time round-trip.
|
|
if (!projectId && credentials?.accessToken) {
|
|
const discovered = await ensureAntigravityProjectAssigned(credentials.accessToken);
|
|
if (discovered) projectId = discovered;
|
|
}
|
|
|
|
if (!projectId) {
|
|
// (#489) Return a structured error instead of throwing — gives the client a clear signal
|
|
// to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error".
|
|
const errorMsg =
|
|
"Missing Google projectId for Antigravity account. Auto-discovery via loadCodeAssist " +
|
|
"found no Cloud Code project. Please reconnect OAuth in Providers → Antigravity (and " +
|
|
"ensure the Google account has completed Gemini Code Assist onboarding).";
|
|
const errorBody = {
|
|
error: {
|
|
message: errorMsg,
|
|
type: "oauth_missing_project_id",
|
|
code: "missing_project_id",
|
|
},
|
|
};
|
|
const resp = new Response(JSON.stringify(errorBody), {
|
|
status: 422,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
// Returning a Response object signals the executor to stop and forward it
|
|
return resp as unknown as never;
|
|
}
|
|
|
|
// Validate projectId is non-empty and not just whitespace
|
|
const trimmedProjectId = typeof projectId === "string" ? projectId.trim() : projectId;
|
|
if (!trimmedProjectId) {
|
|
const resp = new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message:
|
|
"Invalid (empty) Google projectId for Antigravity account. " +
|
|
"Please reconnect OAuth in Providers → Antigravity.",
|
|
type: "oauth_missing_project_id",
|
|
code: "missing_project_id",
|
|
},
|
|
}),
|
|
{ status: 422, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
return resp as unknown as never;
|
|
}
|
|
|
|
const upstreamModel = await cleanModelName(model, modelIdOverride);
|
|
const isClaude = upstreamModel.toLowerCase().includes("claude");
|
|
const baseBody = bodyRecord;
|
|
const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel)
|
|
? stripCloudCodeThinkingConfig(baseBody)
|
|
: baseBody;
|
|
const normalizedRequest = asRecord(normalizedBody.request);
|
|
const rawContents = Array.isArray(normalizedRequest?.contents)
|
|
? normalizedRequest.contents
|
|
: [];
|
|
|
|
// Fix contents for Gemini-compatible Cloud Code requests via Antigravity.
|
|
// Claude-branded Antigravity models use the same streamGenerateContent schema.
|
|
const normalizedContents: AntigravityContent[] =
|
|
rawContents.map((content): AntigravityContent => {
|
|
const c = content as AntigravityChunkContent;
|
|
let role = typeof c.role === "string" ? c.role : "user";
|
|
if (c.parts?.some((p) => p.functionResponse)) {
|
|
role = "user";
|
|
}
|
|
|
|
const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false;
|
|
|
|
const parts =
|
|
c.parts?.filter((p) => {
|
|
if (typeof p.text === "string" && p.text === "") return false;
|
|
if (p.functionCall && !p.functionCall.name) return false;
|
|
|
|
// Only strip if it's NOT our bypass sentinel.
|
|
// Antigravity models (like Gemini) need this sentinel to bypass 400 errors.
|
|
return (
|
|
!p.thought &&
|
|
(hasFunctionCall ||
|
|
!p.thoughtSignature ||
|
|
p.thoughtSignature === "skip_thought_signature_validator")
|
|
);
|
|
}) || [];
|
|
return { ...c, role, parts };
|
|
}) || [];
|
|
|
|
const contents: AntigravityContent[] = [];
|
|
for (const c of normalizedContents) {
|
|
if (!Array.isArray(c.parts) || c.parts.length === 0) continue;
|
|
if (contents.length > 0 && contents[contents.length - 1].role === c.role) {
|
|
contents[contents.length - 1].parts.push(...c.parts);
|
|
} else {
|
|
contents.push(c);
|
|
}
|
|
}
|
|
|
|
const rawTransformedRequest = {
|
|
...normalizedRequest,
|
|
...(contents.length > 0 && { contents }),
|
|
sessionId: getAntigravitySessionId(
|
|
credentials,
|
|
typeof normalizedRequest?.sessionId === "string" ? normalizedRequest.sessionId : undefined
|
|
),
|
|
safetySettings: undefined,
|
|
toolConfig:
|
|
Array.isArray(normalizedRequest?.tools) && normalizedRequest.tools.length > 0
|
|
? { functionCallingConfig: { mode: "VALIDATED" } }
|
|
: normalizedRequest?.toolConfig,
|
|
};
|
|
|
|
const transformedRequest = isClaude
|
|
? sanitizeAntigravityGeminiRequest(rawTransformedRequest)
|
|
: rawTransformedRequest;
|
|
|
|
// Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor")
|
|
const requestContents = transformedRequest.contents;
|
|
if (Array.isArray(requestContents)) {
|
|
for (const msg of requestContents) {
|
|
if (Array.isArray(msg.parts)) {
|
|
for (const part of msg.parts) {
|
|
if (typeof part.text === "string") {
|
|
part.text = obfuscateSensitiveWords(part.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
applyAntigravityGenerationDefaults(transformedRequest);
|
|
|
|
const {
|
|
project: _project,
|
|
model: _model,
|
|
userAgent: _userAgent,
|
|
requestType: _requestType,
|
|
requestId: _requestId,
|
|
request: _request,
|
|
...passthroughFields
|
|
} = normalizedBody;
|
|
|
|
const requestType = _requestType === "image_gen" ? "image_gen" : "agent";
|
|
const envelope: AntigravityRequestEnvelope = {
|
|
project: projectId,
|
|
requestId: generateAntigravityRequestId(),
|
|
request: transformedRequest,
|
|
model: upstreamModel,
|
|
userAgent: getAntigravityEnvelopeUserAgent(credentials),
|
|
requestType,
|
|
...passthroughFields,
|
|
};
|
|
|
|
if (requestType === "agent" && envelope.enabledCreditTypes === undefined) {
|
|
envelope.enabledCreditTypes = ["GOOGLE_ONE_AI"];
|
|
}
|
|
|
|
return envelope;
|
|
}
|
|
|
|
async refreshCredentials(
|
|
credentials: AntigravityCredentials,
|
|
log?: ExecutorLog | null
|
|
): Promise<AntigravityCredentials | null> {
|
|
if (!credentials.refreshToken) return null;
|
|
|
|
try {
|
|
const bodyParams: Record<string, string> = {
|
|
grant_type: "refresh_token",
|
|
refresh_token: credentials.refreshToken,
|
|
};
|
|
// Only include non-empty client_id/client_secret — Google OAuth rejects
|
|
// empty params which raw URLSearchParams produces (buildFormParams semantics).
|
|
if (this.config.clientId) bodyParams.client_id = this.config.clientId;
|
|
if (this.config.clientSecret) bodyParams.client_secret = this.config.clientSecret;
|
|
|
|
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
"User-Agent": antigravityNativeOAuthUserAgent(),
|
|
},
|
|
body: new URLSearchParams(bodyParams),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Detect unrecoverable token (invalid_grant = revoked / expired refresh token)
|
|
try {
|
|
const errorBody = (await response.json()) as Record<string, unknown>;
|
|
if (errorBody.error === "invalid_grant") {
|
|
log?.error?.("TOKEN", "Antigravity refresh token revoked. Re-authentication required.");
|
|
return { error: "unrecoverable_refresh_error" } as unknown as AntigravityCredentials;
|
|
}
|
|
} catch {
|
|
// not JSON — fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const tokens = (await response.json()) as Record<string, unknown>;
|
|
log?.info?.("TOKEN", "Antigravity refreshed");
|
|
|
|
return {
|
|
accessToken: typeof tokens.access_token === "string" ? tokens.access_token : undefined,
|
|
refreshToken:
|
|
typeof tokens.refresh_token === "string"
|
|
? tokens.refresh_token
|
|
: credentials.refreshToken,
|
|
expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : undefined,
|
|
projectId: credentials.projectId,
|
|
// Preserve providerSpecificData so a projectId stored there survives the refresh
|
|
// (the onCredentialsRefreshed DB write) instead of being dropped → 422 (#2480).
|
|
providerSpecificData: credentials.providerSpecificData,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
log?.error?.("TOKEN", `Antigravity refresh error: ${message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
generateSessionId(): string {
|
|
return `-${parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 9_000_000_000_000_000_000}`;
|
|
}
|
|
|
|
parseRetryHeaders(headers: Headers | null | undefined): number | null {
|
|
if (!headers?.get) return null;
|
|
|
|
const retryAfter = headers.get("retry-after");
|
|
if (retryAfter) {
|
|
const seconds = parseInt(retryAfter, 10);
|
|
if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
|
|
|
|
const date = new Date(retryAfter);
|
|
if (!isNaN(date.getTime())) {
|
|
const diff = date.getTime() - Date.now();
|
|
return diff > 0 ? diff : null;
|
|
}
|
|
}
|
|
|
|
const resetAfter = headers.get("x-ratelimit-reset-after");
|
|
if (resetAfter) {
|
|
const seconds = parseInt(resetAfter, 10);
|
|
if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
|
|
}
|
|
|
|
const resetTimestamp = headers.get("x-ratelimit-reset");
|
|
if (resetTimestamp) {
|
|
const ts = parseInt(resetTimestamp, 10) * 1000;
|
|
const diff = ts - Date.now();
|
|
return diff > 0 ? diff : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Parse retry time from Antigravity error message body
|
|
// Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s"
|
|
parseRetryFromErrorMessage(errorMessage: unknown): number | null {
|
|
if (!errorMessage || typeof errorMessage !== "string") return null;
|
|
|
|
const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i);
|
|
if (!match) return null;
|
|
|
|
let totalMs = 0;
|
|
if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000; // hours
|
|
if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; // minutes
|
|
if (match[3]) totalMs += parseInt(match[3]) * 1000; // seconds
|
|
|
|
// "reset after 0s" = burst/RPM limit, not quota exhaustion.
|
|
// Return a minimum backoff so the auto-retry loop handles it
|
|
// instead of falling through to the 24h exhaustion classifier.
|
|
if (totalMs === 0) return 2_000; // 2s minimum burst-limit backoff
|
|
|
|
return totalMs;
|
|
}
|
|
|
|
/**
|
|
* Collect an SSE streaming response into a single non-streaming JSON response.
|
|
* Parses Gemini-format SSE chunks and assembles text content + usage into one
|
|
* OpenAI-format chat.completion payload.
|
|
*/
|
|
collectStreamToResponse(
|
|
response: Response,
|
|
model: string,
|
|
url: string,
|
|
headers: Record<string, string>,
|
|
transformedBody: Record<string, unknown>,
|
|
log?: ExecutorLog | null,
|
|
signal?: AbortSignal | null
|
|
) {
|
|
if (!response.body) {
|
|
return Promise.resolve({ response, url, headers, transformedBody });
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
const logger = log || undefined;
|
|
|
|
const SSE_COLLECT_TIMEOUT_MS = 120_000;
|
|
|
|
const collect = async () => {
|
|
const collected: AntigravityCollectedStream = {
|
|
textContent: "",
|
|
finishReason: "stop",
|
|
toolCalls: [],
|
|
usage: null,
|
|
remainingCredits: null,
|
|
};
|
|
const partialLine = { value: "" };
|
|
let timedOut = false;
|
|
const timeout = AbortSignal.timeout(SSE_COLLECT_TIMEOUT_MS);
|
|
try {
|
|
while (true) {
|
|
if (signal?.aborted) throw new Error("Request aborted during SSE collection");
|
|
const { done, value } = await Promise.race([
|
|
reader.read(),
|
|
new Promise<never>((_, reject) =>
|
|
timeout.addEventListener(
|
|
"abort",
|
|
() => reject(new Error("SSE collection timed out")),
|
|
{ once: true }
|
|
)
|
|
),
|
|
]);
|
|
if (done) break;
|
|
processAntigravitySSEText(
|
|
decoder.decode(value, { stream: true }),
|
|
partialLine,
|
|
collected,
|
|
logger
|
|
);
|
|
}
|
|
} catch (err) {
|
|
const msg = err?.message || String(err);
|
|
timedOut = msg.includes("timed out");
|
|
log?.warn?.("SSE_COLLECT", `Error collecting SSE stream: ${msg}`);
|
|
// Fall through — return whatever was collected so far
|
|
}
|
|
processAntigravitySSEText(decoder.decode(), partialLine, collected, logger);
|
|
flushAntigravitySSEText(partialLine, collected, logger);
|
|
|
|
const result = {
|
|
id: `chatcmpl-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message:
|
|
collected.toolCalls.length > 0
|
|
? {
|
|
role: "assistant",
|
|
content: collected.textContent || null,
|
|
tool_calls: collected.toolCalls,
|
|
}
|
|
: { role: "assistant", content: collected.textContent },
|
|
finish_reason: timedOut
|
|
? "length"
|
|
: collected.toolCalls.length > 0
|
|
? "tool_calls"
|
|
: collected.finishReason,
|
|
},
|
|
],
|
|
...(collected.usage && { usage: collected.usage }),
|
|
// Expose credit balance for upstream consumers (usage service, dashboard)
|
|
...(collected.remainingCredits && { _remainingCredits: collected.remainingCredits }),
|
|
};
|
|
|
|
const syntheticStatus = timedOut ? 504 : response.status;
|
|
const syntheticResponse = new Response(JSON.stringify(result), {
|
|
status: syntheticStatus,
|
|
statusText: timedOut ? "Gateway Timeout" : response.statusText,
|
|
headers: [["Content-Type", "application/json"]],
|
|
});
|
|
|
|
return { response: syntheticResponse, url, headers, transformedBody };
|
|
};
|
|
|
|
return collect();
|
|
}
|
|
|
|
/**
|
|
* #3786 — Drive the per-request Pro-family upstream-id FALLBACK CHAIN.
|
|
*
|
|
* The upstream silently renamed the Gemini 3.1 Pro-high id (HTTP 400 on the old id) and the
|
|
* live id cannot be known from static analysis (competitor proxies disagree). When the
|
|
* resolved upstream id has a fallback chain (see ANTIGRAVITY_PRO_FALLBACK_CHAINS) we try the
|
|
* requested id first and, ONLY on a 400, retry the next candidate until one succeeds (2xx)
|
|
* or the chain is exhausted — then the original 400 surfaces (sanitized, hard rule #12).
|
|
*
|
|
* Off the happy path entirely: a model with no chain, or whose first id is not a 400, makes
|
|
* exactly the same single call as before (zero extra upstream requests).
|
|
*/
|
|
async execute(input: ExecuteInput) {
|
|
await resolveAntigravityVersion();
|
|
|
|
// Look up the chain by the NORMALLY-resolved upstream id (honours MITM/static aliases).
|
|
// If a MITM alias remapped the id away from a known Pro tier, no chain applies → fast path.
|
|
const resolvedUpstreamId = await cleanModelName(input.model);
|
|
const chain = getAntigravityModelFallbacks(resolvedUpstreamId);
|
|
|
|
if (chain.length <= 1) {
|
|
// No fallback chain (flash, claude, plain pro, unknown) → single attempt, unchanged.
|
|
return this.executeOnce(input);
|
|
}
|
|
|
|
let firstResult: Awaited<ReturnType<AntigravityExecutor["executeOnce"]>> | null = null;
|
|
for (let i = 0; i < chain.length; i++) {
|
|
const candidate = chain[i];
|
|
const result = await this.executeOnce(input, candidate);
|
|
|
|
// Success (or any non-400) on a candidate → return immediately.
|
|
if (result.response.status !== HTTP_STATUS.BAD_REQUEST) {
|
|
return result;
|
|
}
|
|
|
|
// Remember the FIRST 400 so the exhausted-chain case surfaces the original error.
|
|
if (i === 0) firstResult = result;
|
|
|
|
const isLast = i === chain.length - 1;
|
|
if (!isLast) {
|
|
input.log?.debug?.(
|
|
"AG_PRO_FALLBACK",
|
|
`400 on "${candidate}" — retrying with next Pro candidate "${chain[i + 1]}"`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Chain exhausted: surface the FIRST candidate's sanitized 400.
|
|
input.log?.warn?.(
|
|
"AG_PRO_FALLBACK",
|
|
`Pro fallback chain exhausted (all ${chain.length} candidates 400'd) for "${resolvedUpstreamId}"`
|
|
);
|
|
return firstResult ?? result;
|
|
}
|
|
|
|
// Unreachable (loop always returns), but keeps the type checker happy.
|
|
return firstResult ?? this.executeOnce(input);
|
|
}
|
|
|
|
/**
|
|
* #3786 — Run the request once for a SINGLE resolved upstream model id. The Pro-family
|
|
* fallback chain in `execute()` calls this per candidate (`modelIdOverride`), retrying the
|
|
* next id on a 400. `modelIdOverride === undefined` is the normal (non-chain) path and
|
|
* preserves the prior behavior exactly. Returns the executor result plus the upstream
|
|
* status of the first response so `execute()` can decide whether to fall through. @internal
|
|
*/
|
|
private async executeOnce(
|
|
{ model, body, stream, credentials, signal, log, upstreamExtraHeaders }: ExecuteInput,
|
|
modelIdOverride?: string
|
|
) {
|
|
await resolveAntigravityVersion();
|
|
const fallbackCount = this.getFallbackCount();
|
|
let lastError = null;
|
|
let lastStatus = 0;
|
|
const MAX_AUTO_RETRIES = 3;
|
|
const retryAttemptsByUrl: Record<number, number> = {}; // Track retry attempts per URL
|
|
|
|
// Always stream upstream — buildUrl always returns the streaming endpoint.
|
|
// For non-streaming clients, we collect the SSE below and return a synthetic
|
|
// non-streaming Response so chatCore's non-streaming path stays unchanged.
|
|
const upstreamStream = true;
|
|
|
|
// Account ID for credits tracking.
|
|
// Use connectionId as the stable cache key — it's available in both the executor
|
|
// (via credentials.connectionId) and the usage fetcher (via connection.id).
|
|
// The email-based key was unreliable because email isn't always on the credentials object.
|
|
const accountId: string = credentials?.connectionId || "unknown";
|
|
|
|
// Resolve credits mode once per execute() call. "always" injects
|
|
// enabledCreditTypes: ["GOOGLE_ONE_AI"] on the first request so the
|
|
// preflight normal call is skipped entirely.
|
|
const creditsMode = getCreditsMode();
|
|
const useCreditsFirst = shouldUseCreditsFirst(credentials?.accessToken || "", creditsMode);
|
|
|
|
const fetchWithReadinessTimeout = async (
|
|
url: string,
|
|
init: RequestInit,
|
|
timeoutMs = STREAM_READINESS_TIMEOUT_MS
|
|
): Promise<Response> => {
|
|
const boundedTimeoutMs = Math.max(0, Math.floor(timeoutMs));
|
|
if (boundedTimeoutMs <= 0) {
|
|
return fetch(url, init);
|
|
}
|
|
|
|
const timeoutController = new AbortController();
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
|
timeoutController.abort(new AntigravityPreResponseTimeoutError(boundedTimeoutMs, url));
|
|
}, boundedTimeoutMs);
|
|
|
|
const existingSignal = init.signal instanceof AbortSignal ? init.signal : null;
|
|
const combinedSignal = existingSignal
|
|
? mergeAbortSignals(existingSignal, timeoutController.signal)
|
|
: timeoutController.signal;
|
|
|
|
try {
|
|
return await fetch(url, { ...init, signal: combinedSignal });
|
|
} catch (error) {
|
|
if (
|
|
timeoutController.signal.aborted &&
|
|
isAntigravityPreResponseTimeout(timeoutController.signal.reason)
|
|
) {
|
|
throw timeoutController.signal.reason;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
|
const url = this.buildUrl(model, upstreamStream, urlIndex);
|
|
const headers = this.buildHeaders(credentials, upstreamStream);
|
|
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
|
const transformed = await this.transformRequest(
|
|
model,
|
|
body,
|
|
upstreamStream,
|
|
credentials,
|
|
modelIdOverride
|
|
);
|
|
let requestToolNameMap: Map<string, string> | null = null;
|
|
|
|
if (transformed instanceof Response) {
|
|
return { response: transformed, url, headers, transformedBody: body };
|
|
}
|
|
|
|
let transformedBody: Record<string, unknown> = transformed;
|
|
|
|
if (transformedBody && typeof transformedBody === "object") {
|
|
const cloaked = cloakAntigravityToolPayload(transformedBody);
|
|
transformedBody = cloaked.body;
|
|
requestToolNameMap = cloaked.toolNameMap;
|
|
}
|
|
|
|
// Credits-first: inject GOOGLE_ONE_AI upfront so we never try the normal
|
|
// quota path. If credits are exhausted / disabled shouldUseCreditsFirst()
|
|
// returns false and we fall back to the legacy retry-on-429 flow.
|
|
if (useCreditsFirst) {
|
|
transformedBody = injectCreditsField(transformedBody);
|
|
log?.debug?.("AG_CREDITS", "Credits-first enabled (ANTIGRAVITY_CREDITS=always)");
|
|
}
|
|
|
|
// Initialize retry counter for this URL
|
|
if (!retryAttemptsByUrl[urlIndex]) {
|
|
retryAttemptsByUrl[urlIndex] = 0;
|
|
}
|
|
|
|
try {
|
|
const serializedRequest = serializeAntigravityRequest(
|
|
this.provider,
|
|
headers,
|
|
transformedBody
|
|
);
|
|
let finalHeaders = serializedRequest.headers;
|
|
const capture = (h: Record<string, string>, s: string) =>
|
|
prl.captureCurrentProviderBody(url, h, s, log);
|
|
const clientProfile = applyAntigravityClientProfileHeaders(
|
|
finalHeaders,
|
|
credentials,
|
|
transformedBody
|
|
);
|
|
|
|
log?.debug?.(
|
|
"TELEMETRY",
|
|
`[Antigravity] Execute - URL: ${url}, Model: ${model}, Target: ${getRequestTargetModel(transformedBody)}, RetryAttempt: ${retryAttemptsByUrl[urlIndex]}`
|
|
);
|
|
|
|
// Dump outgoing headers (mask Authorization) and envelope shape for debugging
|
|
if (log?.debug) {
|
|
const safeHeaders = { ...finalHeaders };
|
|
if (safeHeaders["Authorization"]) safeHeaders["Authorization"] = "Bearer ***";
|
|
log.debug("AG_REQUEST_HEADERS", JSON.stringify(safeHeaders));
|
|
|
|
const envelope = transformedBody as Record<string, unknown>;
|
|
const requestInner = envelope.request as Record<string, unknown> | undefined;
|
|
log.debug(
|
|
"AG_REQUEST_ENVELOPE",
|
|
JSON.stringify({
|
|
fieldOrder: Object.keys(envelope),
|
|
project: envelope.project,
|
|
requestId: envelope.requestId,
|
|
model: envelope.model,
|
|
userAgent: envelope.userAgent,
|
|
requestType: envelope.requestType,
|
|
enabledCreditTypes: envelope.enabledCreditTypes,
|
|
clientProfile,
|
|
sessionId: requestInner?.sessionId,
|
|
generationConfig: requestInner?.generationConfig,
|
|
})
|
|
);
|
|
}
|
|
|
|
await capture(finalHeaders, serializedRequest.bodyString);
|
|
let response = await fetchWithReadinessTimeout(url, {
|
|
method: "POST",
|
|
headers: finalHeaders,
|
|
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
|
|
...(stream ? { duplex: "half" } : {}),
|
|
signal,
|
|
});
|
|
|
|
if (response.status === HTTP_STATUS.FORBIDDEN && finalHeaders["x-goog-user-project"]) {
|
|
const retryHeaders = { ...finalHeaders };
|
|
removeHeaderCaseInsensitive(retryHeaders, "x-goog-user-project");
|
|
log?.debug?.("RETRY", "403 with x-goog-user-project, retrying once without it");
|
|
await capture(retryHeaders, serializedRequest.bodyString);
|
|
response = await fetchWithReadinessTimeout(url, {
|
|
method: "POST",
|
|
headers: retryHeaders,
|
|
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
|
|
...(stream ? { duplex: "half" } : {}),
|
|
signal,
|
|
});
|
|
finalHeaders = retryHeaders;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
log?.warn?.(
|
|
"TELEMETRY",
|
|
`[Antigravity] Error Response - URL: ${url}, Status: ${response.status}, Model: ${model}`
|
|
);
|
|
}
|
|
|
|
// Parse retry time for 429/503 responses
|
|
let retryMs: number | null = null;
|
|
|
|
if (
|
|
response.status === HTTP_STATUS.RATE_LIMITED ||
|
|
response.status === HTTP_STATUS.SERVICE_UNAVAILABLE
|
|
) {
|
|
// Try to get retry time from headers first
|
|
retryMs = this.parseRetryHeaders(response.headers);
|
|
|
|
// If no retry time in headers, try to parse from error message body
|
|
if (!retryMs) {
|
|
try {
|
|
const errorBody = await response.clone().text();
|
|
const errorJson = JSON.parse(errorBody);
|
|
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
|
|
|
|
// 1. Try to parse explicit retry time from message
|
|
const parsedRetryMs = this.parseRetryFromErrorMessage(errorMessage);
|
|
|
|
// 2. Classify 429 (pass header-parsed retry hint as fallback
|
|
// signal — multi-hour Retry-After upgrades rate_limited to
|
|
// quota_exhausted so the GOOGLE_ONE_AI credits retry fires).
|
|
const effectiveRetryHintMs = retryMs ?? parsedRetryMs ?? null;
|
|
const category = classify429(errorMessage);
|
|
|
|
// 3. Decide final retry time BEFORE the credits retry so that
|
|
// full_quota_exhausted can skip the credits attempt entirely
|
|
// (avoids ~41s hold on an already-exhausted account) and
|
|
// persist the cooldown to DB for post-restart routing.
|
|
const decision: Decision = decide429(category, parsedRetryMs);
|
|
retryMs = decision.retryAfterMs;
|
|
log?.debug?.(
|
|
"AG_429",
|
|
`Category: ${category}, Decision: ${decision.kind} — ${decision.reason}`
|
|
);
|
|
|
|
if (decision.kind === "full_quota_exhausted" && retryMs) {
|
|
markConnectionQuotaExhausted(accountId, retryMs);
|
|
}
|
|
|
|
const creditsAlreadyInjected =
|
|
(transformedBody as { enabledCreditTypes?: unknown }).enabledCreditTypes != null;
|
|
|
|
if (category === "quota_exhausted" && creditsAlreadyInjected) {
|
|
handleCreditsFailure(credentials?.accessToken || "");
|
|
log?.warn?.("AG_CREDITS", "Credits-first request 429'd — credits likely exhausted");
|
|
markCreditsExhausted(accountId);
|
|
}
|
|
|
|
if (
|
|
category === "quota_exhausted" &&
|
|
decision.kind !== "full_quota_exhausted" &&
|
|
!creditsAlreadyInjected &&
|
|
shouldRetryWithCredits(credentials?.accessToken || "", creditsMode !== "off")
|
|
) {
|
|
log?.info?.("AG_CREDITS", "Retrying with Google One AI credits");
|
|
const creditsBody = injectCreditsField(transformedBody);
|
|
const serializedCreditsRequest = serializeAntigravityRequest(
|
|
this.provider,
|
|
headers,
|
|
creditsBody
|
|
);
|
|
const finalCreditsHeaders = serializedCreditsRequest.headers;
|
|
try {
|
|
await capture(finalCreditsHeaders, serializedCreditsRequest.bodyString);
|
|
const creditsResp = await fetchWithReadinessTimeout(url, {
|
|
method: "POST",
|
|
headers: finalCreditsHeaders,
|
|
body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream),
|
|
...(stream ? { duplex: "half" } : {}),
|
|
signal,
|
|
});
|
|
if (creditsResp.ok || creditsResp.status !== HTTP_STATUS.RATE_LIMITED) {
|
|
log?.info?.("AG_CREDITS", `Credits retry succeeded: ${creditsResp.status}`);
|
|
if (!stream) {
|
|
const collected = await this.collectStreamToResponse(
|
|
creditsResp,
|
|
model,
|
|
url,
|
|
finalCreditsHeaders,
|
|
creditsBody,
|
|
log,
|
|
signal
|
|
);
|
|
// Parse _remainingCredits from the synthetic response and cache
|
|
try {
|
|
const syntheticJson = await collected.response.clone().json();
|
|
const rc = syntheticJson?._remainingCredits;
|
|
if (Array.isArray(rc)) {
|
|
const googleCredit = rc.find((c) => c.creditType === "GOOGLE_ONE_AI");
|
|
if (googleCredit) {
|
|
const balance = parseInt(googleCredit.creditAmount, 10);
|
|
if (!isNaN(balance))
|
|
updateAntigravityRemainingCredits(accountId, balance);
|
|
}
|
|
}
|
|
} catch {
|
|
/**/
|
|
}
|
|
return {
|
|
...collected,
|
|
transformedBody: attachToolNameMap(creditsBody, requestToolNameMap),
|
|
};
|
|
}
|
|
return {
|
|
response: creditsResp,
|
|
url,
|
|
headers: finalCreditsHeaders,
|
|
transformedBody: attachToolNameMap(creditsBody, requestToolNameMap),
|
|
};
|
|
}
|
|
|
|
// Credit retry also 429'd
|
|
handleCreditsFailure(credentials?.accessToken || "");
|
|
log?.warn?.("AG_CREDITS", "Credits retry also 429'd");
|
|
|
|
// Also mark in our legacy exhaustion map to avoid retrying other routes
|
|
markCreditsExhausted(accountId);
|
|
} catch (creditsErr) {
|
|
handleCreditsFailure(credentials?.accessToken || "");
|
|
log?.warn?.("AG_CREDITS", `Credits retry failed: ${creditsErr}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore parse errors, will fall back to exponential backoff
|
|
}
|
|
}
|
|
|
|
// Bounded short-retry: a non-null retryAfterMs ≤ 60s covers nearly every
|
|
// 429 (decide429 returns 2s/5s/60s defaults), so this branch MUST share the
|
|
// per-URL attempt counter. Without the bound a persistent 429 loops forever
|
|
// on the same endpoint/account (urlIndex-- cancels the loop's urlIndex++) and
|
|
// never returns the 429 to the account-fallback layer in chat.ts.
|
|
if (
|
|
retryMs &&
|
|
retryMs <= LONG_RETRY_THRESHOLD_MS &&
|
|
retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES
|
|
) {
|
|
retryAttemptsByUrl[urlIndex]++;
|
|
const effectiveRetryMs = Math.min(retryMs, MAX_RETRY_AFTER_MS);
|
|
log?.debug?.(
|
|
"RETRY",
|
|
`${response.status} retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} with Retry-After: ${Math.ceil(effectiveRetryMs / 1000)}s, waiting...`
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, effectiveRetryMs));
|
|
urlIndex--;
|
|
continue;
|
|
}
|
|
|
|
// Auto retry only for 429 when retryMs is 0 or undefined
|
|
if (
|
|
response.status === HTTP_STATUS.RATE_LIMITED &&
|
|
(!retryMs || retryMs === 0) &&
|
|
retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES
|
|
) {
|
|
retryAttemptsByUrl[urlIndex]++;
|
|
// Exponential backoff: 2s, 4s, 8s...
|
|
const backoffMs = Math.min(
|
|
1000 * 2 ** retryAttemptsByUrl[urlIndex],
|
|
MAX_RETRY_AFTER_MS
|
|
);
|
|
log?.debug?.(
|
|
"RETRY",
|
|
`429 auto retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} after ${backoffMs / 1000}s`
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
urlIndex--;
|
|
continue;
|
|
}
|
|
|
|
log?.debug?.(
|
|
"RETRY",
|
|
`${response.status}, Retry-After ${retryMs ? `too long (${Math.ceil(retryMs / 1000)}s)` : "missing"}, trying fallback`
|
|
);
|
|
lastStatus = response.status;
|
|
|
|
if (urlIndex + 1 < fallbackCount) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (this.shouldRetry(response.status, urlIndex)) {
|
|
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
|
|
lastStatus = response.status;
|
|
continue;
|
|
}
|
|
|
|
// If we have a 429 with long retry time, embed it in response body
|
|
if (
|
|
response.status === HTTP_STATUS.RATE_LIMITED &&
|
|
retryMs &&
|
|
retryMs > LONG_RETRY_THRESHOLD_MS
|
|
) {
|
|
try {
|
|
const respBody = await response.clone().text();
|
|
let obj;
|
|
try {
|
|
obj = JSON.parse(respBody);
|
|
} catch {
|
|
obj = {};
|
|
}
|
|
obj.retryAfterMs = retryMs;
|
|
const modifiedBody = JSON.stringify(obj);
|
|
const modifiedResponse = new Response(modifiedBody, {
|
|
status: response.status,
|
|
headers: response.headers,
|
|
});
|
|
return {
|
|
response: modifiedResponse,
|
|
url,
|
|
headers: finalHeaders,
|
|
transformedBody: attachToolNameMap(transformedBody, requestToolNameMap),
|
|
};
|
|
} catch (err) {
|
|
log?.warn?.("RETRY", `Failed to embed retryAfterMs: ${err}`);
|
|
// Fall back to original response
|
|
}
|
|
}
|
|
|
|
// For non-streaming clients, collect the SSE stream and return a synthetic
|
|
// non-streaming Response so chatCore doesn't need to handle SSE conversion.
|
|
if (!stream) {
|
|
// #3229: surface a real upstream error instead of masking a 4xx/5xx as an
|
|
// empty `chat.completion` envelope (collectStreamToResponse synthesizes a
|
|
// success-shaped body when the upstream returned no SSE data).
|
|
if (!response.ok) {
|
|
const rawBody = await response
|
|
.clone()
|
|
.text()
|
|
.catch(() => "");
|
|
const errorBody = buildAntigravityUpstreamError(
|
|
response.status,
|
|
response.statusText,
|
|
rawBody
|
|
);
|
|
return {
|
|
response: new Response(JSON.stringify(errorBody), {
|
|
status: response.status,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
url,
|
|
headers: finalHeaders,
|
|
transformedBody: attachToolNameMap(transformedBody, requestToolNameMap),
|
|
};
|
|
}
|
|
const collected = await this.collectStreamToResponse(
|
|
response,
|
|
model,
|
|
url,
|
|
finalHeaders,
|
|
transformedBody,
|
|
log,
|
|
signal
|
|
);
|
|
// When credits were injected (credits-first or credits-retry), the
|
|
// synthetic body contains _remainingCredits — mirror it into the
|
|
// balance cache so the dashboard stays fresh.
|
|
try {
|
|
const syntheticJson = await collected.response.clone().json();
|
|
const rc = syntheticJson?._remainingCredits;
|
|
if (Array.isArray(rc)) {
|
|
const googleCredit = rc.find(
|
|
(c: { creditType?: string }) => c?.creditType === "GOOGLE_ONE_AI"
|
|
);
|
|
if (googleCredit) {
|
|
const balance = parseInt(googleCredit.creditAmount, 10);
|
|
if (!isNaN(balance)) updateAntigravityRemainingCredits(accountId, balance);
|
|
}
|
|
}
|
|
} catch {
|
|
/* balance cache is best-effort */
|
|
}
|
|
return {
|
|
...collected,
|
|
transformedBody: attachToolNameMap(transformedBody, requestToolNameMap),
|
|
};
|
|
}
|
|
|
|
// Streaming path: wrap the response body in a pass-through TransformStream
|
|
// that extracts remainingCredits from the final SSE chunk(s) without
|
|
// consuming the stream. The client receives the unmodified SSE data.
|
|
if (response.body) {
|
|
let sseBuffer = "";
|
|
const decoder = new TextDecoder(); // Singleton for correct streaming decode
|
|
const MAX_BUFFER_SIZE = 16 * 1024; // Limit to prevent OOM on large streams
|
|
|
|
const passThrough = new TransformStream(
|
|
{
|
|
transform(chunk, controller) {
|
|
controller.enqueue(chunk);
|
|
// Accumulate text to scan for remainingCredits
|
|
try {
|
|
const text = decoder.decode(chunk, { stream: true });
|
|
sseBuffer += text;
|
|
// Limit buffer size to prevent unbounded growth
|
|
// Truncate only after a complete newline to avoid splitting SSE lines mid-payload
|
|
if (sseBuffer.length > MAX_BUFFER_SIZE) {
|
|
const lastNewline = sseBuffer.lastIndexOf(
|
|
"\n",
|
|
sseBuffer.length - MAX_BUFFER_SIZE
|
|
);
|
|
if (lastNewline !== -1) {
|
|
sseBuffer = sseBuffer.slice(lastNewline + 1);
|
|
} else {
|
|
// No newline found in discard region — buffer contains an incomplete SSE line.
|
|
// Discard it entirely to avoid returning malformed data; the remainingCredits
|
|
// parser won't find valid data in a truncated line anyway.
|
|
sseBuffer = "";
|
|
}
|
|
}
|
|
} catch {
|
|
/* decoding best-effort */
|
|
}
|
|
},
|
|
flush() {
|
|
// Final decode for any remaining bytes
|
|
try {
|
|
const text = decoder.decode(); // Flush pending bytes
|
|
sseBuffer += text;
|
|
} catch {
|
|
/* decoding best-effort */
|
|
}
|
|
|
|
// Parse the accumulated SSE data for remainingCredits
|
|
try {
|
|
const lines = sseBuffer.split("\n");
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("data:")) continue;
|
|
const payload = trimmed.slice(5).trim();
|
|
if (!payload || payload === "[DONE]") continue;
|
|
try {
|
|
const parsed = JSON.parse(payload);
|
|
if (Array.isArray(parsed?.remainingCredits)) {
|
|
const googleCredit = parsed.remainingCredits.find((c: unknown) => {
|
|
const credit = asRecord(c);
|
|
return credit?.creditType === "GOOGLE_ONE_AI";
|
|
}) as AntigravityCreditEntry | undefined;
|
|
if (googleCredit) {
|
|
const balance = parseInt(String(googleCredit.creditAmount ?? ""), 10);
|
|
if (!isNaN(balance)) {
|
|
updateAntigravityRemainingCredits(accountId, balance);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* skip malformed lines */
|
|
}
|
|
}
|
|
} catch {
|
|
/* credits extraction is best-effort */
|
|
}
|
|
sseBuffer = "";
|
|
},
|
|
},
|
|
{ highWaterMark: 16384 },
|
|
{ highWaterMark: 16384 }
|
|
);
|
|
const tappedBody = response.body.pipeThrough(passThrough);
|
|
const tappedResponse = new Response(tappedBody, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
});
|
|
return {
|
|
response: tappedResponse,
|
|
url,
|
|
headers: finalHeaders,
|
|
transformedBody: attachToolNameMap(transformedBody, requestToolNameMap),
|
|
};
|
|
}
|
|
|
|
return {
|
|
response,
|
|
url,
|
|
headers: finalHeaders,
|
|
transformedBody: attachToolNameMap(transformedBody, requestToolNameMap),
|
|
};
|
|
} catch (error) {
|
|
lastError = error;
|
|
log?.error?.(
|
|
"TELEMETRY",
|
|
`[Antigravity] Network/Fetch Error - URL: ${url}, Model: ${model}, Error: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
if (urlIndex + 1 < fallbackCount) {
|
|
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`);
|
|
}
|
|
}
|
|
|
|
export default AntigravityExecutor;
|