@bitkyc08/opencodex 2.53.0 → 2.54.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/gui/dist/assets/{index-BlO4Yl6q.js → index-CkvITofZ.js} +2 -2
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/devin.ts +39 -7
- package/src/cli/capabilities.ts +4 -2
- package/src/cli/catalog.ts +39 -10
- package/src/cli/dispatch.ts +19 -64
- package/src/cli/doctor.ts +1 -1
- package/src/cli/internal-command.ts +44 -0
- package/src/cli/registry.ts +9 -7
- package/src/cli/restart-scope.ts +184 -0
- package/src/codex/app-server-processes.ts +20 -5
- package/src/codex/app-server-restart-service.ts +29 -0
- package/src/codex/catalog/provider-fetch.ts +13 -5
- package/src/codex/catalog/sync.ts +12 -3
- package/src/codex/desktop-app/darwin.ts +268 -0
- package/src/codex/desktop-app/handoff.ts +303 -0
- package/src/codex/desktop-app/linux.ts +388 -0
- package/src/codex/desktop-app/lock.ts +226 -0
- package/src/codex/desktop-app/types.ts +141 -0
- package/src/codex/desktop-app/windows.ts +239 -0
- package/src/codex/desktop-app-restart.ts +264 -279
- package/src/codex/inject.ts +83 -21
- package/src/codex/sync.ts +16 -22
- package/src/generated/compatibility-version.json +58 -22
- package/src/lib/codex-restart-contract.ts +31 -0
- package/src/providers/registry.ts +13 -12
- package/src/server/responses/core.ts +62 -4
- package/src/server/responses/encrypted-payload.ts +161 -0
- package/src/server/responses.ts +1 -1
- package/src/types/provider.ts +6 -5
- package/src/web-search/index.ts +14 -67
- package/src/web-search/passthrough-bridge.ts +256 -32
- package/src/web-search/sidecar-providers.ts +76 -0
|
@@ -158,9 +158,11 @@ import {
|
|
|
158
158
|
import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
|
|
159
159
|
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
160
160
|
import {
|
|
161
|
-
|
|
161
|
+
createPassthroughWebSearchBridgeExecutor,
|
|
162
162
|
createPassthroughWebSearchBridgeStream,
|
|
163
163
|
planPassthroughWebSearchBridge,
|
|
164
|
+
resolvePassthroughWebSearchBridgeAuth,
|
|
165
|
+
shouldResolveOpenAiPassthroughWebSearchBridge,
|
|
164
166
|
} from "../../web-search/passthrough-bridge";
|
|
165
167
|
import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
|
|
166
168
|
import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
@@ -394,7 +396,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat
|
|
|
394
396
|
|
|
395
397
|
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
|
|
396
398
|
import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
|
|
397
|
-
import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
|
|
399
|
+
import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace, stripAgentMessageCiphertextInPlace } from "./encrypted-payload";
|
|
398
400
|
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, type ProviderFetchOptions } from "./fetch-helpers";
|
|
399
401
|
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
|
|
400
402
|
import {
|
|
@@ -4024,6 +4026,49 @@ async function handleResponsesInner(
|
|
|
4024
4026
|
return unreadableEncryptedAgentTaskResponse(recoveryFailureReason);
|
|
4025
4027
|
}
|
|
4026
4028
|
|
|
4029
|
+
// The guard above asks whether the CURRENT worker task is readable, and it only inspects the
|
|
4030
|
+
// tail item. An `agent_message` that mixes readable text with backend ciphertext answers
|
|
4031
|
+
// "readable" to that question at every position, so it passed -- and then
|
|
4032
|
+
// `normalizeRoutedAgentMessages` refused to lower it, because lowering requires every part to
|
|
4033
|
+
// be representable. The raw Responses passthrough serialized the private item as it stood, so
|
|
4034
|
+
// backend ciphertext and an item type only the Codex backend declares reached a third-party
|
|
4035
|
+
// provider, which answered `422 unknown item type "agent_message"` (#4454).
|
|
4036
|
+
//
|
|
4037
|
+
// The opaque-blob path already knows the repair: replace the undecryptable part with an
|
|
4038
|
+
// omission marker, which leaves the item lowerable. It applied that repair only AFTER an
|
|
4039
|
+
// upstream rejection. For a destination that cannot accept the private item under any
|
|
4040
|
+
// circumstances, that round trip was never going to succeed and sent the ciphertext to find
|
|
4041
|
+
// out, so do the repair here instead. Recovery above has already had its chance to turn the
|
|
4042
|
+
// same bytes into real plaintext; only what it could not rescue reaches this.
|
|
4043
|
+
if (inboundWire === "responses" && !finalRouteCanPassThroughEncryptedTask) {
|
|
4044
|
+
// Only the raw Responses passthrough puts input items on the wire verbatim, so that is the
|
|
4045
|
+
// only wire this has to repair: translated wires rebuild the body from parsed messages, where
|
|
4046
|
+
// `inputContentParts` drops an encrypted part instead of forwarding it. The exemption is the
|
|
4047
|
+
// canonical Codex backend alone, because it is the one destination that minted these bytes and
|
|
4048
|
+
// can read them. `authMode: "forward"` is NOT that test -- a noncanonical forward gateway is
|
|
4049
|
+
// somebody else's server that happens to be configured for passthrough, and it receives the
|
|
4050
|
+
// ciphertext like any other third party.
|
|
4051
|
+
//
|
|
4052
|
+
// Combo children run this too. Each child carries its own `structuredClone` of the body
|
|
4053
|
+
// (`concreteComboRequestBody`) and its own concrete route, so a sibling's repair is invisible
|
|
4054
|
+
// here and a target that resolves to a routed Responses wire would otherwise send the
|
|
4055
|
+
// ciphertext that the parent's own dispatch no longer does.
|
|
4056
|
+
const wireProvider = resolveWireProtocolOverride(
|
|
4057
|
+
route.providerName,
|
|
4058
|
+
route.modelId,
|
|
4059
|
+
route.provider,
|
|
4060
|
+
inboundWire,
|
|
4061
|
+
);
|
|
4062
|
+
if (wireProvider.adapter === "openai-responses" && !isCanonicalOpenAiForwardProvider(wireProvider)) {
|
|
4063
|
+
const repaired = stripAgentMessageCiphertextInPlace((body as { input?: unknown } | undefined)?.input);
|
|
4064
|
+
if (repaired > 0) {
|
|
4065
|
+
console.warn(
|
|
4066
|
+
`[opencodex] replaced ciphertext in ${repaired} replayed agent message(s) with an omission marker; the selected provider cannot read native ChatGPT ciphertext`,
|
|
4067
|
+
);
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4027
4072
|
// The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
|
|
4028
4073
|
// safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
|
|
4029
4074
|
// I/O instead of stripping the id and silently forwarding a context-free delta (#702).
|
|
@@ -4726,7 +4771,8 @@ async function handleResponsesInner(
|
|
|
4726
4771
|
const needsOpenAiVision = !visionDescribeTerminal
|
|
4727
4772
|
&& shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed);
|
|
4728
4773
|
const needsOpenAiSearch = !routedCompaction && !adapter.runTurn
|
|
4729
|
-
&& shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough)
|
|
4774
|
+
&& (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough)
|
|
4775
|
+
|| shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough));
|
|
4730
4776
|
if (needsOpenAiVision || needsOpenAiSearch) {
|
|
4731
4777
|
try {
|
|
4732
4778
|
const candidates = listOpenAiForwardSidecarCandidates(config);
|
|
@@ -6199,9 +6245,15 @@ async function handleResponsesInner(
|
|
|
6199
6245
|
// conversation upstream, and hands back ordinary Responses SSE — so every rewrite below,
|
|
6200
6246
|
// including the guard itself, still inspects the client-facing stream. Default OFF: without
|
|
6201
6247
|
// the opt-in this is one planner call and the relay is byte-identical to before.
|
|
6248
|
+
const webSearchBridgeAuth = resolvePassthroughWebSearchBridgeAuth(
|
|
6249
|
+
route.provider.webSearchBridge?.backend,
|
|
6250
|
+
config,
|
|
6251
|
+
openAiSidecar,
|
|
6252
|
+
);
|
|
6202
6253
|
const webSearchBridgePlan = planPassthroughWebSearchBridge(parsed, route.provider, {
|
|
6203
6254
|
isPassthrough: true,
|
|
6204
6255
|
stream: parsed.stream === true,
|
|
6256
|
+
auth: webSearchBridgeAuth,
|
|
6205
6257
|
});
|
|
6206
6258
|
// Capture the binding that actually served the first leg, after its permitted reselection.
|
|
6207
6259
|
const webSearchBridgeBinding = requestBindings.get(request);
|
|
@@ -6235,7 +6287,13 @@ async function handleResponsesInner(
|
|
|
6235
6287
|
}),
|
|
6236
6288
|
false,
|
|
6237
6289
|
),
|
|
6238
|
-
execute:
|
|
6290
|
+
execute: createPassthroughWebSearchBridgeExecutor(webSearchBridgePlan, {
|
|
6291
|
+
providerApiKey: route.provider.apiKey ?? "",
|
|
6292
|
+
auth: webSearchBridgeAuth,
|
|
6293
|
+
hostedTool: parsed._webSearch,
|
|
6294
|
+
describeImages: isModelTextOnly(route.provider, route.modelId),
|
|
6295
|
+
sidecar: config.webSearchSidecar,
|
|
6296
|
+
}),
|
|
6239
6297
|
// Appending a search result can push the continuation past the ceiling the first leg
|
|
6240
6298
|
// was admitted under, so the same limit is re-applied before every later send.
|
|
6241
6299
|
checkOutboundBody: (continuationBody: string) => {
|
|
@@ -319,6 +319,167 @@ export function hasEncryptedContentPart(content: unknown): boolean {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
|
|
322
|
+
/** The marker `prepareOpaqueBlobRecovery` already substitutes for an undecryptable part. */
|
|
323
|
+
export const OMITTED_ENCRYPTED_CONTENT_TEXT = "[encrypted content omitted]";
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The Fernet WIRE shape, without validating the body: version prefix, base64url alphabet, and a
|
|
327
|
+
* canonical encoded length. Free text is judged by this rather than by
|
|
328
|
+
* `looksLikeBackendCiphertext`, which is length >= 64 over a character class that a SHA-256 hex
|
|
329
|
+
* digest matches exactly at 64 characters -- as do a SHA-512 digest, a long key, and adjacent
|
|
330
|
+
* short encoded fragments. An `encrypted_content` slot carries ciphertext by definition and is
|
|
331
|
+
* stripped whatever it holds; a text part does not, and replacing a digest a child deliberately
|
|
332
|
+
* printed would destroy readable content to protect bytes that were never secret.
|
|
333
|
+
*/
|
|
334
|
+
const FERNET_SHAPED = /^g[A-Za-z0-9_-]+={0,2}$/;
|
|
335
|
+
|
|
336
|
+
function looksLikeFernetToken(text: string): boolean {
|
|
337
|
+
return text.length >= 100 && text.length % 4 === 0 && FERNET_SHAPED.test(text);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function textWithRunsOmitted(payload: string, runs: readonly FernetTokenRun[]): string {
|
|
341
|
+
let last = 0;
|
|
342
|
+
let out = "";
|
|
343
|
+
for (const run of runs) {
|
|
344
|
+
out += payload.slice(last, run.index) + OMITTED_ENCRYPTED_CONTENT_TEXT;
|
|
345
|
+
last = run.index + run.token.length;
|
|
346
|
+
}
|
|
347
|
+
return out + payload.slice(last);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Replace ciphertext inside `agent_message` items with an omission marker, so
|
|
352
|
+
* `normalizeRoutedAgentMessages` can lower them onto public messages. Returns how many items
|
|
353
|
+
* were repaired. Items are replaced rather than mutated, and every other item type is left
|
|
354
|
+
* alone: reasoning and function-output blobs keep their own reactive recovery.
|
|
355
|
+
*
|
|
356
|
+
* `hasUnreadableEncryptedAgentTask` above answers a different question: can the CURRENT worker
|
|
357
|
+
* task be read at all? It inspects only the tail item and reports false the moment any plaintext
|
|
358
|
+
* survives the envelope. `normalizeRoutedAgentMessages` asks the opposite question -- is EVERY
|
|
359
|
+
* part lowerable? -- and forwards the private item verbatim when one is not. A mixed
|
|
360
|
+
* `input_text` + `encrypted_content` item answers "readable" to the first and "not lowerable"
|
|
361
|
+
* to the second, so it fell between them: the guard never fired, the adapter refused to lower it,
|
|
362
|
+
* and the raw Responses passthrough put a private item and backend ciphertext on the wire
|
|
363
|
+
* (#4454). Position was never the discriminator -- a replayed child result lands mid-history and
|
|
364
|
+
* the tail-only scan cannot see it -- but the tail is equally exposed when it is mixed.
|
|
365
|
+
*
|
|
366
|
+
* This is the repair `prepareOpaqueBlobRecovery` performs after an upstream rejection, applied
|
|
367
|
+
* before dispatch for a destination that cannot accept the private item under any circumstances.
|
|
368
|
+
* The round trip it replaces was never going to succeed, and it sent ciphertext to a third party
|
|
369
|
+
* to find that out. Nothing is decrypted, and nothing readable is lost: the parent could not read
|
|
370
|
+
* these bytes either.
|
|
371
|
+
*
|
|
372
|
+
* The two kinds of slot are judged differently, because they carry different guarantees. An
|
|
373
|
+
* `encrypted_content` slot holds ciphertext by definition, so it is stripped whatever it holds:
|
|
374
|
+
* demanding a well-formed token there would reopen this defect one payload later, since a
|
|
375
|
+
* truncated token, a standard-base64 blob carrying `+` or `/`, an unexpected version byte, or a
|
|
376
|
+
* run past the recovery size limits would each keep the item and forward the bytes.
|
|
377
|
+
*
|
|
378
|
+
* A text part carries no such guarantee, so it is matched strictly: embedded runs that validate
|
|
379
|
+
* as Fernet, or a whole slot with the Fernet wire shape. A loose character-class test would be
|
|
380
|
+
* worse than the defect for that half -- a SHA-256 digest is exactly 64 characters of
|
|
381
|
+
* `[A-Za-z0-9]` and would be replaced with a marker, silently deleting something a child
|
|
382
|
+
* deliberately printed.
|
|
383
|
+
*/
|
|
384
|
+
export function stripAgentMessageCiphertextInPlace(input: unknown): number {
|
|
385
|
+
if (!Array.isArray(input)) return 0;
|
|
386
|
+
let repaired = 0;
|
|
387
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
388
|
+
const item = input[index];
|
|
389
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
390
|
+
const record = item as Record<string, unknown>;
|
|
391
|
+
if (record.type !== "agent_message") continue;
|
|
392
|
+
const content = record.content;
|
|
393
|
+
if (typeof content === "string") {
|
|
394
|
+
const replaced = textWithoutCiphertext(content);
|
|
395
|
+
if (replaced === content) continue;
|
|
396
|
+
input[index] = { ...record, content: replaced };
|
|
397
|
+
repaired += 1;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (!Array.isArray(content)) continue;
|
|
401
|
+
const parts = contentWithoutCiphertext(content);
|
|
402
|
+
if (parts === content) continue;
|
|
403
|
+
input[index] = { ...record, content: parts };
|
|
404
|
+
repaired += 1;
|
|
405
|
+
}
|
|
406
|
+
return repaired;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Free text: drop embedded token runs, and replace a slot that is nothing but a token. */
|
|
410
|
+
function textWithoutCiphertext(text: string): string {
|
|
411
|
+
const runs = fernetTokenRuns(text);
|
|
412
|
+
if (runs.length > 0) return textWithRunsOmitted(text, runs);
|
|
413
|
+
return looksLikeFernetToken(text.trim()) ? OMITTED_ENCRYPTED_CONTENT_TEXT : text;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function ciphertextTextOfPart(part: unknown): string | undefined {
|
|
417
|
+
if (!part || typeof part !== "object") return undefined;
|
|
418
|
+
const record = part as { type?: unknown; text?: unknown };
|
|
419
|
+
return (record.type === "input_text" || record.type === "text") && typeof record.text === "string"
|
|
420
|
+
? record.text
|
|
421
|
+
: undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Adjacent text slots that are one token between them. Each fragment can be too short to judge on
|
|
426
|
+
* its own, which is the text-side twin of the split `encrypted_content` run. The join must still
|
|
427
|
+
* be Fernet-shaped, so two ordinary encoded fragments do not become a marker by being adjacent.
|
|
428
|
+
*/
|
|
429
|
+
function joinedCiphertextTextParts(content: readonly unknown[]): Set<object> {
|
|
430
|
+
const flagged = new Set<object>();
|
|
431
|
+
let run: Array<{ part: object; text: string }> = [];
|
|
432
|
+
const finish = (): void => {
|
|
433
|
+
if (run.length > 1 && looksLikeFernetToken(run.map(entry => entry.text).join(""))) {
|
|
434
|
+
for (const entry of run) flagged.add(entry.part);
|
|
435
|
+
}
|
|
436
|
+
run = [];
|
|
437
|
+
};
|
|
438
|
+
for (const part of content) {
|
|
439
|
+
const text = ciphertextTextOfPart(part);
|
|
440
|
+
if (text === undefined || text.trim().length === 0 || !/^[A-Za-z0-9_-]+={0,2}$/.test(text)) {
|
|
441
|
+
finish();
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
run.push({ part: part as object, text });
|
|
445
|
+
}
|
|
446
|
+
finish();
|
|
447
|
+
return flagged;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function contentWithoutCiphertext(content: unknown[]): unknown[] {
|
|
451
|
+
let changed = false;
|
|
452
|
+
const joined = joinedCiphertextTextParts(content);
|
|
453
|
+
const parts = content.map((part: unknown) => {
|
|
454
|
+
if (!part || typeof part !== "object") return part;
|
|
455
|
+
if (joined.has(part)) {
|
|
456
|
+
changed = true;
|
|
457
|
+
return { type: "input_text", text: OMITTED_ENCRYPTED_CONTENT_TEXT };
|
|
458
|
+
}
|
|
459
|
+
const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
|
|
460
|
+
if (record.type === "encrypted_content" && typeof record.encrypted_content === "string") {
|
|
461
|
+
changed = true;
|
|
462
|
+
// Keep whatever plaintext a recognizable slot carries around its token; a slot this
|
|
463
|
+
// cannot parse is replaced whole rather than forwarded on the chance that it is benign.
|
|
464
|
+
const runs = fernetTokenRuns(record.encrypted_content);
|
|
465
|
+
return {
|
|
466
|
+
type: "input_text",
|
|
467
|
+
text: runs.length > 0
|
|
468
|
+
? textWithRunsOmitted(record.encrypted_content, runs)
|
|
469
|
+
: OMITTED_ENCRYPTED_CONTENT_TEXT,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const text = ciphertextTextOfPart(part);
|
|
473
|
+
if (text === undefined) return part;
|
|
474
|
+
const replaced = textWithoutCiphertext(text);
|
|
475
|
+
if (replaced === text) return part;
|
|
476
|
+
changed = true;
|
|
477
|
+
return { ...record, text: replaced };
|
|
478
|
+
});
|
|
479
|
+
return changed ? parts : content;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
|
|
322
483
|
|
|
323
484
|
export function sanitizeEncryptedContentInPlace(input: unknown): number {
|
|
324
485
|
if (!Array.isArray(input)) return 0;
|
package/src/server/responses.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { requestPacingOverloadResponse } from "./responses/pacing-overload";
|
|
|
5
5
|
|
|
6
6
|
export { buildToolBridgeMaps, isV1CollabSurface, collabSurface, multiAgentGuidanceText, V2_GUIDANCE_CHAR_BUDGET, injectDeveloperMessage } from "./responses/collaboration";
|
|
7
7
|
export type { MultiAgentGuidanceOptions, MultiAgentGuidanceDeps } from "./responses/collaboration";
|
|
8
|
-
export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload";
|
|
8
|
+
export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace, stripAgentMessageCiphertextInPlace } from "./responses/encrypted-payload";
|
|
9
9
|
export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse } from "./responses/compact";
|
|
10
10
|
export { disableResponsesRequestTimeout, safeHostLabel, fetchWithHeaderTimeout } from "./responses/fetch-helpers";
|
|
11
11
|
export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, linkAbortSignal } from "./responses/core";
|
package/src/types/provider.ts
CHANGED
|
@@ -87,11 +87,11 @@ export interface RateLimitRetryPolicy {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* Backend ids admitted by `providers.<name>.webSearchBridge.backend`.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
90
|
+
* Backend ids admitted by `providers.<name>.webSearchBridge.backend`. Each id is explicit-only:
|
|
91
|
+
* an omitted backend keeps the bridge disarmed rather than silently falling back to a paid
|
|
92
|
+
* Luna or Exa search. `ollama` spends this provider's API key on the search endpoint.
|
|
93
|
+
* `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and
|
|
94
|
+
* that executor's own credential; a missing credential leaves the bridge disarmed.
|
|
95
95
|
*/
|
|
96
96
|
export const PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS = [
|
|
97
97
|
"ollama",
|
|
@@ -118,6 +118,7 @@ export type ProviderWebSearchBridgeBackend = typeof PROVIDER_WEB_SEARCH_BRIDGE_B
|
|
|
118
118
|
*
|
|
119
119
|
* Never armed for `authMode: "forward"` (ChatGPT) or for a provider that executes hosted search
|
|
120
120
|
* upstream; see `planPassthroughWebSearchBridge` in `src/web-search/passthrough-bridge.ts`.
|
|
121
|
+
* A mixed `web_search` + client tool call still fails closed. Assistant text is not a search call.
|
|
121
122
|
*/
|
|
122
123
|
export interface ProviderWebSearchBridgeConfig {
|
|
123
124
|
/** Master switch. Absent or false keeps today's relay-and-fail behavior exactly. */
|
package/src/web-search/index.ts
CHANGED
|
@@ -7,11 +7,17 @@ import { isCodexReserveRequestEligible } from "../codex/loopback-target";
|
|
|
7
7
|
import type { DataPlaneAdmission } from "../server/auth-cors";
|
|
8
8
|
import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
|
|
9
9
|
import { resolveSidecarAuth } from "../sidecar/auth";
|
|
10
|
-
import { getAccountSet } from "../oauth/store";
|
|
11
10
|
import { validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor";
|
|
12
11
|
import type { OcxWebSearchSidecarConfig } from "../types";
|
|
13
12
|
import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout";
|
|
14
13
|
import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
|
|
14
|
+
import {
|
|
15
|
+
findAnthropicSidecarProvider,
|
|
16
|
+
findGeminiSidecarProvider,
|
|
17
|
+
findXaiSidecarProvider,
|
|
18
|
+
xaiSearchOptionsFromConfig,
|
|
19
|
+
type AnthropicSidecarProvider,
|
|
20
|
+
} from "./sidecar-providers";
|
|
15
21
|
|
|
16
22
|
export { runWithWebSearch } from "./loop";
|
|
17
23
|
export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME };
|
|
@@ -19,6 +25,13 @@ export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-exe
|
|
|
19
25
|
export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor";
|
|
20
26
|
export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor";
|
|
21
27
|
export { runExaWebSearch, mapExaSearchResponse } from "./exa-executor";
|
|
28
|
+
export {
|
|
29
|
+
findAnthropicSidecarProvider,
|
|
30
|
+
findGeminiSidecarProvider,
|
|
31
|
+
findXaiSidecarProvider,
|
|
32
|
+
xaiSearchOptionsFromConfig,
|
|
33
|
+
type AnthropicSidecarProvider,
|
|
34
|
+
};
|
|
22
35
|
|
|
23
36
|
const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
|
|
24
37
|
// Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset).
|
|
@@ -85,72 +98,6 @@ export function webSearchStallTimeoutSec(
|
|
|
85
98
|
return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC);
|
|
86
99
|
}
|
|
87
100
|
|
|
88
|
-
/** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */
|
|
89
|
-
export interface AnthropicSidecarProvider {
|
|
90
|
-
providerName: string;
|
|
91
|
-
provider: OcxProviderConfig;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* First enabled anthropic-adapter OAuth provider whose ACTIVE account holds a usable credential — the
|
|
96
|
-
* only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by
|
|
97
|
-
* getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a
|
|
98
|
-
* terminally-invalid account); token refresh happens later at executor time.
|
|
99
|
-
* Delegates to the shared sidecar auth module (#2188) so web-search and vision
|
|
100
|
-
* cannot drift on what "Anthropic auth present" means.
|
|
101
|
-
*/
|
|
102
|
-
export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined {
|
|
103
|
-
const auth = resolveSidecarAuth(config);
|
|
104
|
-
if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined;
|
|
105
|
-
return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* First enabled provider whose stored Grok OAuth account is active and not marked for
|
|
110
|
-
* reauth — the only credential the xai web-search executor may spend. Same account-set
|
|
111
|
-
* predicate the shared sidecar auth module applies to Anthropic.
|
|
112
|
-
*/
|
|
113
|
-
export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined {
|
|
114
|
-
// The stored Grok credential lives under the provider named "xai" (registry id);
|
|
115
|
-
// OAuth account sets are keyed by provider name, so the name IS the credential key.
|
|
116
|
-
const provider = config.providers["xai"];
|
|
117
|
-
if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined;
|
|
118
|
-
const set = getAccountSet("xai");
|
|
119
|
-
const active = set?.accounts.find(account => account.id === set.activeAccountId);
|
|
120
|
-
if (active && active.needsReauth !== true) return { providerName: "xai", provider };
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* First usable Antigravity credential holder: the "google-antigravity" provider
|
|
126
|
-
* (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose
|
|
127
|
-
* active stored account is healthy AND carries a discovered CCA projectId — the
|
|
128
|
-
* executor cannot form the envelope without it.
|
|
129
|
-
*/
|
|
130
|
-
export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined {
|
|
131
|
-
const provider = config.providers["google-antigravity"];
|
|
132
|
-
if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined;
|
|
133
|
-
const set = getAccountSet("google-antigravity");
|
|
134
|
-
const active = set?.accounts.find(account => account.id === set.activeAccountId);
|
|
135
|
-
if (!active || active.needsReauth === true) return undefined;
|
|
136
|
-
const projectId = (active.credential as { projectId?: string } | undefined)?.projectId;
|
|
137
|
-
if (!projectId) return undefined;
|
|
138
|
-
return { providerName: "google-antigravity", provider };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Lift the persisted xSearch config block into executor options (absent block = web_search only). */
|
|
142
|
-
export function xaiSearchOptionsFromConfig(cfg: Pick<OcxWebSearchSidecarConfig, "xSearch">): XaiSearchOptions {
|
|
143
|
-
const x = cfg.xSearch;
|
|
144
|
-
if (!x || x.enabled !== true) return {};
|
|
145
|
-
return {
|
|
146
|
-
xSearch: true,
|
|
147
|
-
...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}),
|
|
148
|
-
...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}),
|
|
149
|
-
...(x.fromDate ? { fromDate: x.fromDate } : {}),
|
|
150
|
-
...(x.toDate ? { toDate: x.toDate } : {}),
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
101
|
/** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */
|
|
155
102
|
export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa";
|
|
156
103
|
|