@gajae-code/coding-agent 0.15.4 → 0.15.6

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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -208,7 +208,15 @@ function chainCacheKey(
208
208
  preferred ?? null,
209
209
  fallbacks,
210
210
  ctx
211
- ? [ctx.provider, ctx.modelId, ctx.wireModelId ?? null, ctx.api, ctx.baseUrl ?? null, ctx.webSearch ?? null]
211
+ ? [
212
+ ctx.provider,
213
+ ctx.modelId,
214
+ ctx.wireModelId ?? null,
215
+ ctx.api,
216
+ ctx.baseUrl ?? null,
217
+ ctx.webSearch ?? null,
218
+ ctx.ownerAuthOverride ?? false,
219
+ ]
212
220
  : null,
213
221
  ]);
214
222
  }
@@ -278,7 +286,17 @@ function isAnthropicWire(api: string): boolean {
278
286
  }
279
287
 
280
288
  function isGoogleWire(api: string): boolean {
281
- return api === "google-generative-ai" || api === "google-vertex" || api === "google-gemini-cli";
289
+ return (
290
+ api === "google-generative-ai" ||
291
+ api === "google-generative-language" ||
292
+ api === "gemini-wire" ||
293
+ api === "google-vertex" ||
294
+ api === "google-gemini-cli"
295
+ );
296
+ }
297
+
298
+ function isGenerativeLanguageWire(api: string): boolean {
299
+ return api === "google-generative-ai" || api === "google-generative-language" || api === "gemini-wire";
282
300
  }
283
301
 
284
302
  function isOpenAICompatWire(api: string): boolean {
@@ -305,6 +323,52 @@ function looksXaiFamilyModelId(ctx: ActiveSearchModelContext): boolean {
305
323
  return looksXaiModelId(ctx.wireModelId) || looksXaiModelId(ctx.modelId);
306
324
  }
307
325
 
326
+ const OFFICIAL_GEMINI_HOSTS = new Set([
327
+ "cloudcode-pa.googleapis.com",
328
+ "daily-cloudcode-pa.googleapis.com",
329
+ "daily-cloudcode-pa.sandbox.googleapis.com",
330
+ "generativelanguage.googleapis.com",
331
+ ]);
332
+
333
+ function normalizedHost(baseUrl: string): string | undefined {
334
+ try {
335
+ return new URL(baseUrl).hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
336
+ } catch {
337
+ return undefined;
338
+ }
339
+ }
340
+
341
+ function isOfficialGeminiBaseUrl(baseUrl: string | undefined): boolean {
342
+ if (!baseUrl?.trim()) return true;
343
+ const host = normalizedHost(baseUrl);
344
+ return host !== undefined && OFFICIAL_GEMINI_HOSTS.has(host);
345
+ }
346
+
347
+ function isOfficialXaiBaseUrl(baseUrl: string | undefined): boolean {
348
+ if (!baseUrl?.trim()) return true;
349
+ return normalizedHost(baseUrl) === "api.x.ai";
350
+ }
351
+
352
+ const CANONICAL_GEMINI_PROVIDERS = new Set(["google", "gemini", "google-gemini-cli", "google-antigravity"]);
353
+
354
+ function hasCustomGeminiSearchContext(ctx: ActiveSearchModelContext): boolean {
355
+ if (!isGoogleWire(ctx.api)) return false;
356
+ if (ctx.ownerAuthOverride) return true;
357
+ if (!isOfficialGeminiBaseUrl(ctx.baseUrl)) return true;
358
+ const provider = ctx.provider.toLowerCase();
359
+ return Boolean(ctx.resolveCredentials && !CANONICAL_GEMINI_PROVIDERS.has(provider));
360
+ }
361
+
362
+ function hasCustomXaiSearchContext(ctx: ActiveSearchModelContext): boolean {
363
+ if (!isOpenAICompatWire(ctx.api)) return false;
364
+ if (ctx.ownerAuthOverride) return true;
365
+ const provider = ctx.provider.toLowerCase();
366
+ if (provider === "xai" && !isOfficialXaiBaseUrl(ctx.baseUrl)) return true;
367
+ if (!looksXaiFamilyModelId(ctx)) return false;
368
+ if (!isOfficialXaiBaseUrl(ctx.baseUrl)) return true;
369
+ return provider !== "xai";
370
+ }
371
+
308
372
  export function isLocalBaseUrl(baseUrl: string | undefined): boolean {
309
373
  if (!baseUrl) return false;
310
374
  let url: URL;
@@ -369,12 +433,29 @@ export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | und
369
433
  if (!ctx || ctx.webSearch === "off") return undefined;
370
434
  const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
371
435
  if (modelId.startsWith("claude-") && isAnthropicWire(ctx.api)) return "anthropic";
372
- if (modelId.startsWith("gemini-") && isGoogleWire(ctx.api)) return "gemini";
373
- if (looksXaiFamilyModelId(ctx) && isOpenAICompatWire(ctx.api)) return "xai";
436
+ if (modelId.startsWith("gemini-") && isGoogleWire(ctx.api) && !hasCustomGeminiSearchContext(ctx)) return "gemini";
437
+ // A custom proxy may expose a Grok model through an OpenAI wire while the
438
+ // process also has canonical xAI credentials. Do not infer the canonical
439
+ // xAI provider from the model family: that would silently send the search to
440
+ // api.x.ai with another registry's credentials. Custom active contexts are
441
+ // dispatched below by activeContextNativeId, where the provider can consume
442
+ // the active owner-bound resolver instead.
443
+ if (
444
+ looksXaiFamilyModelId(ctx) &&
445
+ isOpenAICompatWire(ctx.api) &&
446
+ ctx.provider.toLowerCase() === "xai" &&
447
+ !hasCustomXaiSearchContext(ctx)
448
+ )
449
+ return "xai";
374
450
  // `codex` hits the ChatGPT backend with local Codex OAuth, so only infer it
375
451
  // for genuine OpenAI endpoints. Custom/proxy OpenAI-compatible models fall
376
- // through to `activeContextNativeId` → `openai-compatible` (their own creds).
377
- if (looksOpenAIFamilyModelId(ctx) && isOpenAICompatWire(ctx.api) && isOpenAIOfficialBaseUrl(ctx.baseUrl)) {
452
+ // through to `activeContextNativeId` and reuse their own credentials.
453
+ if (
454
+ !ctx.ownerAuthOverride &&
455
+ looksOpenAIFamilyModelId(ctx) &&
456
+ isOpenAICompatWire(ctx.api) &&
457
+ isOpenAIOfficialBaseUrl(ctx.baseUrl)
458
+ ) {
378
459
  return "codex";
379
460
  }
380
461
  return undefined;
@@ -382,7 +463,10 @@ export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | und
382
463
 
383
464
  function canUseDirectProviderMapping(ctx: ActiveSearchModelContext, id: SearchProviderId): boolean {
384
465
  if (ctx.webSearch === "off") return false;
466
+ if (id === "gemini" && hasCustomGeminiSearchContext(ctx)) return false;
467
+ if (id === "xai" && hasCustomXaiSearchContext(ctx)) return false;
385
468
  if (id !== "codex") return true;
469
+ if (ctx.ownerAuthOverride) return false;
386
470
  // Same constraint as inference: the ChatGPT-backed codex provider is valid
387
471
  // only for official OpenAI endpoints, not custom/proxy base URLs.
388
472
  return isOpenAIOfficialBaseUrl(ctx.baseUrl);
@@ -395,6 +479,10 @@ export async function canUseGenericCredentials(
395
479
  signal?: AbortSignal,
396
480
  ): Promise<boolean> {
397
481
  if (!ctx) return false;
482
+ if (ctx.resolveCredentials) {
483
+ const resolved = await ctx.resolveCredentials({ sessionId, signal });
484
+ return Boolean(resolved.apiKey) || hasActiveCredentialHeader(resolved.headers);
485
+ }
398
486
  const key = await authStorage.getApiKey(ctx.provider, sessionId, {
399
487
  baseUrl: ctx.baseUrl,
400
488
  modelId: ctx.modelId,
@@ -403,6 +491,13 @@ export async function canUseGenericCredentials(
403
491
  return Boolean(key);
404
492
  }
405
493
 
494
+ function hasActiveCredentialHeader(headers: Record<string, string> | undefined): boolean {
495
+ return Object.keys(headers ?? {}).some(key => {
496
+ const normalized = key.toLowerCase();
497
+ return normalized === "authorization" || normalized === "x-api-key" || normalized === "x-goog-api-key";
498
+ });
499
+ }
500
+
406
501
  /**
407
502
  * Native web-search provider to attempt by reusing the ACTIVE model's own
408
503
  * credentials + baseUrl, dispatched by the model's wire protocol.
@@ -424,13 +519,20 @@ export function activeContextNativeId(ctx: ActiveSearchModelContext | undefined)
424
519
  if (!ctx || ctx.webSearch === "off") return undefined;
425
520
  const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
426
521
  // Dispatch must match exactly what each provider can service by reusing the
427
- // active credential: the OpenAI-compatible adapter only speaks the two plain
428
- // OpenAI wires (not azure), and the Gemini active path only speaks the public
429
- // Generative Language wire (not vertex/cloud-code). Returning an id the
430
- // provider would reject just wastes a guaranteed-fail attempt before DuckDuckGo.
522
+ // active credential: xAI-family models use the xAI search tools, the
523
+ // OpenAI-compatible adapter only speaks the two plain OpenAI wires (not
524
+ // azure), and the Gemini active path only speaks the public Generative
525
+ // Language wire (not vertex/cloud-code). Returning an id the provider would
526
+ // reject just wastes a guaranteed-fail attempt before DuckDuckGo.
431
527
  if (isAnthropicWire(ctx.api) && modelId.startsWith("claude-")) return "anthropic";
528
+ if (isGenerativeLanguageWire(ctx.api) && modelId.startsWith("gemini-")) return "gemini";
529
+ if (
530
+ isOpenAICompatWire(ctx.api) &&
531
+ hasCustomXaiSearchContext(ctx) &&
532
+ (looksXaiFamilyModelId(ctx) || ctx.provider.toLowerCase() === "xai")
533
+ )
534
+ return "xai";
432
535
  if (ctx.api === "openai-responses" || ctx.api === "openai-completions") return "openai-compatible";
433
- if (ctx.api === "google-generative-ai" && modelId.startsWith("gemini-")) return "gemini";
434
536
  return undefined;
435
537
  }
436
538
 
@@ -445,6 +547,13 @@ export async function resolveProviderChain(options: ResolveProviderChainOptions)
445
547
  settings,
446
548
  } = options;
447
549
 
550
+ // Resolve the active model before consulting the chain cache. The registry
551
+ // resolver refreshes rotating apiKeyEnv credentials and updates generated
552
+ // headers, which also advances AuthStorage's generation when credentials
553
+ // rotate or disappear.
554
+ if (preferredProvider === "auto" && activeModelContext?.resolveCredentials) {
555
+ await activeModelContext.resolveCredentials({ sessionId, signal });
556
+ }
448
557
  const cacheKey = chainCacheKey(preferredProvider, fallbackProviders, activeModelContext, authStorage, settings);
449
558
  const perStorage = chainCache.get(authStorage);
450
559
  const now = Date.now();
@@ -18,6 +18,7 @@ import {
18
18
  } from "@gajae-code/ai/utils/anthropic-auth";
19
19
  import { $credentialEnv, $env } from "@gajae-code/utils";
20
20
  import type {
21
+ ActiveSearchModelCredentials,
21
22
  AnthropicApiResponse,
22
23
  AnthropicCitation,
23
24
  SearchCitation,
@@ -73,6 +74,28 @@ function buildSystemBlocks(
73
74
  });
74
75
  }
75
76
 
77
+ function mergeHeadersPreservingExplicit(
78
+ baseHeaders: Record<string, string>,
79
+ explicitHeaders: Record<string, string> | undefined,
80
+ ): Record<string, string> {
81
+ const merged = { ...baseHeaders };
82
+ for (const [key, value] of Object.entries(explicitHeaders ?? {})) {
83
+ for (const existingKey of Object.keys(merged)) {
84
+ if (existingKey.toLowerCase() === key.toLowerCase()) delete merged[existingKey];
85
+ }
86
+ merged[key] = value;
87
+ }
88
+ return merged;
89
+ }
90
+
91
+ function explicitCredentialHeader(headers: Record<string, string> | undefined): string | undefined {
92
+ for (const [key, value] of Object.entries(headers ?? {})) {
93
+ const normalized = key.toLowerCase();
94
+ if (normalized === "authorization" || normalized === "x-api-key") return value;
95
+ }
96
+ return undefined;
97
+ }
98
+
76
99
  /**
77
100
  * Calls the Anthropic API with web search tool enabled.
78
101
  * @param auth - Authentication configuration (API key or OAuth)
@@ -93,7 +116,7 @@ async function callSearch(
93
116
  extraHeaders?: Record<string, string>,
94
117
  ): Promise<AnthropicApiResponse> {
95
118
  const url = buildAnthropicUrl(auth);
96
- const headers = { ...(extraHeaders ?? {}), ...buildAnthropicSearchHeaders(auth) };
119
+ const headers = mergeHeadersPreservingExplicit(buildAnthropicSearchHeaders(auth), extraHeaders);
97
120
 
98
121
  const systemBlocks = buildSystemBlocks(auth, model, systemPrompt);
99
122
 
@@ -284,29 +307,78 @@ export async function searchAnthropic(
284
307
  // proxy), prefer its wire model id and carry its request headers through.
285
308
  let modelOverride: string | undefined;
286
309
  let extraHeaders: Record<string, string> | undefined;
287
-
288
- if (searchApiKey) {
289
- auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
290
- } else if ("authStorage" in params) {
291
- const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
310
+ const activeContext = "authStorage" in params ? params.activeModelContext : undefined;
311
+ const activeSessionId = "authStorage" in params ? params.sessionId : undefined;
312
+ const activeCustomContext =
313
+ activeContext?.api === "anthropic-messages" &&
314
+ (activeContext.ownerAuthOverride || activeContext.provider.toLowerCase() !== "anthropic");
315
+
316
+ if (activeCustomContext && activeContext?.resolveCredentials) {
317
+ const activeCredentials = await activeContext.resolveCredentials({
318
+ sessionId: activeSessionId,
292
319
  signal: params.signal,
293
320
  });
294
- if (apiKey) auth = buildAnthropicAuthConfig(apiKey);
295
-
296
- // Fall back to the active model's own credentials + baseUrl when no
297
- // canonical Anthropic key exists but the active model speaks the
298
- // Anthropic wire (e.g. Claude served through a proxy).
299
- const ctx = params.activeModelContext;
300
- if (!auth && ctx && ctx.api === "anthropic-messages") {
301
- const ctxKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
302
- baseUrl: ctx.baseUrl,
303
- modelId: ctx.modelId,
321
+ const ctxKey =
322
+ activeCredentials.apiKey ?? explicitCredentialHeader(activeCredentials.headers ?? activeContext.headers);
323
+ if (ctxKey) {
324
+ auth = buildAnthropicAuthConfig(ctxKey, activeContext.baseUrl);
325
+ modelOverride = activeContext.wireModelId ?? activeContext.modelId;
326
+ extraHeaders = activeCredentials.headers ?? activeContext.headers;
327
+ }
328
+ } else if (searchApiKey) {
329
+ auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
330
+ } else if ("authStorage" in params) {
331
+ const ctx = activeContext;
332
+ const resolveActiveCredentials = ctx?.resolveCredentials;
333
+ const activeAnthropicWire = ctx?.api === "anthropic-messages";
334
+ const activeModelOwnsProvider =
335
+ ctx !== undefined &&
336
+ resolveActiveCredentials !== undefined &&
337
+ activeAnthropicWire &&
338
+ ctx.provider.toLowerCase() === "anthropic";
339
+ const activeCustomModel =
340
+ ctx !== undefined &&
341
+ resolveActiveCredentials !== undefined &&
342
+ activeAnthropicWire &&
343
+ ctx.provider.toLowerCase() !== "anthropic";
344
+ if (activeModelOwnsProvider || activeCustomModel) {
345
+ const activeCredentials: ActiveSearchModelCredentials = await resolveActiveCredentials({
346
+ sessionId: params.sessionId,
304
347
  signal: params.signal,
305
348
  });
349
+ const ctxKey = activeCredentials.apiKey ?? explicitCredentialHeader(activeCredentials.headers ?? ctx.headers);
306
350
  if (ctxKey) {
307
351
  auth = buildAnthropicAuthConfig(ctxKey, ctx.baseUrl);
308
352
  modelOverride = ctx.wireModelId ?? ctx.modelId;
309
- extraHeaders = ctx.headers;
353
+ extraHeaders = activeCredentials.headers ?? ctx.headers;
354
+ }
355
+ } else {
356
+ const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
357
+ signal: params.signal,
358
+ });
359
+ if (apiKey) auth = buildAnthropicAuthConfig(apiKey);
360
+
361
+ // Fall back to the active model's own credentials + baseUrl when no
362
+ // canonical Anthropic key exists but the active model speaks the
363
+ // Anthropic wire (e.g. Claude served through a proxy).
364
+ if (!auth && ctx && ctx.api === "anthropic-messages") {
365
+ const activeCredentials: ActiveSearchModelCredentials = ctx.resolveCredentials
366
+ ? await ctx.resolveCredentials({ sessionId: params.sessionId, signal: params.signal })
367
+ : {
368
+ apiKey: await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
369
+ baseUrl: ctx.baseUrl,
370
+ modelId: ctx.modelId,
371
+ signal: params.signal,
372
+ }),
373
+ headers: ctx.headers,
374
+ };
375
+ const ctxKey =
376
+ activeCredentials.apiKey ?? explicitCredentialHeader(activeCredentials.headers ?? ctx.headers);
377
+ if (ctxKey) {
378
+ auth = buildAnthropicAuthConfig(ctxKey, ctx.baseUrl);
379
+ modelOverride = ctx.wireModelId ?? ctx.modelId;
380
+ extraHeaders = activeCredentials.headers ?? ctx.headers;
381
+ }
310
382
  }
311
383
  }
312
384
  }
@@ -20,7 +20,10 @@ export const SEARXNG_BEARER_CREDENTIAL_PREFIX = "gjc-searxng-bearer:";
20
20
  /**
21
21
  * Shared web search parameters passed to providers.
22
22
  *
23
- * `authStorage` is the **only** credential source providers may consult.
23
+ * `authStorage` is the credential source for canonical providers. Native
24
+ * active-model search may instead use `activeModelContext.resolveCredentials`,
25
+ * which is supplied by ModelRegistry so rotating config credentials and
26
+ * generated headers are refreshed before the request.
24
27
  * Opening a sibling SQLite handle or calling provider-direct refresh helpers
25
28
  * (e.g. `refreshOpenAIOpenAI code backendToken`, `refreshGoogleCloudToken`) is prohibited:
26
29
  * it races the broker's per-credential refresh and POSTs the broker sentinel
@@ -75,8 +78,8 @@ export interface SearchParams {
75
78
  codeExecution?: Record<string, unknown>;
76
79
  urlContext?: Record<string, unknown>;
77
80
  /**
78
- * The single source of truth for credentials. Providers MUST consult this
79
- * handle exclusively (`getApiKey` for bearer-style auth, `getOAuthAccess`
81
+ * The canonical source of truth for credentials. Providers MUST consult this
82
+ * handle (`getApiKey` for bearer-style auth, `getOAuthAccess`
80
83
  * when identity metadata is required). Do not open `AgentStorage` or any
81
84
  * `AuthCredentialStore` directly — that bypasses the broker pipeline and
82
85
  * the per-credential single-flight refresh.
@@ -108,7 +111,9 @@ export abstract class SearchProvider {
108
111
  abstract isAvailable(authStorage: AuthStorage, settings?: SearchProviderSettings): Promise<boolean> | boolean;
109
112
 
110
113
  /**
111
- * Execute a search. Credentials MUST be resolved through `params.authStorage`.
114
+ * Execute a search. Canonical credentials MUST be resolved through
115
+ * `params.authStorage`; active-model credentials MUST use the injected
116
+ * resolver when one is present.
112
117
  */
113
118
  abstract search(params: SearchParams): Promise<SearchResponse>;
114
119
  }
@@ -16,7 +16,13 @@ import {
16
16
  } from "@gajae-code/ai/providers/google-gemini-headers";
17
17
  import { fetchWithRetry } from "@gajae-code/utils";
18
18
 
19
- import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
19
+ import type {
20
+ ActiveSearchModelContext,
21
+ ActiveSearchModelCredentials,
22
+ SearchCitation,
23
+ SearchResponse,
24
+ SearchSource,
25
+ } from "../../../web/search/types";
20
26
  import { SearchProviderError } from "../../../web/search/types";
21
27
  import type { SearchParams } from "./base";
22
28
  import { SearchProvider } from "./base";
@@ -38,6 +44,49 @@ const RATE_LIMIT_BUDGET_MS = 30 * 1000;
38
44
  const GEMINI_PROVIDERS = ["google-gemini-cli", "google-antigravity"] as const;
39
45
  type GeminiProviderId = (typeof GEMINI_PROVIDERS)[number];
40
46
 
47
+ function isGenerativeLanguageWire(api: string): boolean {
48
+ return api === "google-generative-ai" || api === "google-generative-language" || api === "gemini-wire";
49
+ }
50
+
51
+ function isGoogleWire(api: string): boolean {
52
+ return isGenerativeLanguageWire(api) || api === "google-gemini-cli" || api === "google-vertex";
53
+ }
54
+
55
+ const OFFICIAL_GEMINI_HOSTS = new Set([
56
+ "cloudcode-pa.googleapis.com",
57
+ "daily-cloudcode-pa.googleapis.com",
58
+ "daily-cloudcode-pa.sandbox.googleapis.com",
59
+ "generativelanguage.googleapis.com",
60
+ ]);
61
+ const CANONICAL_GEMINI_PROVIDERS = new Set(["google", "gemini", "google-gemini-cli", "google-antigravity"]);
62
+
63
+ function isOfficialGeminiBaseUrl(baseUrl: string | undefined): boolean {
64
+ if (!baseUrl?.trim()) return true;
65
+ try {
66
+ const host = new URL(baseUrl).hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
67
+ return OFFICIAL_GEMINI_HOSTS.has(host);
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function isCustomGeminiContext(ctx: ActiveSearchModelContext | undefined): boolean {
74
+ if (!ctx || !isGoogleWire(ctx.api)) return false;
75
+ if (ctx.ownerAuthOverride) return true;
76
+ if (!isOfficialGeminiBaseUrl(ctx.baseUrl)) return true;
77
+ const provider = ctx.provider.toLowerCase();
78
+ return Boolean(ctx.resolveCredentials && !CANONICAL_GEMINI_PROVIDERS.has(provider));
79
+ }
80
+
81
+ function isCustomGenerativeLanguageContext(ctx: ActiveSearchModelContext | undefined): boolean {
82
+ return isGenerativeLanguageWire(ctx?.api ?? "") && isCustomGeminiContext(ctx);
83
+ }
84
+
85
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
86
+ const normalized = name.toLowerCase();
87
+ return Object.keys(headers).some(key => key.toLowerCase() === normalized);
88
+ }
89
+
41
90
  interface GeminiToolParams {
42
91
  google_search?: Record<string, unknown>;
43
92
  code_execution?: Record<string, unknown>;
@@ -433,19 +482,28 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
433
482
  * Native Gemini web search over the public Generative Language REST API
434
483
  * (`{baseUrl}/v1beta/models/{model}:generateContent`), reusing the ACTIVE
435
484
  * model's own API key + baseUrl. This is the "native search over a proxy" path
436
- * for `google-generative-ai` wire models whose canonical gemini-cli/antigravity
437
- * OAuth is absent. Distinct from {@link searchGemini}, which speaks the Cloud
438
- * Code Assist API with OAuth.
485
+ * for Generative Language wire models. Distinct from {@link searchGemini},
486
+ * which speaks the Cloud Code Assist API with OAuth.
439
487
  */
440
488
  async function searchGeminiViaGenerativeLanguage(params: SearchParams): Promise<SearchResponse> {
441
489
  const ctx = params.activeModelContext;
442
490
  if (!ctx) throw new SearchProviderError("gemini", "Gemini web search requires active model context", 400);
443
- const apiKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
444
- baseUrl: ctx.baseUrl,
445
- modelId: ctx.modelId,
446
- signal: params.signal,
447
- });
448
- if (!apiKey) throw new SearchProviderError("gemini", `No credentials for ${ctx.provider}`, 401);
491
+ const activeCredentials: ActiveSearchModelCredentials = ctx.resolveCredentials
492
+ ? await ctx.resolveCredentials({ sessionId: params.sessionId, signal: params.signal })
493
+ : {
494
+ apiKey: await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
495
+ baseUrl: ctx.baseUrl,
496
+ modelId: ctx.modelId,
497
+ signal: params.signal,
498
+ }),
499
+ headers: ctx.headers,
500
+ };
501
+ const apiKey = activeCredentials.apiKey;
502
+ const headers = { ...(activeCredentials.headers ?? ctx.headers ?? {}) };
503
+ if (apiKey && !hasHeader(headers, "x-goog-api-key")) headers["x-goog-api-key"] = apiKey;
504
+ if (!apiKey && !hasHeader(headers, "x-goog-api-key")) {
505
+ throw new SearchProviderError("gemini", `No credentials for ${ctx.provider}`, 401);
506
+ }
449
507
 
450
508
  const model = ctx.wireModelId ?? ctx.modelId;
451
509
  const base = (ctx.baseUrl ?? "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
@@ -468,7 +526,10 @@ async function searchGeminiViaGenerativeLanguage(params: SearchParams): Promise<
468
526
 
469
527
  const response = await fetch(url, {
470
528
  method: "POST",
471
- headers: { ...(ctx.headers ?? {}), "x-goog-api-key": apiKey, "Content-Type": "application/json" },
529
+ headers: {
530
+ ...headers,
531
+ ...(hasHeader(headers, "content-type") ? {} : { "Content-Type": "application/json" }),
532
+ },
472
533
  body: JSON.stringify(body),
473
534
  signal: withHardTimeout(params.signal, "llm"),
474
535
  });
@@ -534,11 +595,22 @@ export class GeminiProvider extends SearchProvider {
534
595
  }
535
596
 
536
597
  search(params: SearchParams): Promise<SearchResponse> {
537
- // Native-over-proxy: when canonical gemini-cli/antigravity OAuth is
538
- // absent but the active model speaks the Generative Language wire, reuse
539
- // its own API key + baseUrl instead of failing closed.
540
598
  const ctx = params.activeModelContext;
541
- if (!hasGeminiOAuth(params.authStorage) && ctx?.api === "google-generative-ai") {
599
+ // Native-over-proxy: a custom model's owner-bound resolver must win over
600
+ // canonical Gemini OAuth. Otherwise the request would silently leave the
601
+ // proxy and hit Cloud Code with another registry's identity.
602
+ if (isCustomGeminiContext(ctx)) {
603
+ if (isCustomGenerativeLanguageContext(ctx)) return searchGeminiViaGenerativeLanguage(params);
604
+ return Promise.reject(
605
+ new SearchProviderError(
606
+ "gemini",
607
+ `Gemini native search does not support custom ${ctx?.api ?? ""} transports`,
608
+ 400,
609
+ ),
610
+ );
611
+ }
612
+ // Preserve the existing active-key fallback when canonical OAuth is absent.
613
+ if (!hasGeminiOAuth(params.authStorage) && ctx && isGenerativeLanguageWire(ctx.api)) {
542
614
  return searchGeminiViaGenerativeLanguage(params);
543
615
  }
544
616
  return searchGemini({
@@ -1,4 +1,4 @@
1
- import type { SearchCitation, SearchResponse, SearchSource } from "../types";
1
+ import type { ActiveSearchModelCredentials, SearchCitation, SearchResponse, SearchSource } from "../types";
2
2
  import { SearchProviderError } from "../types";
3
3
  import type { SearchParams } from "./base";
4
4
  import { SearchProvider } from "./base";
@@ -7,6 +7,11 @@ import { classifyProviderHttpError, withHardTimeout } from "./utils";
7
7
 
8
8
  type JsonObject = Record<string, unknown>;
9
9
 
10
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
11
+ const normalized = name.toLowerCase();
12
+ return Object.keys(headers).some(key => key.toLowerCase() === normalized);
13
+ }
14
+
10
15
  function isJsonObject(value: unknown): value is JsonObject {
11
16
  return typeof value === "object" && value !== null && !Array.isArray(value);
12
17
  }
@@ -154,15 +159,25 @@ export class OpenAICompatibleSearchProvider extends SearchProvider {
154
159
  if (ctx.api !== "openai-responses" && ctx.api !== "openai-completions") {
155
160
  throw new SearchProviderError(this.id, `OpenAI-compatible web search does not support ${ctx.api}`, 400);
156
161
  }
157
- const apiKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
158
- baseUrl: ctx.baseUrl,
159
- modelId: ctx.modelId,
160
- signal: params.signal,
161
- });
162
- if (!apiKey) throw new SearchProviderError(this.id, `No credentials for ${ctx.provider}`, 401);
162
+ const activeCredentials: ActiveSearchModelCredentials = ctx.resolveCredentials
163
+ ? await ctx.resolveCredentials({ sessionId: params.sessionId, signal: params.signal })
164
+ : {
165
+ apiKey: await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
166
+ baseUrl: ctx.baseUrl,
167
+ modelId: ctx.modelId,
168
+ signal: params.signal,
169
+ }),
170
+ headers: ctx.headers,
171
+ };
172
+ const apiKey = activeCredentials.apiKey;
173
+ const headers = { ...(activeCredentials.headers ?? ctx.headers ?? {}) };
174
+ if (apiKey && !hasHeader(headers, "authorization")) headers.Authorization = `Bearer ${apiKey}`;
175
+ if (!hasHeader(headers, "authorization")) {
176
+ throw new SearchProviderError(this.id, `No credentials for ${ctx.provider}`, 401);
177
+ }
178
+ if (!hasHeader(headers, "content-type")) headers["Content-Type"] = "application/json";
163
179
  const model = ctx.wireModelId ?? ctx.modelId;
164
180
  const baseUrl = ctx.baseUrl ?? "";
165
- const headers = { ...(ctx.headers ?? {}), Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
166
181
  const messages = [
167
182
  { role: "system", content: params.systemPrompt },
168
183
  { role: "user", content: params.query },