@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.
- package/AGENTS_INSTALL.md +9 -1
- package/README.md +11 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +29 -2
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +113 -26
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +96 -7
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +144 -11
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +128 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +71 -15
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +59 -0
- package/src/generated/compatibility-version.json +178 -114
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +140 -1
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +30 -13
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +17 -7
- package/src/server/images.ts +3 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +5 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +33 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +15 -1
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +16 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +285 -57
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +18 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +128 -9
- package/src/storage/cleanup.ts +77 -45
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +43 -1
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
package/src/types/config.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/types/provider.ts
CHANGED
|
@@ -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,
|
package/src/web-search/loop.ts
CHANGED
|
@@ -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
|
+
|