@bitkyc08/opencodex 2.48.0 → 2.50.0

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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -288,6 +288,26 @@ export interface OcxRemoteGuiConfig {
288
288
 
289
289
  export type OcxConnectedClientId = "codex" | "claude";
290
290
 
291
+ /**
292
+ * Redaction policy for management and CLI projections (#3859).
293
+ *
294
+ * `privacy` rather than `dashboard`: `ocx status` and `ocx account` are not the dashboard, and
295
+ * they read the same projections.
296
+ */
297
+ export interface OcxPrivacyConfig {
298
+ /**
299
+ * Mask stored account emails before they leave the proxy. Omitted or `true` is the historical
300
+ * behaviour and the default.
301
+ *
302
+ * Setting this to `false` is a real disclosure decision, not a display preference. Management
303
+ * is not always loopback — under `remoteGui` the unmasked address reaches every management
304
+ * principal that can reach the hub, not only someone sitting at the machine. The default
305
+ * therefore stays masked, and turning it off is an explicit opt-in by the operator who owns
306
+ * those accounts.
307
+ */
308
+ maskEmails?: boolean;
309
+ }
310
+
291
311
  export interface OcxClientConnectionConfig {
292
312
  serverUrl: string;
293
313
  managementUrl: string;
@@ -335,6 +355,8 @@ export interface OcxConfig {
335
355
  remoteGui?: OcxRemoteGuiConfig;
336
356
  /** Remote-hub client state. The admission secret is stored only in service-api-token. */
337
357
  client?: OcxClientConnectionConfig;
358
+ /** Operator-facing redaction policy for management and CLI projections. */
359
+ privacy?: OcxPrivacyConfig;
338
360
  /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
339
361
  emptyCompletionRetry?: boolean;
340
362
  /**
@@ -693,6 +715,12 @@ export interface OcxConfig {
693
715
  * non-loopback binds, whose admission token contract is unchanged.
694
716
  */
695
717
  codexDesktopAuthless?: boolean;
718
+ /**
719
+ * Opt into Codex-owned client compaction while keeping OpenCodex routing. On an authenticated
720
+ * loopback bind, inject the dedicated `opencodex` model provider instead of overriding the
721
+ * built-in `openai` provider, so Codex does not select native remote compaction. Default off.
722
+ */
723
+ codexClientCompaction?: boolean;
696
724
  /**
697
725
  * Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active
698
726
  * so Codex App can show old OpenAI chats and opencodex-created exec chats under its default
@@ -753,7 +781,7 @@ export interface OcxConfig {
753
781
  */
754
782
  codexAccountPickerEnabled?: boolean;
755
783
  /**
756
- * Show the GPT-5.3-Codex-Spark weekly window on Codex quota surfaces. Default false.
784
+ * Show the GPT-5.3-Codex-Spark 5-hour and weekly windows on Codex quota surfaces. Default false.
757
785
  *
758
786
  * Spark is a single-model window that reads 0% for most operators, and on a multi-account
759
787
  * pool it doubles the bar count for information almost nobody acts on. Hidden by default and
@@ -795,6 +823,20 @@ export interface OcxConfig {
795
823
  * that work today — on Azure and custom Responses gateways as well, whose limits are unknown.
796
824
  */
797
825
  maxUpstreamBodyBytes?: number;
826
+ /**
827
+ * Opt-in ceiling, in bytes, on a decompressed INBOUND data-plane request body (#3573).
828
+ *
829
+ * Omitted or 0 = the built-in 256 MiB default. The lever exists because a session on the
830
+ * 922k-token opt-in window serializes its full history past that default, and the request
831
+ * that crosses it is Codex's own remote-compaction request — so the session hits 413 on the
832
+ * one operation that would have shrunk it and cannot recover.
833
+ *
834
+ * Bounded on purpose. `resolveInboundBodyLimitBytes()` clamps to
835
+ * [1 MiB, 512 MiB]; an unbounded inbound cap is a memory DoS because the reader materializes
836
+ * the body several times over. The Bun listener's own `maxRequestBodySize` is fixed when the
837
+ * server starts, so raising this takes effect on restart.
838
+ */
839
+ maxInboundBodyBytes?: number;
798
840
  /**
799
841
  * Opt-in Anthropic OAuth PROACTIVE routing (#294). Default OFF.
800
842
  * Sticky session affinity; new sessions may pick lowest known 5h usage.
@@ -86,6 +86,57 @@ export interface RateLimitRetryPolicy {
86
86
  respectRetryAfter?: boolean;
87
87
  }
88
88
 
89
+ /**
90
+ * Backend ids admitted by `providers.<name>.webSearchBridge.backend`. Only `"ollama"` has a
91
+ * shipped executor; every other id is explicit-only and inert, the same contract the top-level
92
+ * `webSearchSidecar` uses for backends whose executor has not landed. Naming one of them keeps
93
+ * the bridge disarmed rather than silently falling back to a different search provider — in
94
+ * particular it never auto-selects a paid Luna or Exa search.
95
+ */
96
+ export const PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS = [
97
+ "ollama",
98
+ "openai",
99
+ "anthropic",
100
+ "xai",
101
+ "gemini",
102
+ "exa",
103
+ ] as const;
104
+
105
+ export type ProviderWebSearchBridgeBackend = typeof PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS[number];
106
+
107
+ /**
108
+ * Opt-in hosted-web-search bridge for a KEY-auth Responses passthrough provider
109
+ * (`providers.<name>.webSearchBridge`), default OFF (#3761).
110
+ *
111
+ * Codex always declares the hosted `{type:"web_search"}` tool. On the passthrough the proxy
112
+ * treats that as "the destination runs search itself" and relays it unchanged, which is true for
113
+ * the ChatGPT backend and for xAI but false for an OpenAI-shaped key gateway such as Ollama
114
+ * Cloud: the model answers with a `function_call` named `web_search` that nothing executes,
115
+ * and the undeclared-tool guard ends the turn. With this block enabled the proxy intercepts that
116
+ * call, runs the configured search backend itself, feeds the result back upstream, and shows
117
+ * Codex a hosted `web_search_call` cell.
118
+ *
119
+ * Never armed for `authMode: "forward"` (ChatGPT) or for a provider that executes hosted search
120
+ * upstream; see `planPassthroughWebSearchBridge` in `src/web-search/passthrough-bridge.ts`.
121
+ */
122
+ export interface ProviderWebSearchBridgeConfig {
123
+ /** Master switch. Absent or false keeps today's relay-and-fail behavior exactly. */
124
+ enabled?: boolean;
125
+ /** Which executor runs the search. Absent disarms the bridge; there is no implicit default. */
126
+ backend?: ProviderWebSearchBridgeBackend;
127
+ /** Searches executed per turn before the bridge refuses further ones (1..10, default 3). */
128
+ maxSearches?: number;
129
+ /** Per-search deadline in milliseconds (1000..600000, default 60000). */
130
+ timeoutMs?: number;
131
+ /**
132
+ * Absolute search-API URL. Required to use the `ollama` backend against anything other than
133
+ * the canonical `https://ollama.com` origin, which is the only origin derived automatically.
134
+ * The bridge sends the PROVIDER's own API key to this URL, so an operator setting it is
135
+ * authorizing that key for this destination.
136
+ */
137
+ endpoint?: string;
138
+ }
139
+
89
140
  /**
90
141
  * User-configured display price for one model (USD per 1M tokens).
91
142
  * Mirrors the `Cost4` shape used by the usage cost estimator; structurally
@@ -550,6 +601,11 @@ export interface OcxProviderConfig {
550
601
  * SSE/JSON; raw inspection state remains authoritative.
551
602
  */
552
603
  responsesSnapshotRepair?: boolean;
604
+ /**
605
+ * Opt-in hosted-web-search bridge for this KEY-auth Responses passthrough provider (#3761).
606
+ * Absent or disabled leaves the passthrough byte-identical to today.
607
+ */
608
+ webSearchBridge?: ProviderWebSearchBridgeConfig;
553
609
  /**
554
610
  * Provider-wide mapping from Codex effort labels to upstream `reasoning_effort` values.
555
611
  * Map a label to the reserved value `"__omit__"` to send no reasoning field at all for that
package/src/types.ts CHANGED
@@ -101,6 +101,8 @@ export type {
101
101
  ResponsesItemIdRepairConfig,
102
102
  RateLimitRetryPolicy,
103
103
  TransientRetryPolicy,
104
+ ProviderWebSearchBridgeBackend,
105
+ ProviderWebSearchBridgeConfig,
104
106
  ProviderCostOverlay,
105
107
  RequestPacingRule,
106
108
  ProviderRequestPacingConfig,
@@ -111,6 +113,8 @@ export type {
111
113
  OcxProviderConfig,
112
114
  } from "./types/provider";
113
115
 
116
+ export { PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS } from "./types/provider";
117
+
114
118
  export type {
115
119
  CodexAccount,
116
120
  CodexAccountCredentials,
package/src/usage/log.ts CHANGED
@@ -172,6 +172,10 @@ export interface PersistedUsageEntry {
172
172
  closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow";
173
173
  /** Already redacted + capped at capture (request-log.ts redactSecretString().slice(0,500)). */
174
174
  upstreamError?: string;
175
+ /** Where the terminal/failure was observed; absent on historic rows. */
176
+ transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
177
+ /** Whether the terminal came from upstream or a proxy-generated tail. */
178
+ terminalSource?: "upstream" | "synthetic";
175
179
  /**
176
180
  * Bounded route-decision trace (RI-01): why this provider/model/account was
177
181
  * selected. Additive field; old rows without it parse unchanged. Never
@@ -217,6 +221,22 @@ export function isKnownInboundProtocol(value: unknown): value is NonNullable<Per
217
221
  return typeof value === "string" && KNOWN_INBOUND_PROTOCOLS.has(value as NonNullable<PersistedUsageEntry["inboundProtocol"]>);
218
222
  }
219
223
 
224
+ const KNOWN_TRANSPORT_PHASES = new Set<NonNullable<PersistedUsageEntry["transportPhase"]>>([
225
+ "pre_headers", "mid_stream", "terminal_sse",
226
+ ]);
227
+
228
+ export function isKnownTransportPhase(value: unknown): value is NonNullable<PersistedUsageEntry["transportPhase"]> {
229
+ return typeof value === "string" && KNOWN_TRANSPORT_PHASES.has(value as NonNullable<PersistedUsageEntry["transportPhase"]>);
230
+ }
231
+
232
+ const KNOWN_TERMINAL_SOURCES = new Set<NonNullable<PersistedUsageEntry["terminalSource"]>>([
233
+ "upstream", "synthetic",
234
+ ]);
235
+
236
+ export function isKnownTerminalSource(value: unknown): value is NonNullable<PersistedUsageEntry["terminalSource"]> {
237
+ return typeof value === "string" && KNOWN_TERMINAL_SOURCES.has(value as NonNullable<PersistedUsageEntry["terminalSource"]>);
238
+ }
239
+
220
240
  export function usageLogPath(configDir?: string): string {
221
241
  return join(configDir ?? getConfigDir(), "usage.jsonl");
222
242
  }
@@ -511,6 +531,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
511
531
  const responseServiceTier = sanitizeLogMetadataString(entry.responseServiceTier);
512
532
  const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom);
513
533
  const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
534
+ const transportPhase = isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : undefined;
535
+ const terminalSource = isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : undefined;
514
536
  const routeDecision = entry.routeDecision
515
537
  ? normalizeRouteDecisionTrace(entry.routeDecision)
516
538
  : undefined;
@@ -579,6 +601,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
579
601
  ...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}),
580
602
  ...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}),
581
603
  ...(Array.isArray(entry.attempts) ? { attempts } : {}),
604
+ ...(transportPhase ? { transportPhase } : {}),
605
+ ...(terminalSource ? { terminalSource } : {}),
582
606
  ...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
583
607
  ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
584
608
  ...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
@@ -200,6 +200,7 @@ export async function describeImageAnthropic(
200
200
  const res = await fetchWithResetRetry(
201
201
  recovery => fetch(`${base}/v1/messages`, applyUpstreamRecoveryInit({
202
202
  method: "POST",
203
+ redirect: "manual",
203
204
  headers,
204
205
  body: JSON.stringify(body),
205
206
  signal: linkedSignal.signal,
@@ -210,6 +210,7 @@ export async function runAnthropicWebSearch(
210
210
  // ignored a bare `Connection: close` (oven-sh/bun#20492).
211
211
  recovery => fetch(url, applyUpstreamRecoveryInit({
212
212
  method: "POST",
213
+ redirect: "manual",
213
214
  headers,
214
215
  body: JSON.stringify(body),
215
216
  signal: linkedSignal.signal,
@@ -478,6 +478,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
478
478
  // replay on this leg eligible for the same dead socket the reset came from.
479
479
  return requestFetch(request.url, applyUpstreamRecoveryInit({
480
480
  method: request.method,
481
+ redirect: "manual",
481
482
  headers: h,
482
483
  body: request.body,
483
484
  signal: headerDeadline.signal,
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Execute ONE web search via the Ollama web-search API — the executor behind
3
+ * `providers.<name>.webSearchBridge.backend: "ollama"` (#3761).
4
+ *
5
+ * Documented contract: POST <origin>/api/web_search with a bearer key returns
6
+ * {results: [{title, url, content}]}; `max_results` defaults to 5 and caps at 10
7
+ * (https://docs.ollama.com/web-search). Like Exa, this lane returns ranked results
8
+ * rather than a prose answer, so the outcome text is a digest the routed model
9
+ * synthesizes from.
10
+ *
11
+ * The key is the PROVIDER's own API key: an operator who enables the bridge is reusing
12
+ * their Ollama Cloud route key on a second Ollama endpoint. That is why the planner
13
+ * refuses to derive a non-canonical origin on its own.
14
+ *
15
+ * Never throws; every error string passes redactSecretString and scrubs the literal key.
16
+ */
17
+ import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry";
18
+ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
19
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
20
+ import { sidecarEnter } from "../lib/sidecar-tracker";
21
+ import { redactSecretString } from "../lib/redact";
22
+ import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse";
23
+ import type { SidecarOutcome } from "./executor";
24
+
25
+ /** Documented ceiling for the API's own `max_results`; a larger value is rejected upstream. */
26
+ export const OLLAMA_WEB_SEARCH_MAX_RESULTS = 5;
27
+ const OLLAMA_SNIPPET_CHARS = 1000;
28
+
29
+ function isRec(value: unknown): value is Record<string, unknown> {
30
+ return !!value && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ export async function runOllamaWebSearch(
34
+ query: string,
35
+ apiKey: string,
36
+ endpoint: string,
37
+ timeoutMs: number,
38
+ abortSignal?: AbortSignal,
39
+ ): Promise<SidecarOutcome> {
40
+ if (!apiKey) {
41
+ return { text: "", sources: [], error: "ollama web-search backend selected without a provider apiKey" };
42
+ }
43
+ // The executor KNOWS the secret, so pattern-based redaction is not enough: scrub the
44
+ // literal value before anything derived from an upstream body is returned.
45
+ const scrub = (value: string) =>
46
+ redactSecretString(value.split(apiKey).join("[redacted-provider-key]"));
47
+ const linkedSignal = signalWithTimeout(timeoutMs, abortSignal);
48
+ const sidecarExit = sidecarEnter("web-search");
49
+ const startedAt = Date.now();
50
+ try {
51
+ const res = await fetchWithResetRetry(
52
+ recovery => fetch(endpoint, applyUpstreamRecoveryInit({
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
55
+ body: JSON.stringify({ query, max_results: OLLAMA_WEB_SEARCH_MAX_RESULTS }),
56
+ signal: linkedSignal.signal,
57
+ // Bun forwards custom headers across redirects, so a redirect would leak the key.
58
+ redirect: "manual",
59
+ }, recovery)),
60
+ { abortSignal: linkedSignal.signal, label: "ollama-web-search-bridge" },
61
+ );
62
+ const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
63
+ try {
64
+ let bounded: Awaited<ReturnType<typeof readBoundedResponseBytes>> | null = null;
65
+ try {
66
+ bounded = await readBoundedResponseBytes(res, {
67
+ maxBytes: MAX_SIDECAR_RESPONSE_BYTES,
68
+ signal: linkedSignal.signal,
69
+ });
70
+ } catch {
71
+ const reason = linkedSignal.signal.reason;
72
+ if (linkedSignal.signal.aborted && reason instanceof Error && reason.name === "TimeoutError") {
73
+ throw reason;
74
+ }
75
+ // A body-read failure degrades to the status-only outcome below.
76
+ }
77
+ if (bounded?.oversized) {
78
+ const prefix = res.ok ? "ollama web-search response" : `ollama web-search HTTP ${res.status} response`;
79
+ return { text: "", sources: [], error: `${prefix} exceeded byte bound` };
80
+ }
81
+ const text = bounded ? new TextDecoder().decode(bounded.bytes) : "";
82
+ if (!res.ok) {
83
+ // Scrub BEFORE truncating: slicing first can cut the literal key at the boundary
84
+ // and leave an unscrubbable prefix in the surviving text.
85
+ return { text: "", sources: [], error: `ollama web-search HTTP ${res.status}: ${scrub(text).slice(0, 200)}` };
86
+ }
87
+ let payload: unknown = null;
88
+ try {
89
+ payload = JSON.parse(text);
90
+ } catch {
91
+ // The mapper owns the stable malformed/empty JSON outcome.
92
+ }
93
+ return mapOllamaSearchResponse(payload);
94
+ } finally {
95
+ detachBodyGuard();
96
+ }
97
+ } catch (error) {
98
+ const kind = error instanceof Error && error.name === "TimeoutError" ? "timeout" : "connect_error";
99
+ console.warn(`[web-search] ollama bridge ${kind} (${Date.now() - startedAt}ms)`);
100
+ return { text: "", sources: [], error: scrub(error instanceof Error ? error.message : String(error)) };
101
+ } finally {
102
+ sidecarExit();
103
+ linkedSignal.cleanup();
104
+ }
105
+ }
106
+
107
+ /** Map an Ollama /api/web_search payload to a digest the routed model can synthesize from. */
108
+ export function mapOllamaSearchResponse(payload: unknown): SidecarOutcome {
109
+ if (!isRec(payload) || !Array.isArray(payload.results)) {
110
+ return { text: "", sources: [], error: "ollama web-search returned a non-JSON or shapeless body" };
111
+ }
112
+ const sources: WebSearchSource[] = [];
113
+ const lines: string[] = [];
114
+ const seen = new Set<string>();
115
+ for (const result of payload.results) {
116
+ if (!isRec(result) || typeof result.url !== "string" || result.url.length === 0) continue;
117
+ if (seen.has(result.url)) continue;
118
+ seen.add(result.url);
119
+ const title = typeof result.title === "string" && result.title.length > 0 ? result.title : result.url;
120
+ sources.push({ url: result.url, ...(title !== result.url ? { title } : {}) });
121
+ const snippet = typeof result.content === "string" ? result.content.trim().slice(0, OLLAMA_SNIPPET_CHARS) : "";
122
+ lines.push(`- ${title}: ${snippet || "(no excerpt)"} [${result.url}]`);
123
+ }
124
+ if (lines.length === 0) return { text: "", sources: [], error: "ollama web-search returned no results" };
125
+ return { text: `Search results:\n${lines.join("\n")}`, sources };
126
+ }
127
+