@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +57 -0
- package/src/adapters/anthropic-image-normalize.ts +28 -1
- package/src/adapters/anthropic.ts +68 -6
- package/src/adapters/base.ts +8 -0
- package/src/adapters/coding-agent/protocol.ts +41 -16
- package/src/adapters/cursor/cursor-errors.ts +1 -1
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/native-exec-fs.ts +10 -10
- package/src/adapters/cursor/native-exec-network.ts +2 -2
- package/src/adapters/cursor/native-exec-shell.ts +13 -12
- package/src/adapters/cursor/native-exec.ts +51 -10
- package/src/adapters/cursor/policy-error.ts +75 -0
- package/src/adapters/cursor/protobuf-request.ts +105 -1
- package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
- package/src/adapters/devin/live-models.ts +33 -2
- package/src/adapters/google-wire-compiler.ts +8 -0
- package/src/adapters/google.ts +46 -0
- package/src/adapters/input-media-guard.ts +45 -0
- package/src/adapters/kiro/adapter.ts +8 -0
- package/src/adapters/kiro/payload.ts +28 -6
- package/src/adapters/kiro-events.ts +25 -6
- package/src/adapters/kiro-images.ts +30 -0
- package/src/adapters/kiro-retry.ts +8 -0
- package/src/adapters/openai-chat.ts +33 -4
- package/src/adapters/openai-responses.ts +26 -0
- package/src/adapters/registry.ts +4 -0
- package/src/bridge.ts +163 -116
- package/src/chat/image-parts.ts +151 -0
- package/src/chat/inbound.ts +70 -33
- package/src/cli/connect.ts +30 -9
- package/src/cli/dispatch.ts +7 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/runtime-api.ts +25 -0
- package/src/cli/status.ts +21 -19
- package/src/cli/system-restart-client.ts +25 -0
- package/src/clients/config-export.ts +14 -4
- package/src/codex/app-server-processes.ts +25 -0
- package/src/codex/auth-context.ts +8 -0
- package/src/codex/autostart-health.ts +36 -2
- package/src/codex/catalog/provider-fetch.ts +41 -0
- package/src/codex/catalog-auto-refresh.ts +182 -0
- package/src/codex/catalog-refresh-status.ts +93 -0
- package/src/codex/history-provider.ts +55 -0
- package/src/codex/model-entitlements.ts +78 -0
- package/src/codex/native-profile-processes.ts +114 -15
- package/src/codex/prompt-text-probe.ts +274 -41
- package/src/codex/routing-adoption.ts +189 -0
- package/src/codex/routing.ts +520 -48
- package/src/codex/runtime.ts +249 -7
- package/src/combos/failover.ts +45 -0
- package/src/config.ts +124 -4
- package/src/generated/compatibility-version.json +110 -74
- package/src/generated/model-metadata.ts +1 -0
- package/src/lib/request-execution-budget.ts +202 -0
- package/src/lib/upstream-retry.ts +95 -8
- package/src/lib/workflow-budget.ts +172 -0
- package/src/oauth/devin.ts +57 -12
- package/src/providers/quota.ts +37 -6
- package/src/providers/registry.ts +53 -6
- package/src/responses/input-media.ts +65 -0
- package/src/responses/parser-content.ts +42 -0
- package/src/responses/schema.ts +12 -2
- package/src/server/audio-live.ts +1 -2
- package/src/server/audio-transcriptions.ts +1 -2
- package/src/server/auth-cors.ts +1 -1
- package/src/server/background-lifecycle.ts +18 -0
- package/src/server/chat-completions.ts +23 -8
- package/src/server/chat-native.ts +17 -17
- package/src/server/index.ts +24 -0
- package/src/server/management/request-history-routes.ts +5 -0
- package/src/server/request-log.ts +8 -2
- package/src/server/responses/compact.ts +51 -3
- package/src/server/responses/core.ts +238 -28
- package/src/server/search.ts +7 -9
- package/src/types/config.ts +51 -6
- package/src/usage/log.ts +37 -0
- package/src/vision/eligibility.ts +37 -4
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +45 -10
- package/src/web-search/alpha-search.ts +324 -0
- package/src/web-search/index.ts +13 -22
- package/src/web-search/passthrough-bridge.ts +195 -22
- package/src/web-search/sidecar-providers.ts +22 -0
package/src/usage/log.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, type Hash } from "node:crypto";
|
|
|
2
2
|
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getConfigDir } from "../config";
|
|
5
|
+
import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing";
|
|
5
6
|
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
|
|
6
7
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
7
8
|
import { sanitizeLogMetadataString } from "../lib/redact";
|
|
@@ -187,6 +188,13 @@ export interface PersistedUsageEntry {
|
|
|
187
188
|
transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
|
|
188
189
|
/** Whether the terminal came from upstream or a proxy-generated tail. */
|
|
189
190
|
terminalSource?: "upstream" | "synthetic";
|
|
191
|
+
/**
|
|
192
|
+
* What happened to this request's Codex pool binding, and why (#4546). A move discards the
|
|
193
|
+
* prompt-cache prefix warmed on the previous account, so it is recorded as an event rather
|
|
194
|
+
* than left to be inferred from account labels across rows. Additive; older rows omit it.
|
|
195
|
+
*/
|
|
196
|
+
affinity?: CodexAffinityMove;
|
|
197
|
+
affinityReason?: CodexAffinityReason;
|
|
190
198
|
/**
|
|
191
199
|
* Bounded route-decision trace (RI-01): why this provider/model/account was
|
|
192
200
|
* selected. Additive field; old rows without it parse unchanged. Never
|
|
@@ -248,6 +256,28 @@ export function isKnownTerminalSource(value: unknown): value is NonNullable<Pers
|
|
|
248
256
|
return typeof value === "string" && KNOWN_TERMINAL_SOURCES.has(value as NonNullable<PersistedUsageEntry["terminalSource"]>);
|
|
249
257
|
}
|
|
250
258
|
|
|
259
|
+
/**
|
|
260
|
+
* The persisted entry is built by an explicit whitelist, so a field the writer sets but this
|
|
261
|
+
* normalizer does not name is dropped without a word. #4592 added the affinity record at the
|
|
262
|
+
* call site and it never reached disk for exactly that reason.
|
|
263
|
+
*/
|
|
264
|
+
const KNOWN_AFFINITY_MOVES = new Set<NonNullable<PersistedUsageEntry["affinity"]>>([
|
|
265
|
+
"reused", "held", "detour", "rebound", "new_bind", "cleared",
|
|
266
|
+
]);
|
|
267
|
+
const KNOWN_AFFINITY_REASONS = new Set<NonNullable<PersistedUsageEntry["affinityReason"]>>([
|
|
268
|
+
"healthy", "quota_headroom", "quota_refusal", "transient", "transient_hold_expired",
|
|
269
|
+
"unusable", "paused", "plan_excluded", "cooldown", "quota_avoided", "generation",
|
|
270
|
+
"expired", "model_lane",
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
export function isKnownAffinityMove(value: unknown): value is NonNullable<PersistedUsageEntry["affinity"]> {
|
|
274
|
+
return typeof value === "string" && KNOWN_AFFINITY_MOVES.has(value as NonNullable<PersistedUsageEntry["affinity"]>);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function isKnownAffinityReason(value: unknown): value is NonNullable<PersistedUsageEntry["affinityReason"]> {
|
|
278
|
+
return typeof value === "string" && KNOWN_AFFINITY_REASONS.has(value as NonNullable<PersistedUsageEntry["affinityReason"]>);
|
|
279
|
+
}
|
|
280
|
+
|
|
251
281
|
export function usageLogPath(configDir?: string): string {
|
|
252
282
|
return join(configDir ?? getConfigDir(), "usage.jsonl");
|
|
253
283
|
}
|
|
@@ -590,6 +620,11 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
|
|
|
590
620
|
const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
|
|
591
621
|
const transportPhase = isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : undefined;
|
|
592
622
|
const terminalSource = isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : undefined;
|
|
623
|
+
const affinity = isKnownAffinityMove(entry.affinity) ? entry.affinity : undefined;
|
|
624
|
+
// A reason without a move describes nothing, so it is only kept alongside one.
|
|
625
|
+
const affinityReason = affinity !== undefined && isKnownAffinityReason(entry.affinityReason)
|
|
626
|
+
? entry.affinityReason
|
|
627
|
+
: undefined;
|
|
593
628
|
const routeDecision = entry.routeDecision
|
|
594
629
|
? normalizeRouteDecisionTrace(entry.routeDecision)
|
|
595
630
|
: undefined;
|
|
@@ -660,6 +695,8 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
|
|
|
660
695
|
...(Array.isArray(entry.attempts) ? { attempts } : {}),
|
|
661
696
|
...(transportPhase ? { transportPhase } : {}),
|
|
662
697
|
...(terminalSource ? { terminalSource } : {}),
|
|
698
|
+
...(affinity ? { affinity } : {}),
|
|
699
|
+
...(affinityReason ? { affinityReason } : {}),
|
|
663
700
|
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
|
|
664
701
|
...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
|
|
665
702
|
...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
|
|
@@ -26,6 +26,7 @@ import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../gen
|
|
|
26
26
|
import { nativeInputModalities } from "../codex/catalog/metadata";
|
|
27
27
|
import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
|
|
28
28
|
import { enrichProviderFromRegistry } from "../providers/derive";
|
|
29
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination";
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* The wire protocols `planVisionSidecar` can dispatch to (#2188 roadmap 170
|
|
@@ -113,7 +114,28 @@ function enrichedProviderForVision(
|
|
|
113
114
|
if (cached) return cached;
|
|
114
115
|
const configured = config.providers?.[providerName];
|
|
115
116
|
if (!configured) return undefined;
|
|
116
|
-
|
|
117
|
+
// Runtime providers may carry non-cloneable hooks (for example a provider-scoped fetch).
|
|
118
|
+
// Enrichment mutates top-level fields but does not mutate nested provider values in place, so
|
|
119
|
+
// a shallow copy plus private copies of the vision-capability containers is sufficient and
|
|
120
|
+
// avoids both config mutation and structuredClone(DataCloneError) on runtime functions.
|
|
121
|
+
const enriched: OcxProviderConfig = {
|
|
122
|
+
...configured,
|
|
123
|
+
...(configured.noVisionModels ? { noVisionModels: [...configured.noVisionModels] } : {}),
|
|
124
|
+
...(configured.modelInputModalities ? {
|
|
125
|
+
modelInputModalities: Object.fromEntries(
|
|
126
|
+
Object.entries(configured.modelInputModalities).map(([id, modalities]) => [id, [...modalities]]),
|
|
127
|
+
),
|
|
128
|
+
} : {}),
|
|
129
|
+
...(configured.modelCapabilities ? {
|
|
130
|
+
modelCapabilities: Object.fromEntries(Object.entries(configured.modelCapabilities).map(([id, capability]) => [
|
|
131
|
+
id,
|
|
132
|
+
{
|
|
133
|
+
...capability,
|
|
134
|
+
...(capability.inputModalities ? { inputModalities: [...capability.inputModalities] } : {}),
|
|
135
|
+
},
|
|
136
|
+
])),
|
|
137
|
+
} : {}),
|
|
138
|
+
};
|
|
117
139
|
enrichProviderFromRegistry(providerName, enriched);
|
|
118
140
|
cache.set(providerName, enriched);
|
|
119
141
|
return enriched;
|
|
@@ -156,9 +178,10 @@ function modelAcceptsImageInputWithCache(
|
|
|
156
178
|
): boolean | undefined {
|
|
157
179
|
if (candidate.native === true || (candidate.provider === "openai" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(candidate.id))) {
|
|
158
180
|
const nativeProvider = enrichedProviderForVision(config, candidate.provider, cache);
|
|
159
|
-
if (nativeProvider && isModelVisionSidecarConsumer(
|
|
160
|
-
|
|
161
|
-
|
|
181
|
+
if (nativeProvider && isModelVisionSidecarConsumer(nativeProvider, candidate.id)) return false;
|
|
182
|
+
const declared = Object.hasOwn(nativeProvider?.modelCapabilities ?? {}, candidate.id)
|
|
183
|
+
? nativeProvider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
|
|
184
|
+
if (declared !== undefined) return declared.includes("image");
|
|
162
185
|
return advertisesImageInput(nativeInputModalities(candidate.id)) ?? true;
|
|
163
186
|
}
|
|
164
187
|
if (isVisionSidecarConsumerWithCache(config, candidate.provider, candidate.id, cache)) return false;
|
|
@@ -166,6 +189,16 @@ function modelAcceptsImageInputWithCache(
|
|
|
166
189
|
const declared = Object.hasOwn(provider?.modelCapabilities ?? {}, candidate.id)
|
|
167
190
|
? provider?.modelCapabilities?.[candidate.id]?.inputModalities : undefined;
|
|
168
191
|
if (declared !== undefined) return declared.includes("image");
|
|
192
|
+
const configuredModalities = provider ? modelRecordValue(provider.modelInputModalities, candidate.id) : undefined;
|
|
193
|
+
const fromConfiguredModalities = advertisesImageInput(configuredModalities);
|
|
194
|
+
if (fromConfiguredModalities !== undefined) return fromConfiguredModalities;
|
|
195
|
+
const canonicalCodex = candidate.provider === "openai"
|
|
196
|
+
&& provider !== undefined
|
|
197
|
+
&& isCanonicalOpenAiForwardProvider(provider);
|
|
198
|
+
if (canonicalCodex) {
|
|
199
|
+
const fromCodexBackend = metadataImageInput("openai-codex", candidate.id);
|
|
200
|
+
if (fromCodexBackend !== undefined) return fromCodexBackend;
|
|
201
|
+
}
|
|
169
202
|
const fromRow = advertisesImageInput(candidate.inputModalities);
|
|
170
203
|
if (fromRow !== undefined) return fromRow;
|
|
171
204
|
return metadataImageInput(candidate.provider, candidate.id);
|
package/src/vision/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
resolveEffectiveVisionModel,
|
|
36
36
|
shouldResolveOpenAiVisionSidecar,
|
|
37
37
|
planVisionSidecar,
|
|
38
|
+
requiresVisionPreprocessing,
|
|
38
39
|
} from "./plan";
|
|
39
40
|
export type { AnthropicVisionProvider, VisionPlan } from "./plan";
|
|
40
41
|
export { stripImagesInPlace } from "./image-rewrite";
|
package/src/vision/plan.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OcxConfig, OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
2
|
-
import type
|
|
2
|
+
import { modelRecordValue, type VisionReasoningEffort } from "../reasoning-effort";
|
|
3
3
|
import type { VisionSettings } from "./describe";
|
|
4
4
|
import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
|
|
5
5
|
import type { CodexAuthPolicyConfig } from "../codex/auth-context";
|
|
@@ -92,6 +92,34 @@ function messagesHaveImage(parsed: OcxParsedRequest): boolean {
|
|
|
92
92
|
carriesImages(m.role) && Array.isArray(m.content) && (m.content as OcxContentPart[]).some(p => p.type === "image"));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Direct-image admission for a routed target. Returns true when capability evidence proves the
|
|
97
|
+
* target cannot accept image input, so the caller must describe or strip the image first.
|
|
98
|
+
* Explicit text-only config, an explicit per-model modality list without `image`, and
|
|
99
|
+
* proven-negative registry/vendor metadata each require the vision preprocessor. A genuinely
|
|
100
|
+
* unknown custom model is NOT guessed blind: it keeps the established pass-through behaviour.
|
|
101
|
+
* The provider-only fallback keeps legacy unit callers stable; production dispatch always
|
|
102
|
+
* supplies providerName so the complete capability chain is consulted.
|
|
103
|
+
*/
|
|
104
|
+
export function requiresVisionPreprocessing(
|
|
105
|
+
config: Pick<OcxConfig, "providers">,
|
|
106
|
+
provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities" | "modelCapabilities">,
|
|
107
|
+
modelId: string,
|
|
108
|
+
providerName?: string,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (isModelTextOnly(provider, modelId)) return true;
|
|
111
|
+
const runtimeDeclared = Object.hasOwn(provider.modelCapabilities ?? {}, modelId)
|
|
112
|
+
? provider.modelCapabilities?.[modelId]?.inputModalities
|
|
113
|
+
: undefined;
|
|
114
|
+
if (runtimeDeclared !== undefined) return !runtimeDeclared.includes("image");
|
|
115
|
+
const runtimeModalities = modelRecordValue(provider.modelInputModalities, modelId);
|
|
116
|
+
if (Array.isArray(runtimeModalities) && runtimeModalities.length > 0) {
|
|
117
|
+
return !runtimeModalities.includes("image");
|
|
118
|
+
}
|
|
119
|
+
if (!providerName) return false;
|
|
120
|
+
return modelAcceptsImageInput(config, { provider: providerName, id: modelId }) === false;
|
|
121
|
+
}
|
|
122
|
+
|
|
95
123
|
/** Shared by auth admission and planning so a routed describer never borrows OpenAI auth. */
|
|
96
124
|
function usableRoutedVisionModel(config: OcxConfig): string | undefined {
|
|
97
125
|
const cfg = config.visionSidecar;
|
|
@@ -109,10 +137,11 @@ function usableRoutedVisionModel(config: OcxConfig): string | undefined {
|
|
|
109
137
|
export function shouldResolveOpenAiVisionSidecar(
|
|
110
138
|
config: OcxConfig,
|
|
111
139
|
provider: OcxProviderConfig,
|
|
112
|
-
|
|
113
|
-
|
|
140
|
+
modelId: string,
|
|
141
|
+
parsed: OcxParsedRequest,
|
|
142
|
+
providerName?: string,
|
|
114
143
|
): boolean {
|
|
115
|
-
if (!
|
|
144
|
+
if (!requiresVisionPreprocessing(config, provider, modelId, providerName) || !messagesHaveImage(parsed)) return false;
|
|
116
145
|
const cfg = config.visionSidecar ?? {};
|
|
117
146
|
if (cfg.enabled === false) return false;
|
|
118
147
|
if (usableRoutedVisionModel(config)) return false;
|
|
@@ -132,10 +161,12 @@ export interface VisionPlan {
|
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
/**
|
|
135
|
-
* Decide whether the vision sidecar should pre-describe images for this request
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
164
|
+
* Decide whether the vision sidecar should pre-describe images for this request. Raw image
|
|
165
|
+
* delivery is capability-driven: targets proven text-only are preprocessed, targets proven
|
|
166
|
+
* image-capable bypass this planner, and genuinely unknown custom targets retain legacy behavior.
|
|
167
|
+
* The request must
|
|
168
|
+
* carry an image, the sidecar must be enabled, and the selected backend must be dispatchable.
|
|
169
|
+
* Returns undefined otherwise; the caller strips images before any unverified upstream send.
|
|
139
170
|
*/
|
|
140
171
|
export function planVisionSidecar(
|
|
141
172
|
config: OcxConfig,
|
|
@@ -143,9 +174,13 @@ export function planVisionSidecar(
|
|
|
143
174
|
modelId: string,
|
|
144
175
|
parsed: OcxParsedRequest,
|
|
145
176
|
openAiSidecar?: ResolvedOpenAiForwardSidecar,
|
|
146
|
-
options: {
|
|
177
|
+
options: {
|
|
178
|
+
admission?: Pick<DataPlaneAdmission, "source">;
|
|
179
|
+
codexAuthPolicy?: CodexAuthPolicyConfig;
|
|
180
|
+
providerName?: string;
|
|
181
|
+
} = {},
|
|
147
182
|
): VisionPlan | undefined {
|
|
148
|
-
if (!
|
|
183
|
+
if (!requiresVisionPreprocessing(config, provider, modelId, options.providerName)) return undefined;
|
|
149
184
|
if (!messagesHaveImage(parsed)) return undefined;
|
|
150
185
|
const cfg = config.visionSidecar ?? {};
|
|
151
186
|
if (cfg.enabled === false) return undefined;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve Codex's built-in `/v1/alpha/search` when no ChatGPT forward provider exists.
|
|
3
|
+
*
|
|
4
|
+
* The ChatGPT relay in src/server/search.ts is byte-identical on purpose: the client talks an
|
|
5
|
+
* unpublished alpha envelope, and the only honest answer while a forward provider is configured
|
|
6
|
+
* is to copy bytes. That leaves API-key-only and routed-provider deployments with a 400 even
|
|
7
|
+
* when they already paid for a web-search sidecar. This module is the empty-candidates branch
|
|
8
|
+
* of that handler — it never runs when a forward provider is present, and it never borrows a
|
|
9
|
+
* different paid backend than the one the operator named.
|
|
10
|
+
*
|
|
11
|
+
* Do not import `./index.ts` from here. The barrel is still evaluating when search.ts loads,
|
|
12
|
+
* and pulling it in recreates the cycle sidecar-providers.ts exists to avoid.
|
|
13
|
+
*/
|
|
14
|
+
import { formatErrorResponse } from "../bridge";
|
|
15
|
+
import { redactSecretString } from "../lib/redact";
|
|
16
|
+
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
17
|
+
import type { OcxConfig, OcxProviderConfig, OcxWebSearchSidecarConfig } from "../types";
|
|
18
|
+
import { runAnthropicWebSearch } from "./anthropic-executor";
|
|
19
|
+
import { runExaWebSearch } from "./exa-executor";
|
|
20
|
+
import type { SidecarOutcome, SidecarSettings } from "./executor";
|
|
21
|
+
import { runGeminiWebSearch } from "./gemini-executor";
|
|
22
|
+
import {
|
|
23
|
+
findAnthropicSidecarProvider,
|
|
24
|
+
findGeminiSidecarProvider,
|
|
25
|
+
findXaiSidecarProvider,
|
|
26
|
+
resolveSidecarBackend,
|
|
27
|
+
xaiSearchOptionsFromConfig,
|
|
28
|
+
} from "./sidecar-providers";
|
|
29
|
+
import { safeWebSearchSources } from "./sources";
|
|
30
|
+
import { runXaiWebSearch } from "./xai-executor";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Same total-search budget the ChatGPT relay uses in src/server/search.ts. The sidecar loop's
|
|
34
|
+
* 60s default is a different contract (a helper turn beside a routed model); alpha/search is
|
|
35
|
+
* the whole request, so it keeps the relay's 200s ceiling unless config.search.timeoutMs says
|
|
36
|
+
* otherwise.
|
|
37
|
+
*/
|
|
38
|
+
const SEARCH_UPSTREAM_TIMEOUT_MS = 200_000;
|
|
39
|
+
/** Queries honored from one alpha/search body; the rest are ignored rather than billed. */
|
|
40
|
+
const MAX_QUERIES_PER_CALL = 3;
|
|
41
|
+
const MAX_QUERY_CHARS = 1_000;
|
|
42
|
+
const DEFAULT_REASONING = "low";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Search model each sidecar backend runs when the operator did not name one for THIS backend.
|
|
46
|
+
* Copied from the passthrough bridge's table on purpose: sending a ChatGPT slug to Anthropic
|
|
47
|
+
* is the failure that table exists to prevent, and alpha/search would reproduce it if it
|
|
48
|
+
* trusted `webSearchSidecar.model` unconditionally.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_BACKEND_MODELS = {
|
|
51
|
+
anthropic: "claude-sonnet-5",
|
|
52
|
+
xai: "grok-4.6",
|
|
53
|
+
gemini: "gemini-3.8-flash",
|
|
54
|
+
// Exa ignores model; the placeholder only satisfies SidecarSettings.
|
|
55
|
+
exa: "gpt-5.6-luna",
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
export type AlphaSearchSidecarBackend = keyof typeof DEFAULT_BACKEND_MODELS;
|
|
59
|
+
|
|
60
|
+
type ResolvedAlphaSearchSidecar =
|
|
61
|
+
| { backend: "anthropic"; providerName: string; provider: OcxProviderConfig }
|
|
62
|
+
| { backend: "xai"; providerName: string; provider: OcxProviderConfig }
|
|
63
|
+
| { backend: "gemini"; providerName: string; provider: OcxProviderConfig }
|
|
64
|
+
| { backend: "exa"; apiKey: string };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Why this path cannot serve the request, kept distinct from "nobody asked for it".
|
|
68
|
+
*
|
|
69
|
+
* The two refusals read identically to the operator but mean opposite things: `unconfigured` is
|
|
70
|
+
* a deployment that never named a backend, while `missing-credential` is one that named a
|
|
71
|
+
* backend the proxy cannot authenticate. Answering both with the ChatGPT-auth sentence is the
|
|
72
|
+
* behaviour the feature request called out — it tells an operator who already chose Exa to go
|
|
73
|
+
* set up ChatGPT OAuth, which is the one thing they were trying to avoid.
|
|
74
|
+
*/
|
|
75
|
+
export type AlphaSearchSidecarResolution =
|
|
76
|
+
| { status: "ready"; sidecar: ResolvedAlphaSearchSidecar }
|
|
77
|
+
| { status: "unconfigured" }
|
|
78
|
+
| { status: "missing-credential"; backend: AlphaSearchSidecarBackend };
|
|
79
|
+
|
|
80
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
81
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Only the operator's explicit sidecar backend can serve this path, and only with THAT
|
|
86
|
+
* backend's own credential. `openai` is the ChatGPT forward path, which is absent by the
|
|
87
|
+
* time we are here; auto-selecting a different paid backend from leftover keys is how an
|
|
88
|
+
* anthropic-named config would silently spend Exa.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveAlphaSearchSidecar(config: OcxConfig): AlphaSearchSidecarResolution {
|
|
91
|
+
const sidecar = config.webSearchSidecar;
|
|
92
|
+
// The master switch is the operator saying this sidecar may not run. planWebSearch honors it the
|
|
93
|
+
// same way, and ignoring it here would make `enabled: false` mean "off for the routed loop, on
|
|
94
|
+
// for alpha/search" — the one reading under which a disabled backend still spends money.
|
|
95
|
+
if (sidecar?.enabled === false) return { status: "unconfigured" };
|
|
96
|
+
const backend = resolveSidecarBackend(sidecar?.backend);
|
|
97
|
+
if (backend === "openai") return { status: "unconfigured" };
|
|
98
|
+
switch (backend) {
|
|
99
|
+
case "anthropic": {
|
|
100
|
+
const found = findAnthropicSidecarProvider(config);
|
|
101
|
+
return found
|
|
102
|
+
? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } }
|
|
103
|
+
: { status: "missing-credential", backend };
|
|
104
|
+
}
|
|
105
|
+
case "xai": {
|
|
106
|
+
const found = findXaiSidecarProvider(config);
|
|
107
|
+
return found
|
|
108
|
+
? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } }
|
|
109
|
+
: { status: "missing-credential", backend };
|
|
110
|
+
}
|
|
111
|
+
case "gemini": {
|
|
112
|
+
const found = findGeminiSidecarProvider(config);
|
|
113
|
+
return found
|
|
114
|
+
? { status: "ready", sidecar: { backend, providerName: found.providerName, provider: found.provider } }
|
|
115
|
+
: { status: "missing-credential", backend };
|
|
116
|
+
}
|
|
117
|
+
case "exa": {
|
|
118
|
+
const apiKey = sidecar?.exaApiKey;
|
|
119
|
+
return typeof apiKey === "string" && apiKey.length > 0
|
|
120
|
+
? { status: "ready", sidecar: { backend, apiKey } }
|
|
121
|
+
: { status: "missing-credential", backend };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pushQuery(queries: string[], value: unknown): void {
|
|
127
|
+
if (typeof value !== "string") return;
|
|
128
|
+
const trimmed = value.trim();
|
|
129
|
+
if (trimmed.length === 0 || queries.includes(trimmed)) return;
|
|
130
|
+
if (queries.length < MAX_QUERIES_PER_CALL) queries.push(trimmed.slice(0, MAX_QUERY_CHARS));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Codex's live-search client sends a Responses-shaped envelope whose primary operation is
|
|
135
|
+
* `commands.search_query: [{ q }]`. Top-level `query` / `q` / `search_query` strings are the
|
|
136
|
+
* fallback for tests and any thinner client; they are consulted only when the envelope form
|
|
137
|
+
* produced nothing usable, so a present-but-empty `search_query` array cannot hide a
|
|
138
|
+
* top-level query the operator actually sent.
|
|
139
|
+
*/
|
|
140
|
+
export function extractAlphaSearchQueries(body: unknown): string[] {
|
|
141
|
+
const queries: string[] = [];
|
|
142
|
+
if (!isRecord(body)) return queries;
|
|
143
|
+
const searchQuery = isRecord(body.commands) ? body.commands.search_query : undefined;
|
|
144
|
+
if (Array.isArray(searchQuery)) {
|
|
145
|
+
for (const entry of searchQuery) {
|
|
146
|
+
if (isRecord(entry)) pushQuery(queries, entry.q);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (queries.length === 0) {
|
|
150
|
+
pushQuery(queries, body.query);
|
|
151
|
+
if (queries.length === 0) pushQuery(queries, body.q);
|
|
152
|
+
if (queries.length === 0) pushQuery(queries, body.search_query);
|
|
153
|
+
}
|
|
154
|
+
return queries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function modelForAlphaSearchBackend(
|
|
158
|
+
backend: AlphaSearchSidecarBackend,
|
|
159
|
+
sidecar: Pick<OcxWebSearchSidecarConfig, "backend" | "model"> | undefined,
|
|
160
|
+
): string {
|
|
161
|
+
const backendDefault = DEFAULT_BACKEND_MODELS[backend];
|
|
162
|
+
if (resolveSidecarBackend(sidecar?.backend) !== backend) return backendDefault;
|
|
163
|
+
return sidecar?.model ?? backendDefault;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sidecarSettingsForAlphaSearch(
|
|
167
|
+
backend: AlphaSearchSidecarBackend,
|
|
168
|
+
config: OcxConfig,
|
|
169
|
+
): SidecarSettings {
|
|
170
|
+
const sidecar = config.webSearchSidecar;
|
|
171
|
+
return {
|
|
172
|
+
model: modelForAlphaSearchBackend(backend, sidecar),
|
|
173
|
+
reasoning: sidecar?.reasoning ?? DEFAULT_REASONING,
|
|
174
|
+
timeoutMs: config.search?.timeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runAlphaSearchQuery(
|
|
179
|
+
query: string,
|
|
180
|
+
resolved: ResolvedAlphaSearchSidecar,
|
|
181
|
+
settings: SidecarSettings,
|
|
182
|
+
config: OcxConfig,
|
|
183
|
+
signal?: AbortSignal,
|
|
184
|
+
): Promise<SidecarOutcome> {
|
|
185
|
+
switch (resolved.backend) {
|
|
186
|
+
case "anthropic":
|
|
187
|
+
return runAnthropicWebSearch(query, resolved.providerName, resolved.provider, settings, signal);
|
|
188
|
+
case "xai":
|
|
189
|
+
return runXaiWebSearch(
|
|
190
|
+
query,
|
|
191
|
+
resolved.providerName,
|
|
192
|
+
resolved.provider,
|
|
193
|
+
settings,
|
|
194
|
+
xaiSearchOptionsFromConfig(config.webSearchSidecar ?? {}),
|
|
195
|
+
signal,
|
|
196
|
+
);
|
|
197
|
+
case "gemini":
|
|
198
|
+
return runGeminiWebSearch(query, resolved.providerName, resolved.provider, settings, signal);
|
|
199
|
+
case "exa":
|
|
200
|
+
return runExaWebSearch(query, resolved.apiKey, settings, signal);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatAlphaSearchBody(text: string, sources: SidecarOutcome["sources"]): {
|
|
205
|
+
encrypted_output: null;
|
|
206
|
+
output: string;
|
|
207
|
+
results: Array<{ title: string; url: string }>;
|
|
208
|
+
} {
|
|
209
|
+
// Title falls back to the URL so the client always sees both fields; unsafe URLs are
|
|
210
|
+
// dropped entirely rather than echoed into `results`.
|
|
211
|
+
return {
|
|
212
|
+
encrypted_output: null,
|
|
213
|
+
output: text,
|
|
214
|
+
results: safeWebSearchSources(sources).map(source => ({
|
|
215
|
+
url: source.url,
|
|
216
|
+
title: source.title ?? source.url,
|
|
217
|
+
})),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const NO_FORWARD_PROVIDER_MESSAGE =
|
|
222
|
+
"Built-in web search needs a ChatGPT forward provider, but none is configured in opencodex. "
|
|
223
|
+
+ "Routed and OpenAI API-key providers cannot serve /v1/alpha/search. "
|
|
224
|
+
+ "Configure webSearchSidecar.backend (anthropic, xai, gemini, or exa) with that backend's credential instead.";
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What a named backend is missing, said in the operator's own terms.
|
|
228
|
+
*
|
|
229
|
+
* An operator who already chose a backend does not need to be told to configure ChatGPT auth —
|
|
230
|
+
* that answer is what the request asked this path to stop giving. They need to know which
|
|
231
|
+
* credential the backend they named could not find.
|
|
232
|
+
*/
|
|
233
|
+
function missingCredentialMessage(backend: AlphaSearchSidecarBackend): string {
|
|
234
|
+
const detail: Record<AlphaSearchSidecarBackend, string> = {
|
|
235
|
+
anthropic: "no usable stored Anthropic OAuth account was found",
|
|
236
|
+
xai: "no usable stored Grok OAuth account was found",
|
|
237
|
+
gemini: "no usable stored Antigravity OAuth account with a discovered project was found",
|
|
238
|
+
exa: "webSearchSidecar.exaApiKey is not set",
|
|
239
|
+
};
|
|
240
|
+
return "Built-in web search is configured to use the " + backend + " backend, but "
|
|
241
|
+
+ detail[backend] + ". Restore that backend's credential, or choose another "
|
|
242
|
+
+ "webSearchSidecar.backend. This request was not sent to any other backend.";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Run the named sidecar backend against an alpha/search body. Callers must already know there
|
|
247
|
+
* is no ChatGPT forward candidate — this function does not re-check that, so a mis-call would
|
|
248
|
+
* spend the sidecar even when the relay could have copied bytes.
|
|
249
|
+
*/
|
|
250
|
+
export async function handleAlphaSearchSidecarFallback(
|
|
251
|
+
body: unknown,
|
|
252
|
+
config: OcxConfig,
|
|
253
|
+
signal?: AbortSignal,
|
|
254
|
+
logCtx?: { provider: string },
|
|
255
|
+
): Promise<Response> {
|
|
256
|
+
const resolution = resolveAlphaSearchSidecar(config);
|
|
257
|
+
if (resolution.status === "missing-credential") {
|
|
258
|
+
// Never the ChatGPT-auth sentence here: the operator already named a backend, so the honest
|
|
259
|
+
// answer names what that backend is missing.
|
|
260
|
+
if (logCtx) logCtx.provider = resolution.backend;
|
|
261
|
+
return formatErrorResponse(400, "invalid_request_error", missingCredentialMessage(resolution.backend));
|
|
262
|
+
}
|
|
263
|
+
if (resolution.status !== "ready") {
|
|
264
|
+
return formatErrorResponse(400, "invalid_request_error", NO_FORWARD_PROVIDER_MESSAGE);
|
|
265
|
+
}
|
|
266
|
+
const resolved = resolution.sidecar;
|
|
267
|
+
if (logCtx) logCtx.provider = resolved.backend;
|
|
268
|
+
|
|
269
|
+
const queries = extractAlphaSearchQueries(body);
|
|
270
|
+
if (queries.length === 0) {
|
|
271
|
+
return formatErrorResponse(
|
|
272
|
+
400,
|
|
273
|
+
"invalid_request_error",
|
|
274
|
+
"Built-in web search request is missing a usable query (commands.search_query, query, q, or search_query).",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const settings = sidecarSettingsForAlphaSearch(resolved.backend, config);
|
|
279
|
+
const sidecarExit = sidecarEnter("search");
|
|
280
|
+
try {
|
|
281
|
+
const texts: string[] = [];
|
|
282
|
+
const sources: SidecarOutcome["sources"] = [];
|
|
283
|
+
const errors: string[] = [];
|
|
284
|
+
for (const query of queries) {
|
|
285
|
+
if (signal?.aborted) break;
|
|
286
|
+
const outcome = await runAlphaSearchQuery(query, resolved, settings, config, signal);
|
|
287
|
+
if (outcome.error) {
|
|
288
|
+
errors.push(outcome.error);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
texts.push(queries.length > 1 ? `Results for "${query}":\n${outcome.text}` : outcome.text);
|
|
292
|
+
for (const source of outcome.sources) {
|
|
293
|
+
if (!sources.some(existing => existing.url === source.url)) sources.push(source);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (signal?.aborted) {
|
|
297
|
+
return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
|
|
298
|
+
}
|
|
299
|
+
if (texts.length === 0) {
|
|
300
|
+
const detail = redactSecretString(errors[0] ?? "web search produced no results");
|
|
301
|
+
return formatErrorResponse(
|
|
302
|
+
502,
|
|
303
|
+
"upstream_error",
|
|
304
|
+
resolved.backend + " web search failed: " + detail,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return new Response(JSON.stringify(formatAlphaSearchBody(texts.join("\n\n"), sources)), {
|
|
308
|
+
status: 200,
|
|
309
|
+
headers: { "content-type": "application/json" },
|
|
310
|
+
});
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (signal?.aborted) {
|
|
313
|
+
return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
|
|
314
|
+
}
|
|
315
|
+
const detail = redactSecretString(err instanceof Error ? err.message : String(err));
|
|
316
|
+
return formatErrorResponse(
|
|
317
|
+
502,
|
|
318
|
+
"upstream_error",
|
|
319
|
+
resolved.backend + " web search failed: " + detail,
|
|
320
|
+
);
|
|
321
|
+
} finally {
|
|
322
|
+
sidecarExit();
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/web-search/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
2
2
|
import { modelInList, toolChoiceToolPredicate } from "../types";
|
|
3
|
-
import {
|
|
3
|
+
import { requiresVisionPreprocessing } from "../vision";
|
|
4
4
|
import type { SidecarSettings } from "./executor";
|
|
5
5
|
import type { CodexAuthPolicyConfig } from "../codex/auth-context";
|
|
6
6
|
import { isCodexReserveRequestEligible } from "../codex/loopback-target";
|
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
findAnthropicSidecarProvider,
|
|
16
16
|
findGeminiSidecarProvider,
|
|
17
17
|
findXaiSidecarProvider,
|
|
18
|
+
resolveSidecarBackend,
|
|
18
19
|
xaiSearchOptionsFromConfig,
|
|
19
20
|
type AnthropicSidecarProvider,
|
|
21
|
+
type WebSearchBackendId,
|
|
20
22
|
} from "./sidecar-providers";
|
|
21
23
|
|
|
22
24
|
export { runWithWebSearch } from "./loop";
|
|
@@ -29,8 +31,10 @@ export {
|
|
|
29
31
|
findAnthropicSidecarProvider,
|
|
30
32
|
findGeminiSidecarProvider,
|
|
31
33
|
findXaiSidecarProvider,
|
|
34
|
+
resolveSidecarBackend,
|
|
32
35
|
xaiSearchOptionsFromConfig,
|
|
33
36
|
type AnthropicSidecarProvider,
|
|
37
|
+
type WebSearchBackendId,
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
|
|
@@ -98,24 +102,6 @@ export function webSearchStallTimeoutSec(
|
|
|
98
102
|
return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
/** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */
|
|
102
|
-
export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa";
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The
|
|
106
|
-
* anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting
|
|
107
|
-
* it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna)
|
|
108
|
-
* to the Anthropic API.
|
|
109
|
-
* The 2188 follow-up ids (xai/gemini/exa) resolve to themselves the same explicit-only way; their
|
|
110
|
-
* planWebSearch arms stay fail-closed until each executor layer lands.
|
|
111
|
-
*/
|
|
112
|
-
export function resolveSidecarBackend(
|
|
113
|
-
explicit: WebSearchBackendId | undefined,
|
|
114
|
-
): WebSearchBackendId {
|
|
115
|
-
if (explicit === "anthropic" || explicit === "xai" || explicit === "gemini" || explicit === "exa") return explicit;
|
|
116
|
-
return "openai";
|
|
117
|
-
}
|
|
118
|
-
|
|
119
105
|
export interface SidecarPlan {
|
|
120
106
|
/** Which executor runs the search. Anthropic does not require a forward provider. */
|
|
121
107
|
backend: WebSearchBackendId;
|
|
@@ -166,7 +152,11 @@ export function planWebSearch(
|
|
|
166
152
|
provider: OcxProviderConfig,
|
|
167
153
|
modelId: string,
|
|
168
154
|
openAiSidecar?: ResolvedOpenAiForwardSidecar,
|
|
169
|
-
options: {
|
|
155
|
+
options: {
|
|
156
|
+
admission?: Pick<DataPlaneAdmission, "source">;
|
|
157
|
+
codexAuthPolicy?: CodexAuthPolicyConfig;
|
|
158
|
+
providerName?: string;
|
|
159
|
+
} = {},
|
|
170
160
|
): SidecarPlan | undefined {
|
|
171
161
|
if (!parsed._webSearch || isPassthrough) return undefined;
|
|
172
162
|
if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined;
|
|
@@ -190,8 +180,9 @@ export function planWebSearch(
|
|
|
190
180
|
routedModelStallTimeoutMs,
|
|
191
181
|
timeoutMs,
|
|
192
182
|
);
|
|
193
|
-
//
|
|
194
|
-
|
|
183
|
+
// A target proven unable to accept image input receives verbalized image results instead of
|
|
184
|
+
// search-result images. A genuinely unknown custom target keeps the established pass-through.
|
|
185
|
+
const describeImages = requiresVisionPreprocessing(config, provider, modelId, options.providerName);
|
|
195
186
|
const reasoning = cfg.reasoning ?? DEFAULT_SIDECAR_REASONING;
|
|
196
187
|
const streamRoutedModelOutput = cfg.streamRoutedModelOutput === true;
|
|
197
188
|
|