@yansigit/opencodex 2.31.1 → 2.31.3

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.
Files changed (36) hide show
  1. package/gui/dist/assets/{index-DJDp_XER.js → index-Cxt5fZMP.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/base.ts +6 -0
  5. package/src/adapters/command-code-project-context.ts +377 -0
  6. package/src/adapters/command-code.ts +5 -1
  7. package/src/adapters/cursor/cursor-errors.ts +12 -0
  8. package/src/adapters/cursor/live-transport.ts +21 -0
  9. package/src/adapters/cursor/native-exec-bridge.ts +141 -0
  10. package/src/adapters/cursor/thread-continuity.ts +71 -0
  11. package/src/adapters/cursor.ts +90 -28
  12. package/src/adapters/google-http.ts +12 -2
  13. package/src/adapters/google-wire-compiler.ts +91 -2
  14. package/src/adapters/google.ts +83 -15
  15. package/src/config.ts +2 -0
  16. package/src/generated/compatibility-version.json +57 -25
  17. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  18. package/src/oauth/anthropic-routing.ts +129 -219
  19. package/src/oauth/antigravity-routing.ts +165 -0
  20. package/src/oauth/cursor-routing.ts +252 -0
  21. package/src/oauth/index.ts +3 -0
  22. package/src/providers/cursor-pool.ts +3 -3
  23. package/src/routing/account-pool/affinity.ts +125 -0
  24. package/src/routing/account-pool/cooldown.ts +145 -0
  25. package/src/routing/account-pool/index.ts +45 -0
  26. package/src/routing/account-pool/resolve.ts +356 -0
  27. package/src/routing/account-pool/types.ts +32 -0
  28. package/src/routing/compatibility/behavior.ts +3 -0
  29. package/src/server/management/oauth-account-routes.ts +47 -12
  30. package/src/server/responses/core.ts +400 -72
  31. package/src/types/config.ts +8 -0
  32. package/src/types/provider.ts +7 -0
  33. package/src/types/request.ts +13 -3
  34. package/src/usage/log.ts +4 -0
  35. package/src/web-search/gemini-executor.ts +35 -13
  36. package/src/web-search/index.ts +85 -1
@@ -65,3 +65,74 @@ export function lookupCursorThreadConversation(
65
65
  export function clearCursorThreadContinuityForTests(): void {
66
66
  overrides.clear();
67
67
  }
68
+
69
+ /** Max conversation-id remints after the first surfaced overflow (senpi cap). */
70
+ export const CURSOR_OVERFLOW_REMINT_MAX = 3;
71
+
72
+ type OverflowRemintState = {
73
+ surfaced: boolean;
74
+ remintCount: number;
75
+ skip: boolean;
76
+ };
77
+
78
+ const overflowRemintByScope = new Map<string, OverflowRemintState>();
79
+
80
+ function overflowRemintEntry(scopeKey: string): OverflowRemintState {
81
+ const existing = overflowRemintByScope.get(scopeKey);
82
+ if (existing) return existing;
83
+ const fresh: OverflowRemintState = { surfaced: false, remintCount: 0, skip: false };
84
+ overflowRemintByScope.set(scopeKey, fresh);
85
+ return fresh;
86
+ }
87
+
88
+ /**
89
+ * Stable scope for overflow remint accounting. Thread-identified clients key by
90
+ * thread + identity; conversation-only clients key by the base conversation id
91
+ * captured before any remint (wire id may rotate).
92
+ */
93
+ export function cursorOverflowRemintScopeKey(
94
+ parsed: {
95
+ _clientThreadId?: string;
96
+ _cursorIdentityScope?: string;
97
+ },
98
+ baseConversationId?: string,
99
+ ): string | null {
100
+ if (parsed._clientThreadId) {
101
+ return `overflow\0${cursorThreadScopeKey(parsed._clientThreadId, parsed._cursorIdentityScope)}`;
102
+ }
103
+ const base = baseConversationId?.trim();
104
+ if (!base) return null;
105
+ const scope = parsed._cursorIdentityScope?.trim() || "local";
106
+ return `overflow\0${scope}\0conv\0${base}`;
107
+ }
108
+
109
+ /** True until the first overflow for this scope has been surfaced for Codex compact. */
110
+ export function shouldSurfaceCursorOverflowFirst(scopeKey: string): boolean {
111
+ return overflowRemintEntry(scopeKey).surfaced !== true;
112
+ }
113
+
114
+ export function markCursorOverflowSurfaced(scopeKey: string): void {
115
+ const entry = overflowRemintEntry(scopeKey);
116
+ entry.surfaced = true;
117
+ }
118
+
119
+ export function shouldSkipCursorOverflowRemint(scopeKey: string): boolean {
120
+ const entry = overflowRemintByScope.get(scopeKey);
121
+ if (!entry) return false;
122
+ return entry.skip === true || entry.remintCount >= CURSOR_OVERFLOW_REMINT_MAX;
123
+ }
124
+
125
+ /** Record one overflow remint; returns false when the cap is exhausted. */
126
+ export function recordCursorOverflowRemint(scopeKey: string): boolean {
127
+ const entry = overflowRemintEntry(scopeKey);
128
+ if (entry.skip || entry.remintCount >= CURSOR_OVERFLOW_REMINT_MAX) {
129
+ entry.skip = true;
130
+ return false;
131
+ }
132
+ entry.remintCount += 1;
133
+ return true;
134
+ }
135
+
136
+ export function clearCursorOverflowRemintForTests(): void {
137
+ overflowRemintByScope.clear();
138
+ }
@@ -1,9 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { AdapterEvent, OcxProviderConfig } from "../types";
2
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
3
3
  import type { ProviderAdapter } from "./base";
4
4
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
5
5
  import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
6
- import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
6
+ import {
7
+ isCursorBenignCancelError,
8
+ isCursorInvalidArgumentError,
9
+ isCursorOverflowRemintCandidate,
10
+ safeCursorErrorMessage,
11
+ type CursorSizeContext,
12
+ } from "./cursor/cursor-errors";
7
13
  import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
8
14
  import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
9
15
  import { mapCursorServerMessage } from "./cursor/message-mapper";
@@ -26,7 +32,14 @@ import {
26
32
  } from "./cursor/checkpoint-store";
27
33
  import { debugProviderDiagnostic } from "../lib/debug";
28
34
  import { estimateTokens } from "../lib/token-estimate";
29
- import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
35
+ import {
36
+ cursorOverflowRemintScopeKey,
37
+ markCursorOverflowSurfaced,
38
+ recordCursorOverflowRemint,
39
+ rememberCursorThreadConversation,
40
+ shouldSkipCursorOverflowRemint,
41
+ shouldSurfaceCursorOverflowFirst,
42
+ } from "./cursor/thread-continuity";
30
43
  import { runCursorTurnWithRetry } from "./cursor/transport-retry";
31
44
  import {
32
45
  createDisabledCursorTransport,
@@ -77,10 +90,18 @@ function cursorRequestSizeContext(request: { modelId: string; system: string[];
77
90
  };
78
91
  }
79
92
 
93
+ function assertCursorRequestSupported(parsed: OcxParsedRequest): void {
94
+ if (parsed.options.textFormat !== undefined || parsed._structuredOutput === true) {
95
+ throw new Error("Cursor does not support structured output");
96
+ }
97
+ }
98
+
80
99
  export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAdapterDeps = {}): ProviderAdapter {
81
100
  return {
82
101
  name: "cursor",
83
102
 
103
+ validateRequest: assertCursorRequestSupported,
104
+
84
105
  buildRequest() {
85
106
  return {
86
107
  url: provider.baseUrl || CURSOR_API_URL,
@@ -98,6 +119,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
98
119
  },
99
120
 
100
121
  async runTurn(_parsed, incoming, emit) {
122
+ assertCursorRequestSupported(_parsed);
101
123
  if (incoming.abortSignal?.aborted) {
102
124
  emit({ type: "error", message: "Cursor turn was aborted before start." });
103
125
  return;
@@ -243,39 +265,79 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
243
265
  );
244
266
  };
245
267
 
246
- try {
247
- await runOnce(request);
248
- } catch (err) {
249
- // One-shot fallback for external-model Connect invalid_argument before any
250
- // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result
251
- // resumes, local exec/MCP side effects, and already-emitted output fail closed.
252
- if (
253
- !isCursorInvalidArgumentError(err)
254
- || !isCursorExternalWireModel(request.modelId)
255
- || lastRawIsToolResult
256
- || emittedOutput
257
- || replayUnsafe
258
- || incoming.abortSignal?.aborted
259
- ) {
260
- throw err;
261
- }
262
- const failedConversationId = request.conversationId;
268
+ const overflowRemintBaseId = _parsed._clientThreadId
269
+ ? undefined
270
+ : (previousConversationId ?? _parsed._cursorConversationId);
271
+
272
+ const remintConversationId = (failedConversationId: string) => {
263
273
  lastTransport = undefined;
264
274
  _parsed._cursorConversationId = undefined;
265
- request = createCursorRequest(_parsed, { forceFreshConversation: true });
266
- rekeyContextUsage(failedConversationId, request.conversationId);
267
- _parsed._cursorConversationId = request.conversationId;
268
- // Persist recovery for store:false clients that only send a parent thread id, so the
269
- // next turn does not recompute the stale deterministic thread hash. Isolated helper /
270
- // compaction turns must not park their throwaway id under the parent thread key.
275
+ const next = createCursorRequest(_parsed, { forceFreshConversation: true });
276
+ rekeyContextUsage(failedConversationId, next.conversationId);
277
+ _parsed._cursorConversationId = next.conversationId;
271
278
  if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) {
272
279
  rememberCursorThreadConversation(
273
280
  _parsed._clientThreadId,
274
- request.conversationId,
281
+ next.conversationId,
275
282
  _parsed._cursorIdentityScope,
276
283
  );
277
284
  }
278
- await runOnce(request);
285
+ return next;
286
+ };
287
+
288
+ for (;;) {
289
+ try {
290
+ await runOnce(request);
291
+ break;
292
+ } catch (err) {
293
+ const overflowRemintSafe =
294
+ !lastRawIsToolResult
295
+ && !emittedOutput
296
+ && !replayUnsafe
297
+ && !incoming.abortSignal?.aborted;
298
+ const overflowScopeKey = cursorOverflowRemintScopeKey(
299
+ _parsed,
300
+ overflowRemintBaseId ?? request.conversationId,
301
+ );
302
+
303
+ if (
304
+ overflowScopeKey
305
+ && overflowRemintSafe
306
+ && isCursorOverflowRemintCandidate(err, requestSizeContext)
307
+ ) {
308
+ if (shouldSkipCursorOverflowRemint(overflowScopeKey)) {
309
+ throw err;
310
+ }
311
+ if (shouldSurfaceCursorOverflowFirst(overflowScopeKey)) {
312
+ markCursorOverflowSurfaced(overflowScopeKey);
313
+ throw err;
314
+ }
315
+ if (!recordCursorOverflowRemint(overflowScopeKey)) {
316
+ throw err;
317
+ }
318
+ const failedConversationId = request.conversationId;
319
+ if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef);
320
+ request = remintConversationId(failedConversationId);
321
+ continue;
322
+ }
323
+
324
+ // One-shot fallback for external-model Connect invalid_argument before any
325
+ // non-heartbeat output. Retries apply only to safe plain-user turns; tool-result
326
+ // resumes, local exec/MCP side effects, and already-emitted output fail closed.
327
+ if (
328
+ !isCursorInvalidArgumentError(err)
329
+ || !isCursorExternalWireModel(request.modelId)
330
+ || lastRawIsToolResult
331
+ || emittedOutput
332
+ || replayUnsafe
333
+ || incoming.abortSignal?.aborted
334
+ ) {
335
+ throw err;
336
+ }
337
+ request = remintConversationId(request.conversationId);
338
+ await runOnce(request);
339
+ break;
340
+ }
279
341
  }
280
342
  if (
281
343
  request.checkpointInvalidationReason
@@ -5,7 +5,7 @@ import {
5
5
  retryableGoogleStatus,
6
6
  safeGoogleHttpErrorMessage,
7
7
  } from "./google-errors";
8
- import { repairGoogleInvalidRequestBody } from "./google-wire-compiler";
8
+ import { isGoogleMixedBuiltinToolError, repairGoogleInvalidRequestBody, stripGoogleBuiltinToolsFromWireBody } from "./google-wire-compiler";
9
9
  import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error";
10
10
  import { recordAntigravityCooldown } from "../oauth/antigravity-routing";
11
11
  import {
@@ -378,7 +378,17 @@ async function fetchGoogleWithRetryInternal(
378
378
  } catch (error) {
379
379
  if (ctx.abortSignal?.aborted) throw error;
380
380
  }
381
- const repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText);
381
+ // Mixed built-in + function tools must win over schema repair: those 400s often mention
382
+ // function_declarations, which would otherwise empty parameters and leave google_search attached.
383
+ let repairedBody: string | undefined;
384
+ if (isGoogleMixedBuiltinToolError(payloadText)) {
385
+ repairedBody = stripGoogleBuiltinToolsFromWireBody(activeRequest.body);
386
+ if (repairedBody === undefined) {
387
+ repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText);
388
+ }
389
+ } else {
390
+ repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText);
391
+ }
382
392
  if (repairedBody !== undefined) {
383
393
  compatibilityReplayUsed = true;
384
394
  activeRequest = { ...activeRequest, body: repairedBody };
@@ -94,10 +94,35 @@ function compileContents(value: unknown, toWireName: (name: string) => string):
94
94
  });
95
95
  }
96
96
 
97
+ const GOOGLE_BUILTIN_TOOL_KEYS = new Set([
98
+ "googleSearch", "google_search",
99
+ "urlContext", "url_context",
100
+ "codeExecution", "code_execution",
101
+ ]);
102
+
103
+ function isGoogleBuiltinToolObject(rawTool: unknown): boolean {
104
+ if (!isObject(rawTool)) return false;
105
+ const keys = Object.keys(rawTool);
106
+ return keys.length > 0 && keys.every(key => GOOGLE_BUILTIN_TOOL_KEYS.has(key));
107
+ }
108
+
109
+ function passthroughGoogleBuiltinTools(rawTool: unknown): unknown[] {
110
+ if (!isObject(rawTool) || Array.isArray(rawTool.functionDeclarations)) return [];
111
+ if (!isGoogleBuiltinToolObject(rawTool)) return [];
112
+ const out: Record<string, unknown> = {};
113
+ for (const key of Object.keys(rawTool)) {
114
+ if (key === "googleSearch" || key === "google_search") out.google_search = {};
115
+ else if (key === "urlContext" || key === "url_context") out.url_context = {};
116
+ else if (key === "codeExecution" || key === "code_execution") out.code_execution = rawTool[key] ?? {};
117
+ }
118
+ return Object.keys(out).length > 0 ? [out] : [];
119
+ }
120
+
97
121
  function compileTools(value: unknown, toWireName: (name: string) => string): unknown[] | undefined {
98
122
  if (!Array.isArray(value)) return undefined;
99
123
  const tools = value.flatMap(rawTool => {
100
- if (!isObject(rawTool) || !Array.isArray(rawTool.functionDeclarations)) return [];
124
+ const builtins = passthroughGoogleBuiltinTools(rawTool);
125
+ if (!isObject(rawTool) || !Array.isArray(rawTool.functionDeclarations)) return builtins;
101
126
  const functionDeclarations = rawTool.functionDeclarations.flatMap(rawDeclaration => {
102
127
  if (!isObject(rawDeclaration) || typeof rawDeclaration.name !== "string") return [];
103
128
  return [{
@@ -106,7 +131,10 @@ function compileTools(value: unknown, toWireName: (name: string) => string): unk
106
131
  parameters: sanitizeGeminiToolParameters(rawDeclaration.parameters),
107
132
  }];
108
133
  });
109
- return functionDeclarations.length > 0 ? [{ functionDeclarations }] : [];
134
+ return [
135
+ ...builtins,
136
+ ...(functionDeclarations.length > 0 ? [{ functionDeclarations }] : []),
137
+ ];
110
138
  });
111
139
  return tools.length > 0 ? tools : undefined;
112
140
  }
@@ -141,6 +169,14 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined {
141
169
  const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m));
142
170
  if (valid.length > 0) out.responseModalities = valid;
143
171
  }
172
+ if (value.responseMimeType === "application/json") out.responseMimeType = value.responseMimeType;
173
+ if (isObject(value.responseSchema)) {
174
+ const responseSchema = sanitizeGeminiToolParameters(value.responseSchema);
175
+ const properties = isObject(responseSchema.properties) ? responseSchema.properties : undefined;
176
+ const hasProperties = properties !== undefined && Object.keys(properties).length > 0;
177
+ const hasRequired = Array.isArray(responseSchema.required) && responseSchema.required.length > 0;
178
+ if (hasProperties || hasRequired) out.responseSchema = responseSchema;
179
+ }
144
180
  return Object.keys(out).length > 0 ? out : undefined;
145
181
  }
146
182
 
@@ -230,3 +266,56 @@ export function repairGoogleInvalidRequestBody(body: string, errorPayload: strin
230
266
  }
231
267
  return changed ? JSON.stringify(parsed) : undefined;
232
268
  }
269
+
270
+ function stripBuiltinToolsFromRoot(root: JsonObject): boolean {
271
+ if (!Array.isArray(root.tools)) return false;
272
+ const before = root.tools.length;
273
+ const filtered = root.tools.filter(rawTool => !isGoogleBuiltinToolObject(rawTool));
274
+ root.tools = filtered;
275
+ return filtered.length !== before;
276
+ }
277
+
278
+ /** One mixed-tool 400 replay: drop known built-in siblings and keep functionDeclarations. */
279
+ export function stripGoogleBuiltinToolsFromWireBody(body: string): string | undefined {
280
+ let parsed: unknown;
281
+ try {
282
+ parsed = JSON.parse(body) as unknown;
283
+ } catch {
284
+ return undefined;
285
+ }
286
+ if (!isObject(parsed)) return undefined;
287
+ const root = isObject(parsed.request) ? parsed.request : parsed;
288
+ if (!stripBuiltinToolsFromRoot(root)) return undefined;
289
+ return JSON.stringify(parsed);
290
+ }
291
+
292
+ export function isGoogleMixedBuiltinToolError(errorPayload: string): boolean {
293
+ const mentionsBuiltin = /\b(?:google[_ ]?search|url[_ ]?context|code[_ ]?execution|built[- ]?in(?:\s+tools?)?)\b/i.test(errorPayload);
294
+ if (!mentionsBuiltin) return false;
295
+
296
+ // A mixed-tool error names a builtin AND a function/tool term AND a coexistence verb.
297
+ // Builtin mention is gated above; each pattern below requires a coexistence verb to
298
+ // appear within a bounded window of a tool/function/declaration term (or the phrase
299
+ // itself implies coexistence, e.g. "mutually exclusive"). Bare "coexist"/"alongside"
300
+ // without a nearby tool/function term is NOT enough — a schema error can say fields
301
+ // "coexist" while merely mentioning a builtin.
302
+ const describesIncompatibleCoexistence = [
303
+ /\bmix\w*\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i,
304
+ /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\bmix\w*\b/i,
305
+ /\bcombin\w*\b[^\n.!?]{0,80}\bwith\b/i,
306
+ /\bcoexist\w*\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i,
307
+ /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\bcoexist\w*\b/i,
308
+ /\balongside\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i,
309
+ /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\balongside\b/i,
310
+ /\buse\w*\b[^\n.!?]{0,80}\btogether\b/i,
311
+ /\btogether\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i,
312
+ /\buse\w*\b[^\n.!?]{0,80}\bwith\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i,
313
+ /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\bwith\b[^\n.!?]{0,80}\buse\w*\b/i,
314
+ /\bnot supported with\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i,
315
+ /\bmutually exclusive\b/i,
316
+ /\bincompatib\w*\b[^\n.!?]{0,80}\b(?:coexist\w*|combin\w*|alongside|used together|mutually exclusive)\b/i,
317
+ /\b(?:coexist\w*|combin\w*|alongside|used together|mutually exclusive)\b[^\n.!?]{0,80}\bincompatib\w*\b/i,
318
+ ].some(pattern => pattern.test(errorPayload));
319
+
320
+ return describesIncompatibleCoexistence;
321
+ }
@@ -24,9 +24,16 @@ import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignat
24
24
  import { repairGoogleToolPairs, stripTrailingClaudePrefill } from "./google-antigravity-tools";
25
25
  import { canonicalAntigravityHttpsHost, isAntigravityHttpsHost } from "./google-antigravity-hosts";
26
26
  import { compileGoogleWireBody } from "./google-wire-compiler";
27
+ import { sanitizeGeminiToolParameters } from "./google-tool-schema";
27
28
  import { identifyRoutedModel } from "./identity";
28
29
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
29
30
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
31
+ import {
32
+ extractCcaGroundingSources,
33
+ formatCcaGroundingSourcesAppendix,
34
+ isCcaSearchSuggestionHtml,
35
+ } from "../web-search/gemini-executor";
36
+ import type { WebSearchSource } from "../web-search/parse";
30
37
  import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
31
38
  import { lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
32
39
  import {
@@ -334,22 +341,29 @@ function messagesToGeminiFormat(
334
341
  return { systemInstruction, contents };
335
342
  }
336
343
 
337
- function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
338
- if (!parsed.context.tools?.length) return undefined;
344
+ function toolsToGeminiFormat(
345
+ parsed: OcxParsedRequest,
346
+ wireModelId: string,
347
+ ): unknown[] | undefined {
348
+ const grounding = parsed._ccaInTurnGrounding;
339
349
  const allowed = isAllowedToolChoice(parsed.options.toolChoice)
340
350
  ? new Set(parsed.options.toolChoice.allowedTools)
341
351
  : undefined;
342
352
  const tools = allowed
343
- ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools))
344
- : parsed.context.tools;
345
- if (tools.length === 0) return undefined;
346
- return [{
347
- functionDeclarations: tools.map(t => ({
348
- name: namespacedToolName(t.namespace, t.name),
349
- description: t.description,
350
- parameters: t.parameters,
351
- })),
352
- }];
353
+ ? parsed.context.tools?.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools)) ?? []
354
+ : parsed.context.tools ?? [];
355
+ const functionDeclarations = tools.map(t => ({
356
+ name: namespacedToolName(t.namespace, t.name),
357
+ description: t.description,
358
+ parameters: t.parameters,
359
+ }));
360
+ const wireTools: unknown[] = [];
361
+ if (functionDeclarations.length > 0) wireTools.push({ functionDeclarations });
362
+ if (grounding && !/claude/i.test(wireModelId)) {
363
+ if (grounding.search) wireTools.push({ google_search: {} });
364
+ if (grounding.urlContext) wireTools.push({ url_context: {} });
365
+ }
366
+ return wireTools.length > 0 ? wireTools : undefined;
353
367
  }
354
368
 
355
369
  /**
@@ -430,6 +444,30 @@ function isImageCapableModel(modelId: string): boolean {
430
444
  return IMAGE_CAPABLE_MODELS.has(modelId);
431
445
  }
432
446
 
447
+ function applyGeminiStructuredOutput(
448
+ generationConfig: Record<string, unknown>,
449
+ parsed: OcxParsedRequest,
450
+ wireModelId: string,
451
+ ): void {
452
+ const textFormat = parsed.options.textFormat;
453
+ if (!textFormat) return;
454
+ if (Array.isArray(parsed.context.tools) && parsed.context.tools.length > 0) return;
455
+ if (/claude/i.test(wireModelId) || /claude/i.test(parsed.modelId)) return;
456
+ if (isImageCapableModel(parsed.modelId)) return;
457
+
458
+ generationConfig.responseMimeType = "application/json";
459
+ if (textFormat.type !== "json_schema" || textFormat.schema === undefined) return;
460
+
461
+ const sanitized = sanitizeGeminiToolParameters(textFormat.schema);
462
+ const props = sanitized.properties;
463
+ const propKeys = props !== null && typeof props === "object" && !Array.isArray(props)
464
+ ? Object.keys(props)
465
+ : [];
466
+ const required = Array.isArray(sanitized.required) ? sanitized.required : [];
467
+ if (propKeys.length === 0 && required.length === 0) return;
468
+ generationConfig.responseSchema = sanitized;
469
+ }
470
+
433
471
  /**
434
472
  * Model-visible markdown link for a materialized artifact. Uses the authenticated
435
473
  * opaque HTTP route so remote/container clients can fetch the image without host
@@ -484,11 +522,12 @@ function googleToolCallMetadataFromPart(
484
522
  * Keep that provider visibility bit authoritative here so the streaming and buffered parsers
485
523
  * cannot accidentally expose the same hidden reasoning through different event types.
486
524
  */
487
- function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined {
525
+ function googlePartTextEvent(part: GoogleResponsePart, filterCcaSearchSuggestionHtml = false): AdapterEvent | undefined {
488
526
  // A malformed scalar/object is not text and must not cross the AdapterEvent boundary. Dropping
489
527
  // only this optional field preserves the rest of the part without inventing assistant output by
490
528
  // coercion; an empty string keeps its existing no-event behavior.
491
529
  if (typeof part.text !== "string" || part.text.length === 0) return undefined;
530
+ if (filterCcaSearchSuggestionHtml && isCcaSearchSuggestionHtml(part.text)) return undefined;
492
531
  return part.thought === true
493
532
  ? { type: "reasoning_raw_delta", text: part.text }
494
533
  : { type: "text_delta", text: part.text };
@@ -644,6 +683,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
644
683
  let vertexReplayModel: string | undefined;
645
684
  let vertexReplaySession: string | undefined;
646
685
  let restoreGoogleToolName = (name: string): string => name;
686
+ const emitInTurnGroundingSourcesQueue: boolean[] = [];
647
687
  return {
648
688
  name: "google",
649
689
 
@@ -677,7 +717,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
677
717
  identityModelId,
678
718
  provider.googleMode === "cloud-code-assist",
679
719
  );
680
- const tools = toolsToGeminiFormat(parsed);
720
+ const tools = toolsToGeminiFormat(parsed, routedModelId);
681
721
 
682
722
  const body: Record<string, unknown> = { contents };
683
723
  if (systemInstruction) body.systemInstruction = systemInstruction;
@@ -711,6 +751,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
711
751
  if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) {
712
752
  generationConfig.responseModalities = ["TEXT", "IMAGE"];
713
753
  }
754
+ applyGeminiStructuredOutput(generationConfig, parsed, routedModelId);
714
755
  if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig;
715
756
 
716
757
  const ccaAlwaysSse = provider.googleMode === "cloud-code-assist";
@@ -810,6 +851,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
810
851
  if (/claude/i.test(wireModelId)) {
811
852
  headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
812
853
  }
854
+ emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding);
813
855
  return { url, method: "POST", headers, body: JSON.stringify(envelope) };
814
856
  }
815
857
 
@@ -835,6 +877,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
835
877
  if (apiKey) {
836
878
  const url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${parsed.modelId}:${method}${streamParam}`;
837
879
  headers["x-goog-api-key"] = apiKey;
880
+ emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding);
838
881
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
839
882
  }
840
883
  const project = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
@@ -847,6 +890,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
847
890
  const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`;
848
891
  const token = await getVertexAccessToken();
849
892
  headers["Authorization"] = `Bearer ${token}`;
893
+ emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding);
850
894
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
851
895
  }
852
896
 
@@ -858,10 +902,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
858
902
 
859
903
  const compiled = compileGoogleWireBody(body);
860
904
  restoreGoogleToolName = compiled.restoreToolName;
905
+ emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding);
861
906
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
862
907
  },
863
908
 
864
909
  async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
910
+ const emitInTurnGroundingSources = emitInTurnGroundingSourcesQueue.shift() ?? false;
911
+ const filterCcaSearchSuggestionHtml =
912
+ provider.googleMode === "cloud-code-assist" && emitInTurnGroundingSources;
865
913
  if (!response.body) {
866
914
  yield { type: "error", message: "No response body" };
867
915
  return;
@@ -886,6 +934,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
886
934
  let sawAnyFrame = false;
887
935
  let sawTerminalSignal = false;
888
936
  let pendingStreamThoughtSig: string | undefined;
937
+ const groundingSources: WebSearchSource[] = [];
938
+ let groundingSourcesEmitted = false;
939
+
940
+ const mergeGroundingSources = (groundingMetadata: unknown): void => {
941
+ for (const source of extractCcaGroundingSources(groundingMetadata)) {
942
+ if (!groundingSources.some(existing => existing.url === source.url)) groundingSources.push(source);
943
+ }
944
+ };
889
945
 
890
946
  const handleDataLine = async function* (line: string): AsyncGenerator<AdapterEvent, "continue" | "content" | "terminate"> {
891
947
  const payload = line.slice(5).trim();
@@ -980,8 +1036,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
980
1036
  const candidate = rawCandidate as {
981
1037
  content?: unknown;
982
1038
  finishReason?: string;
1039
+ groundingMetadata?: unknown;
983
1040
  };
984
1041
 
1042
+ if (emitInTurnGroundingSources && candidate.groundingMetadata) {
1043
+ mergeGroundingSources(candidate.groundingMetadata);
1044
+ }
1045
+
985
1046
  if (typeof candidate.finishReason === "string" && candidate.finishReason) {
986
1047
  lastFinishReason = candidate.finishReason;
987
1048
  sawTerminalSignal = true;
@@ -1029,7 +1090,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1029
1090
  if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
1030
1091
  pendingStreamThoughtSig = sig;
1031
1092
  }
1032
- const textEvent = googlePartTextEvent(part);
1093
+ const textEvent = googlePartTextEvent(part, filterCcaSearchSuggestionHtml);
1033
1094
  if (textEvent) {
1034
1095
  emittedContentEvent = true;
1035
1096
  yield textEvent;
@@ -1140,6 +1201,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1140
1201
  yield { type: "error", message: "upstream stream ended without a terminal signal — possible truncation" };
1141
1202
  return;
1142
1203
  }
1204
+ if (emitInTurnGroundingSources && !groundingSourcesEmitted && groundingSources.length > 0) {
1205
+ const appendix = formatCcaGroundingSourcesAppendix(groundingSources);
1206
+ if (appendix) {
1207
+ groundingSourcesEmitted = true;
1208
+ yield { type: "text_delta", text: appendix };
1209
+ }
1210
+ }
1143
1211
  const stopReason = lastFinishReason === "MAX_TOKENS"
1144
1212
  ? "max_tokens"
1145
1213
  : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(lastFinishReason ?? "")
package/src/config.ts CHANGED
@@ -735,6 +735,8 @@ const providerConfigSchema = z.object({
735
735
  // accepted, persisted, and then silently resolved to the `code_mode_only` default — the
736
736
  // operator asked for shell mode, got code mode, and was told nothing (#2106).
737
737
  codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
738
+ // Validated rather than passed through: same rationale as codexToolMode above.
739
+ projectContext: z.enum(["off", "on"]).optional(),
738
740
  responsesItemIdRepair: z.object({
739
741
  message: z.array(z.string().min(1)).optional(),
740
742
  reasoning: z.array(z.string().min(1)).optional(),