@bitkyc08/opencodex 2.6.7 → 2.6.8-preview.20260630
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-CSI5RHdZ.css → index-DIBiVVC0.css} +1 -1
- package/gui/dist/assets/index-DT-bDaop.js +9 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/azure.ts +2 -2
- package/src/adapters/base.ts +6 -1
- package/src/adapters/google-antigravity-replay.ts +136 -0
- package/src/adapters/google-antigravity-wire.ts +94 -0
- package/src/adapters/google-errors.ts +95 -0
- package/src/adapters/google-http.ts +109 -0
- package/src/adapters/google-tool-schema.ts +89 -0
- package/src/adapters/google-truncation.ts +13 -0
- package/src/adapters/google.ts +163 -20
- package/src/adapters/kiro-tools.ts +55 -2
- package/src/cli-status.ts +4 -0
- package/src/cli.ts +7 -0
- package/src/codex-history-provider.ts +195 -22
- package/src/codex-plugins-doctor.ts +242 -0
- package/src/config.ts +1 -0
- package/src/lib/gcp-adc.ts +292 -0
- package/src/oauth/google-antigravity.ts +225 -0
- package/src/oauth/index.ts +19 -1
- package/src/oauth/store.ts +1 -0
- package/src/oauth/types.ts +2 -0
- package/src/provider-context-cap.ts +30 -1
- package/src/providers/antigravity-models.ts +30 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/registry.ts +7 -0
- package/src/redact.ts +26 -0
- package/src/router.ts +6 -0
- package/src/server.ts +45 -9
- package/src/types.ts +12 -0
- package/src/usage-summary.ts +30 -3
- package/src/web-search/loop.ts +1 -1
- package/gui/dist/assets/index-Ct8koBOe.js +0 -9
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-DT-bDaop.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DIBiVVC0.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/adapters/azure.ts
CHANGED
|
@@ -12,8 +12,8 @@ export function createAzureAdapter(provider: OcxProviderConfig): ProviderAdapter
|
|
|
12
12
|
...inner,
|
|
13
13
|
name: "azure-openai",
|
|
14
14
|
|
|
15
|
-
buildRequest(parsed: OcxParsedRequest) {
|
|
16
|
-
const request = inner.buildRequest(parsed);
|
|
15
|
+
async buildRequest(parsed: OcxParsedRequest) {
|
|
16
|
+
const request = await inner.buildRequest(parsed);
|
|
17
17
|
const headers = { ...request.headers };
|
|
18
18
|
if (provider.apiKey) {
|
|
19
19
|
headers["api-key"] = provider.apiKey;
|
package/src/adapters/base.ts
CHANGED
|
@@ -8,7 +8,12 @@ export interface IncomingMeta {
|
|
|
8
8
|
export interface ProviderAdapter {
|
|
9
9
|
name: string;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Build the upstream request. May be async: adapters that resolve a short-lived credential
|
|
13
|
+
* (e.g. Vertex AI ADC token) return a Promise. Sync adapters return the object directly; callers
|
|
14
|
+
* must `await` the result (awaiting a non-Promise is a no-op).
|
|
15
|
+
*/
|
|
16
|
+
buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta): AdapterRequest | Promise<AdapterRequest>;
|
|
12
17
|
|
|
13
18
|
fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response>;
|
|
14
19
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity (Cloud Code Assist) thoughtSignature reasoning-replay cache.
|
|
3
|
+
*
|
|
4
|
+
* Gemini-3 interleaved thinking is stateless upstream: each model content part carries a
|
|
5
|
+
* `thoughtSignature` that MUST be echoed back on the matching part in the next request, or the
|
|
6
|
+
* upstream rejects the turn (HTTP 400). We observe signatures on the response stream, cache them
|
|
7
|
+
* per `model + session`, and re-inject them into the outgoing `request.contents` on the next turn.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors CLIProxyAPI `internal/runtime/executor/antigravity_reasoning_replay.go`. Gemini-only;
|
|
10
|
+
* Claude-on-Antigravity uses inline signature sanitization instead (see google-antigravity-wire).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface ReplayEntry {
|
|
14
|
+
/** thoughtSignature keyed by functionCall identity (name + canonical args). */
|
|
15
|
+
byCall: Map<string, string>;
|
|
16
|
+
expiresAtMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MIN_SIGNATURE_LEN = 16;
|
|
20
|
+
const REPLAY_TTL_MS = 60 * 60 * 1000; // 1h
|
|
21
|
+
const REPLAY_MAX_ENTRIES = 10_240;
|
|
22
|
+
const REPLAY_EVICT_BATCH = 128;
|
|
23
|
+
|
|
24
|
+
const replayCache = new Map<string, ReplayEntry>();
|
|
25
|
+
|
|
26
|
+
function replayKey(model: string, sessionId: string): string {
|
|
27
|
+
return `${model}::session:${sessionId}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Recursively canonicalize a JSON value: object keys sorted, arrays preserved. */
|
|
31
|
+
function canonicalJson(value: unknown): string {
|
|
32
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
33
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
34
|
+
const entries = Object.keys(value as Record<string, unknown>).sort()
|
|
35
|
+
.map(k => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`);
|
|
36
|
+
return `{${entries.join(",")}}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stable identity for a functionCall part: name + recursively canonicalized args. */
|
|
40
|
+
function functionCallKey(name: unknown, args: unknown): string | undefined {
|
|
41
|
+
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
42
|
+
let argsKey = "";
|
|
43
|
+
try {
|
|
44
|
+
argsKey = canonicalJson(args ?? {});
|
|
45
|
+
} catch {
|
|
46
|
+
argsKey = "";
|
|
47
|
+
}
|
|
48
|
+
return `${name}::${argsKey}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractSignature(part: Record<string, unknown>): string | undefined {
|
|
52
|
+
const direct = part.thoughtSignature ?? part.thought_signature;
|
|
53
|
+
if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN) return direct;
|
|
54
|
+
const extra = part.extra_content as { google?: { thought_signature?: unknown } } | undefined;
|
|
55
|
+
const nested = extra?.google?.thought_signature;
|
|
56
|
+
if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN) return nested;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function evictIfNeeded(): void {
|
|
61
|
+
if (replayCache.size <= REPLAY_MAX_ENTRIES) return;
|
|
62
|
+
const oldest = [...replayCache.entries()]
|
|
63
|
+
.sort((a, b) => a[1].expiresAtMs - b[1].expiresAtMs)
|
|
64
|
+
.slice(0, REPLAY_EVICT_BATCH);
|
|
65
|
+
for (const [key] of oldest) replayCache.delete(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Gemini/Flash/Agent use the replay cache; Claude does not (inline sanitization instead). */
|
|
69
|
+
export function antigravityUsesReplayCache(model: string): boolean {
|
|
70
|
+
return !/claude/i.test(model);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
|
|
75
|
+
* the functionCall identity (name + args). Accumulates across the whole session so a sequential
|
|
76
|
+
* multi-step tool loop keeps EVERY prior call's signature, not just the latest part-index slot.
|
|
77
|
+
* `parts` is the already-unwrapped `response.candidates[0].content.parts`.
|
|
78
|
+
*/
|
|
79
|
+
export function observeAntigravityReplay(model: string, sessionId: string, parts: unknown[]): void {
|
|
80
|
+
if (!antigravityUsesReplayCache(model) || !Array.isArray(parts) || parts.length === 0) return;
|
|
81
|
+
const key = replayKey(model, sessionId);
|
|
82
|
+
const entry = replayCache.get(key) ?? { byCall: new Map<string, string>(), expiresAtMs: 0 };
|
|
83
|
+
let changed = false;
|
|
84
|
+
for (const raw of parts) {
|
|
85
|
+
if (!raw || typeof raw !== "object") continue;
|
|
86
|
+
const part = raw as Record<string, unknown>;
|
|
87
|
+
const sig = extractSignature(part);
|
|
88
|
+
if (!sig) continue;
|
|
89
|
+
const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined;
|
|
90
|
+
const ck = fc ? functionCallKey(fc.name, fc.args) : undefined;
|
|
91
|
+
if (!ck) continue; // only function-call signatures are replayable by identity
|
|
92
|
+
if (entry.byCall.get(ck) !== sig) { entry.byCall.set(ck, sig); changed = true; }
|
|
93
|
+
}
|
|
94
|
+
if (!changed && replayCache.has(key)) return;
|
|
95
|
+
entry.expiresAtMs = Date.now() + REPLAY_TTL_MS;
|
|
96
|
+
replayCache.set(key, entry);
|
|
97
|
+
evictIfNeeded();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Re-inject cached thought signatures into the outgoing `request.contents`, matched by functionCall
|
|
102
|
+
* identity across ALL model turns (not just the last one). Only fills a functionCall part that
|
|
103
|
+
* lacks a real signature. Returns the same array reference (mutated in place).
|
|
104
|
+
*/
|
|
105
|
+
export function applyAntigravityReplay(model: string, sessionId: string, contents: unknown[]): unknown[] {
|
|
106
|
+
if (!antigravityUsesReplayCache(model) || !Array.isArray(contents)) return contents;
|
|
107
|
+
const entry = replayCache.get(replayKey(model, sessionId));
|
|
108
|
+
if (!entry || entry.expiresAtMs <= Date.now()) {
|
|
109
|
+
if (entry) replayCache.delete(replayKey(model, sessionId));
|
|
110
|
+
return contents;
|
|
111
|
+
}
|
|
112
|
+
for (const c of contents as { role?: string; parts?: unknown[] }[]) {
|
|
113
|
+
if (!c || typeof c !== "object" || c.role !== "model" || !Array.isArray(c.parts)) continue;
|
|
114
|
+
for (const raw of c.parts) {
|
|
115
|
+
if (!raw || typeof raw !== "object") continue;
|
|
116
|
+
const part = raw as Record<string, unknown>;
|
|
117
|
+
const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined;
|
|
118
|
+
if (!fc) continue;
|
|
119
|
+
if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) continue;
|
|
120
|
+
const ck = functionCallKey(fc.name, fc.args);
|
|
121
|
+
const sig = ck ? entry.byCall.get(ck) : undefined;
|
|
122
|
+
if (sig) part.thoughtSignature = sig;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return contents;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Drop the cache entry when upstream rejects a signature (clear-on-invalid). */
|
|
129
|
+
export function clearAntigravityReplay(model: string, sessionId: string): void {
|
|
130
|
+
replayCache.delete(replayKey(model, sessionId));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Test seam. */
|
|
134
|
+
export function __resetAntigravityReplayCache(): void {
|
|
135
|
+
replayCache.clear();
|
|
136
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OcxContentPart, OcxParsedRequest } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Antigravity request User-Agent (overridable). Mirrors the Antigravity desktop client UA. */
|
|
5
|
+
export const ANTIGRAVITY_REQUEST_UA = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT || "antigravity";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether a stored `OcxToolCall.thoughtSignature` is a REAL upstream Gemini signature versus a
|
|
9
|
+
* synthetic Responses item id (`fc_...`, `call_...`, `rs_...`, etc) that the bridge/parser stashes
|
|
10
|
+
* on the field. Only real signatures may be forwarded to Gemini/Antigravity — sending a synthetic
|
|
11
|
+
* id as `thoughtSignature` breaks multi-turn reasoning continuity (upstream rejects it). Real
|
|
12
|
+
* signatures are opaque base64-ish blobs with no Responses-id prefix.
|
|
13
|
+
*/
|
|
14
|
+
export function isLikelyRealThoughtSignature(sig: string | undefined): boolean {
|
|
15
|
+
if (typeof sig !== "string" || sig.length < 16) return false;
|
|
16
|
+
// Reject synthetic Responses/tool-call ids in both `_` and `-` separated spellings
|
|
17
|
+
// (e.g. `fc_...`, `call_...`, `function-call-...`, `tool-call-...`).
|
|
18
|
+
if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(sig)) return false;
|
|
19
|
+
// Real Gemini thought signatures are opaque base64/base64url blobs: only [A-Za-z0-9+/_=-].
|
|
20
|
+
// Anything containing other characters (or whitespace) is not a real signature.
|
|
21
|
+
return /^[A-Za-z0-9+/_=-]+$/.test(sig);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function firstUserText(parsed: OcxParsedRequest): string | undefined {
|
|
25
|
+
for (const msg of parsed.context.messages) {
|
|
26
|
+
if (msg.role !== "user") continue;
|
|
27
|
+
if (typeof msg.content === "string") return msg.content;
|
|
28
|
+
const first = (msg.content as OcxContentPart[]).find(p => p.type === "text" && typeof p.text === "string");
|
|
29
|
+
if (first && first.type === "text") return first.text;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deterministic Cloud Code Assist session id from the first user message text. Mirrors
|
|
36
|
+
* CLIProxyAPI `generateStableSessionID`: sha256(firstUserText) → BigEndian uint64 masked with
|
|
37
|
+
* 0x7FFFFFFFFFFFFFFF, prefixed with "-". Falls back to a random "-<digits>" id when there is no text.
|
|
38
|
+
*/
|
|
39
|
+
export function antigravitySessionId(parsed: OcxParsedRequest): string {
|
|
40
|
+
// Anchored on the first user message text: this is the one value that stays STABLE across every
|
|
41
|
+
// turn of a conversation, which the replay cache requires (it observes signatures on turn N's
|
|
42
|
+
// response and re-injects them on turn N+1's request, so both turns must map to the same id).
|
|
43
|
+
// Cross-conversation collisions (two threads opening with identical text) are made harmless by
|
|
44
|
+
// the replay cache keying signatures on functionCall identity (name+args), not on this id alone.
|
|
45
|
+
const text = firstUserText(parsed);
|
|
46
|
+
if (!text) return `-${Math.floor(Math.random() * 9e18).toString()}`;
|
|
47
|
+
const digest = createHash("sha256").update(text, "utf8").digest();
|
|
48
|
+
const masked = digest.readBigUInt64BE(0) & 0x7fffffffffffffffn;
|
|
49
|
+
return `-${masked.toString()}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A Gemini content part as it appears in an Antigravity request body. */
|
|
53
|
+
interface GeminiPart {
|
|
54
|
+
thought?: boolean;
|
|
55
|
+
thoughtSignature?: string;
|
|
56
|
+
thought_signature?: string;
|
|
57
|
+
text?: string;
|
|
58
|
+
[key: string]: unknown;
|
|
59
|
+
}
|
|
60
|
+
interface GeminiContent {
|
|
61
|
+
role?: string;
|
|
62
|
+
parts?: GeminiPart[];
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasSignature(part: GeminiPart): boolean {
|
|
67
|
+
return typeof part.thoughtSignature === "string" && part.thoughtSignature.length > 0
|
|
68
|
+
|| typeof part.thought_signature === "string" && part.thought_signature.length > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Claude-on-Antigravity signature sanitization (the no-cache path). Mirrors CLIProxyAPI's
|
|
73
|
+
* `StripEmptySignatureThinkingBlocks` + non-model signature stripping: drop thinking parts that
|
|
74
|
+
* carry no valid signature (they would 400 upstream), and strip signature fields from non-model
|
|
75
|
+
* (user) content. Mutates and returns `contents`.
|
|
76
|
+
*/
|
|
77
|
+
export function sanitizeAntigravityClaudeSignatures(contents: unknown[]): unknown[] {
|
|
78
|
+
if (!Array.isArray(contents)) return contents;
|
|
79
|
+
for (const raw of contents as GeminiContent[]) {
|
|
80
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.parts)) continue;
|
|
81
|
+
const isModel = raw.role === "model";
|
|
82
|
+
if (!isModel) {
|
|
83
|
+
// Non-model parts must not carry thought signatures.
|
|
84
|
+
for (const part of raw.parts) {
|
|
85
|
+
delete part.thoughtSignature;
|
|
86
|
+
delete part.thought_signature;
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Model turn: drop thinking blocks lacking a valid signature.
|
|
91
|
+
raw.parts = raw.parts.filter(part => !(part.thought === true && !hasSignature(part)));
|
|
92
|
+
}
|
|
93
|
+
return contents;
|
|
94
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { redactSecretString } from "../redact";
|
|
2
|
+
|
|
3
|
+
const ABSOLUTE_PATH_PATTERN = /(?:\/Users\/[^ "';,]+|\/home\/[^ "';,]+|\/root\/[^ "';,]*|[A-Za-z]:\\Users\\[^ "';,]+)/g;
|
|
4
|
+
|
|
5
|
+
function sanitizeGoogleErrorText(value: string): string {
|
|
6
|
+
return redactSecretString(value).replace(ABSOLUTE_PATH_PATTERN, "[REDACTED_PATH]");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function safeString(value: unknown): string | undefined {
|
|
10
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Pull the human detail out of the Google API error envelope `{error:{message,status,code}}`. */
|
|
14
|
+
function googleErrorDetail(payloadText: string): { message?: string; status?: string } {
|
|
15
|
+
const trimmed = payloadText.trim();
|
|
16
|
+
if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) {
|
|
17
|
+
return { message: trimmed || undefined };
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(trimmed) as { error?: { message?: unknown; status?: unknown } };
|
|
21
|
+
const err = parsed.error;
|
|
22
|
+
return { message: safeString(err?.message), status: safeString(err?.status) };
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string {
|
|
29
|
+
const lower = `${enumStatus ?? ""} ${text}`.toLowerCase();
|
|
30
|
+
const quotaExhausted =
|
|
31
|
+
lower.includes("quotafailure") ||
|
|
32
|
+
lower.includes("quota exceeded") ||
|
|
33
|
+
lower.includes("exceeded your current quota") ||
|
|
34
|
+
lower.includes("billing");
|
|
35
|
+
if (enumStatus === "RESOURCE_EXHAUSTED" && quotaExhausted) return `${label} quota exhausted`;
|
|
36
|
+
if (status === 429 || enumStatus === "RESOURCE_EXHAUSTED" || lower.includes("rate limit")) {
|
|
37
|
+
return `${label} rate limit exceeded`;
|
|
38
|
+
}
|
|
39
|
+
if (status === 401 || enumStatus === "UNAUTHENTICATED" || lower.includes("unauthenticated") || lower.includes("invalid authentication") || lower.includes("expired")) {
|
|
40
|
+
return `${label} authentication failed`;
|
|
41
|
+
}
|
|
42
|
+
if (status === 403 || enumStatus === "PERMISSION_DENIED" || lower.includes("permission denied") || lower.includes("access denied")) {
|
|
43
|
+
return `${label} access denied`;
|
|
44
|
+
}
|
|
45
|
+
if (status === 503 || enumStatus === "UNAVAILABLE" || lower.includes("overloaded") || lower.includes("unavailable")) {
|
|
46
|
+
return `${label} server overloaded`;
|
|
47
|
+
}
|
|
48
|
+
if (status === 400 || status === 404 || enumStatus === "INVALID_ARGUMENT" || enumStatus === "NOT_FOUND" || lower.includes("invalid") || lower.includes("not found") || lower.includes("malformed")) {
|
|
49
|
+
return `${label} invalid request`;
|
|
50
|
+
}
|
|
51
|
+
return `${label} upstream error`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize a Google/Vertex/Antigravity HTTP error body into a short, classified, secret-redacted
|
|
56
|
+
* message. Mirrors `kiro-errors.ts`. `label` is the provider-facing prefix ("Vertex AI",
|
|
57
|
+
* "Antigravity").
|
|
58
|
+
*/
|
|
59
|
+
export function safeGoogleHttpErrorMessage(label: string, status: number, payloadText: string): string {
|
|
60
|
+
const { message, status: enumStatus } = googleErrorDetail(payloadText);
|
|
61
|
+
const prefix = classifyGoogle(label, status, enumStatus, [message, enumStatus].filter(Boolean).join(" "));
|
|
62
|
+
const detail = message ? sanitizeGoogleErrorText(message).slice(0, 500) : `HTTP ${status}`;
|
|
63
|
+
return `${prefix}: ${detail}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Vertex AI HTTP error message (label = "Vertex AI"). */
|
|
67
|
+
export function safeVertexHttpErrorMessage(status: number, payloadText: string): string {
|
|
68
|
+
return safeGoogleHttpErrorMessage("Vertex AI", status, payloadText);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Antigravity (Cloud Code Assist) HTTP error message (label = "Antigravity"). */
|
|
72
|
+
export function safeAntigravityHttpErrorMessage(status: number, payloadText: string): string {
|
|
73
|
+
return safeGoogleHttpErrorMessage("Antigravity", status, payloadText);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Google-family retryable HTTP set (mirrors Kiro). Quota-exhausted is classified above and not retried. */
|
|
77
|
+
export function retryableGoogleStatus(status: number): boolean {
|
|
78
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True when a 429 body indicates hard quota exhaustion (not a transient rate limit). Quota
|
|
83
|
+
* exhaustion is generally not expected to recover for hours (AIP-194), so it must NOT be retried —
|
|
84
|
+
* unlike a plain rate limit. The HTTP status alone can't distinguish the two, so the retry layer
|
|
85
|
+
* inspects the body with this.
|
|
86
|
+
*/
|
|
87
|
+
export function isQuotaExhaustedBody(payloadText: string): boolean {
|
|
88
|
+
const { message, status } = googleErrorDetail(payloadText);
|
|
89
|
+
if (status !== "RESOURCE_EXHAUSTED") return false;
|
|
90
|
+
const lower = (message ?? "").toLowerCase();
|
|
91
|
+
return lower.includes("quotafailure")
|
|
92
|
+
|| lower.includes("quota exceeded")
|
|
93
|
+
|| lower.includes("exceeded your current quota")
|
|
94
|
+
|| lower.includes("billing");
|
|
95
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AdapterFetchContext, AdapterRequest } from "./base";
|
|
2
|
+
import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
|
|
3
|
+
|
|
4
|
+
const GOOGLE_RETRY_ATTEMPTS = 3;
|
|
5
|
+
const GOOGLE_RETRY_BASE_MS = 250;
|
|
6
|
+
const GOOGLE_RETRY_MAX_MS = 2_000;
|
|
7
|
+
|
|
8
|
+
function retryAfterMs(headers: Headers): number | undefined {
|
|
9
|
+
const raw = headers.get("retry-after")?.trim();
|
|
10
|
+
if (!raw) return undefined;
|
|
11
|
+
const seconds = Number(raw);
|
|
12
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
13
|
+
const dateMs = Date.parse(raw);
|
|
14
|
+
if (!Number.isFinite(dateMs)) return undefined;
|
|
15
|
+
return Math.max(0, dateMs - Date.now());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function retryDelayMs(attempt: number, headers?: Headers): number {
|
|
19
|
+
const retryAfter = headers ? retryAfterMs(headers) : undefined;
|
|
20
|
+
if (retryAfter !== undefined) return Math.min(retryAfter, GOOGLE_RETRY_MAX_MS);
|
|
21
|
+
const exp = Math.min(GOOGLE_RETRY_BASE_MS * (2 ** attempt), GOOGLE_RETRY_MAX_MS);
|
|
22
|
+
return Math.floor(exp * (0.8 + Math.random() * 0.4));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function abortError(signal?: AbortSignal): unknown {
|
|
26
|
+
return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
30
|
+
if (ms <= 0) return;
|
|
31
|
+
if (signal?.aborted) throw abortError(signal);
|
|
32
|
+
await new Promise<void>((resolve, reject) => {
|
|
33
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
34
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); };
|
|
35
|
+
const onAbort = () => { cleanup(); reject(abortError(signal)); };
|
|
36
|
+
timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
37
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
42
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
43
|
+
return parent ? AbortSignal.any([parent, timeout]) : timeout;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function normalizeFinalGoogleError(label: string, res: Response): Promise<Response> {
|
|
47
|
+
if (res.ok) return res;
|
|
48
|
+
const payloadText = await res.clone().text().catch(() => "");
|
|
49
|
+
const headers = new Headers(res.headers);
|
|
50
|
+
headers.delete("content-encoding");
|
|
51
|
+
headers.delete("content-length");
|
|
52
|
+
return new Response(safeGoogleHttpErrorMessage(label, res.status, payloadText), {
|
|
53
|
+
status: res.status, statusText: res.statusText, headers,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fetch a Google-family upstream (Vertex / Antigravity) with Kiro-style hardening: per-attempt
|
|
59
|
+
* timeout (`AbortSignal.any([parent, timeout])`), bounded retry on transient status / network
|
|
60
|
+
* errors, `Retry-After` honoring, jittered exponential backoff, and a classified + redacted final
|
|
61
|
+
* error body. `label` is the provider-facing prefix used in error messages.
|
|
62
|
+
*/
|
|
63
|
+
export async function fetchGoogleWithRetry(label: string, request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
|
|
64
|
+
const timeoutMs = ctx.timeoutMs ?? 100_000;
|
|
65
|
+
let lastError: unknown;
|
|
66
|
+
for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) {
|
|
67
|
+
if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(request.url, {
|
|
70
|
+
method: request.method, headers: request.headers, body: request.body,
|
|
71
|
+
signal: signalWithAttemptTimeout(ctx.abortSignal, timeoutMs),
|
|
72
|
+
});
|
|
73
|
+
if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) {
|
|
74
|
+
return normalizeFinalGoogleError(label, res);
|
|
75
|
+
}
|
|
76
|
+
// A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry —
|
|
77
|
+
// it won't recover for hours and burns retries). Peek the body to tell them apart.
|
|
78
|
+
if (res.status === 429) {
|
|
79
|
+
const peek = await res.clone().text().catch(() => "");
|
|
80
|
+
if (isQuotaExhaustedBody(peek)) {
|
|
81
|
+
const headers = new Headers(res.headers);
|
|
82
|
+
headers.delete("content-encoding");
|
|
83
|
+
headers.delete("content-length");
|
|
84
|
+
return new Response(safeGoogleHttpErrorMessage(label, res.status, peek), {
|
|
85
|
+
status: res.status, statusText: res.statusText, headers,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
await res.body?.cancel().catch(() => {});
|
|
90
|
+
await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (ctx.abortSignal?.aborted) throw err;
|
|
93
|
+
lastError = err;
|
|
94
|
+
if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err;
|
|
95
|
+
await sleepWithAbort(retryDelayMs(attempt), ctx.abortSignal);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw lastError ?? new Error(`${label} fetch failed`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Vertex AI retry wrapper. */
|
|
102
|
+
export function fetchVertexWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
|
|
103
|
+
return fetchGoogleWithRetry("Vertex AI", request, ctx);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Antigravity (Cloud Code Assist) retry wrapper. */
|
|
107
|
+
export function fetchAntigravityWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
|
|
108
|
+
return fetchGoogleWithRetry("Antigravity", request, ctx);
|
|
109
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
type Schema = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
// Gemini / Antigravity (CCA) accept only an OpenAPI-3.0 subset for function `parameters`. Codex
|
|
4
|
+
// emits full JSON-Schema (draft 2020-12) tool definitions, so passing them through verbatim makes
|
|
5
|
+
// CCA reject the whole request with "Request contains an invalid argument" / "Unknown name ...".
|
|
6
|
+
// Every keyword below was confirmed live against the Antigravity backend to trigger a 400.
|
|
7
|
+
const DROPPED_SCHEMA_KEYS = new Set([
|
|
8
|
+
"$schema", "$id", "$comment", "$ref", "$defs", "definitions",
|
|
9
|
+
"examples", "patternProperties", "if", "then", "else",
|
|
10
|
+
"uniqueItems", "additionalItems", "unevaluatedProperties", "unevaluatedItems",
|
|
11
|
+
"dependentRequired", "dependentSchemas", "propertyNames", "contains",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const MAX_DEREF_DEPTH = 64;
|
|
15
|
+
|
|
16
|
+
function resolveRef(ref: string, defs: Map<string, unknown>): unknown {
|
|
17
|
+
// Only local pointers into the schema's own $defs/definitions are supported (e.g.
|
|
18
|
+
// "#/$defs/Foo"). Anything else cannot be inlined, so it collapses to an unconstrained object.
|
|
19
|
+
const match = /^#\/(?:\$defs|definitions)\/(.+)$/.exec(ref);
|
|
20
|
+
if (!match) return undefined;
|
|
21
|
+
return defs.get(decodeURIComponent(match[1].replace(/~1/g, "/").replace(/~0/g, "~")));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function collectDefs(root: unknown, defs: Map<string, unknown>): void {
|
|
25
|
+
if (!root || typeof root !== "object") return;
|
|
26
|
+
for (const bag of ["$defs", "definitions"] as const) {
|
|
27
|
+
const group = (root as Schema)[bag];
|
|
28
|
+
if (group && typeof group === "object" && !Array.isArray(group)) {
|
|
29
|
+
for (const [name, value] of Object.entries(group as Schema)) {
|
|
30
|
+
if (!defs.has(name)) defs.set(name, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeType(value: unknown, out: Schema): void {
|
|
37
|
+
// JSON-Schema allows `type` to be an array (e.g. ["string","null"]); OpenAPI 3.0 does not.
|
|
38
|
+
// Collapse to the first non-null type and mark the field nullable when "null" was present.
|
|
39
|
+
if (!Array.isArray(value)) {
|
|
40
|
+
out.type = value;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const nonNull = value.filter(t => t !== "null");
|
|
44
|
+
if (value.includes("null")) out.nullable = true;
|
|
45
|
+
if (nonNull.length > 0) out.type = nonNull[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitize(node: unknown, defs: Map<string, unknown>, depth: number): unknown {
|
|
49
|
+
if (Array.isArray(node)) return node.map(item => sanitize(item, defs, depth));
|
|
50
|
+
if (!node || typeof node !== "object") return node;
|
|
51
|
+
const input = node as Schema;
|
|
52
|
+
|
|
53
|
+
if (typeof input.$ref === "string" && depth < MAX_DEREF_DEPTH) {
|
|
54
|
+
const target = resolveRef(input.$ref, defs);
|
|
55
|
+
if (target && typeof target === "object") {
|
|
56
|
+
const merged: Schema = { ...(target as Schema) };
|
|
57
|
+
for (const [key, value] of Object.entries(input)) {
|
|
58
|
+
if (key !== "$ref") merged[key] = value;
|
|
59
|
+
}
|
|
60
|
+
return sanitize(merged, defs, depth + 1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const out: Schema = {};
|
|
65
|
+
for (const [key, value] of Object.entries(input)) {
|
|
66
|
+
if (DROPPED_SCHEMA_KEYS.has(key)) continue;
|
|
67
|
+
if (key === "type") { normalizeType(value, out); continue; }
|
|
68
|
+
if (key === "const") { out.enum = [value]; continue; }
|
|
69
|
+
if (key === "exclusiveMinimum" && typeof value === "number") { out.minimum = value; continue; }
|
|
70
|
+
if (key === "exclusiveMaximum" && typeof value === "number") { out.maximum = value; continue; }
|
|
71
|
+
if (key === "additionalProperties") {
|
|
72
|
+
// A boolean additionalProperties is accepted, but a nested schema is only meaningful with
|
|
73
|
+
// its own sanitize pass.
|
|
74
|
+
out.additionalProperties = typeof value === "boolean" ? value : sanitize(value, defs, depth);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
out[key] = sanitize(value, defs, depth);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function sanitizeGeminiToolParameters(parameters: unknown): Record<string, unknown> {
|
|
83
|
+
const defs = new Map<string, unknown>();
|
|
84
|
+
collectDefs(parameters, defs);
|
|
85
|
+
const result = sanitize(parameters, defs, 0);
|
|
86
|
+
return result && typeof result === "object" && !Array.isArray(result)
|
|
87
|
+
? result as Record<string, unknown>
|
|
88
|
+
: { type: "object" };
|
|
89
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { redactSecretString } from "../redact";
|
|
2
|
+
|
|
3
|
+
/** Gemini/Vertex finishReason values that mean the turn was cut off, not cleanly stopped. */
|
|
4
|
+
const TRUNCATION_REASONS = new Set(["MAX_TOKENS", "MALFORMED_FUNCTION_CALL"]);
|
|
5
|
+
|
|
6
|
+
export function isVertexTruncationReason(finishReason: string | undefined): boolean {
|
|
7
|
+
return finishReason !== undefined && TRUNCATION_REASONS.has(finishReason);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function vertexTruncationErrorMessage(reason?: string): string {
|
|
11
|
+
const suffix = reason ? ` (${redactSecretString(reason).slice(0, 160)})` : "";
|
|
12
|
+
return `Vertex AI response truncated upstream before the turn completed${suffix}`;
|
|
13
|
+
}
|