@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/cli.js +3616 -3676
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/exec/bash-executor.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  17. package/dist/types/launch/spawn-options.d.ts +10 -0
  18. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  19. package/dist/types/modes/components/status-line/component.d.ts +10 -3
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  21. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  22. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  23. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  24. package/dist/types/modes/components/usage-row.d.ts +1 -1
  25. package/dist/types/modes/interactive-mode.d.ts +2 -0
  26. package/dist/types/modes/print-mode.d.ts +4 -0
  27. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  28. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  29. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  30. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  31. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  32. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  33. package/dist/types/modes/types.d.ts +2 -0
  34. package/dist/types/sdk.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +42 -6
  36. package/dist/types/session/messages.d.ts +6 -0
  37. package/dist/types/session/session-paths.d.ts +21 -4
  38. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  39. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  40. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  42. package/dist/types/tools/gh.d.ts +5 -3
  43. package/dist/types/tools/hub/index.d.ts +1 -1
  44. package/dist/types/tools/hub/jobs.d.ts +1 -0
  45. package/dist/types/tools/hub/types.d.ts +2 -0
  46. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  47. package/dist/types/tools/xdev.d.ts +5 -3
  48. package/dist/types/utils/git.d.ts +33 -0
  49. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  50. package/dist/types/vibe/runtime.d.ts +23 -0
  51. package/dist/types/web/search/providers/codex.d.ts +1 -1
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +150 -0
  54. package/src/advisor/advise-tool.ts +4 -0
  55. package/src/advisor/runtime.ts +38 -14
  56. package/src/async/job-manager.ts +3 -0
  57. package/src/cli/bench-cli.ts +8 -2
  58. package/src/cli/dry-balance-cli.ts +1 -0
  59. package/src/config/model-registry.ts +89 -8
  60. package/src/config/model-resolver.ts +78 -14
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings.ts +3 -1
  63. package/src/dap/client.ts +168 -1
  64. package/src/dap/config.ts +51 -1
  65. package/src/dap/session.ts +575 -234
  66. package/src/dap/types.ts +6 -5
  67. package/src/discovery/agents.ts +2 -2
  68. package/src/exec/bash-executor.ts +68 -8
  69. package/src/extensibility/extensions/load-errors.ts +13 -0
  70. package/src/extensibility/extensions/runner.ts +6 -4
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  72. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  73. package/src/launch/broker.ts +34 -31
  74. package/src/launch/client.ts +7 -1
  75. package/src/launch/spawn-options.test.ts +31 -0
  76. package/src/launch/spawn-options.ts +17 -0
  77. package/src/lsp/types.ts +5 -1
  78. package/src/main.ts +25 -4
  79. package/src/mcp/transports/stdio.test.ts +9 -1
  80. package/src/modes/components/ask-dialog.ts +137 -73
  81. package/src/modes/components/chat-transcript-builder.ts +10 -1
  82. package/src/modes/components/status-line/component.ts +57 -3
  83. package/src/modes/components/status-line/segments.ts +20 -3
  84. package/src/modes/components/status-line/types.ts +3 -1
  85. package/src/modes/components/tool-execution.ts +5 -0
  86. package/src/modes/components/transcript-container.ts +23 -122
  87. package/src/modes/components/tree-selector.ts +10 -4
  88. package/src/modes/components/usage-row.ts +17 -1
  89. package/src/modes/controllers/command-controller.ts +41 -1
  90. package/src/modes/controllers/event-controller.ts +7 -3
  91. package/src/modes/controllers/input-controller.ts +1 -1
  92. package/src/modes/controllers/selector-controller.ts +29 -8
  93. package/src/modes/interactive-mode.ts +102 -60
  94. package/src/modes/noninteractive-dispose.test.ts +14 -1
  95. package/src/modes/print-mode.ts +81 -9
  96. package/src/modes/rpc/rpc-client.ts +76 -35
  97. package/src/modes/rpc/rpc-frame.ts +156 -0
  98. package/src/modes/rpc/rpc-input.ts +38 -0
  99. package/src/modes/rpc/rpc-mode.ts +11 -4
  100. package/src/modes/setup-wizard/index.ts +2 -0
  101. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  102. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  103. package/src/modes/types.ts +2 -0
  104. package/src/modes/utils/ui-helpers.ts +9 -3
  105. package/src/prompts/system/workflow-notice.md +89 -74
  106. package/src/prompts/tools/browser.md +3 -2
  107. package/src/prompts/tools/debug.md +2 -7
  108. package/src/prompts/tools/github.md +6 -1
  109. package/src/prompts/tools/hub.md +4 -2
  110. package/src/prompts/tools/task-async-contract.md +1 -0
  111. package/src/prompts/tools/task.md +9 -2
  112. package/src/sdk.ts +38 -6
  113. package/src/session/agent-session.ts +484 -188
  114. package/src/session/messages.test.ts +91 -0
  115. package/src/session/messages.ts +248 -110
  116. package/src/session/session-manager.ts +34 -3
  117. package/src/session/session-paths.ts +38 -9
  118. package/src/slash-commands/builtin-registry.ts +1 -0
  119. package/src/task/executor.ts +104 -33
  120. package/src/task/index.ts +46 -9
  121. package/src/task/worktree.ts +10 -0
  122. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  123. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  124. package/src/tools/browser/cmux/socket-client.ts +139 -3
  125. package/src/tools/browser/run-cancellation.ts +4 -0
  126. package/src/tools/browser/tab-protocol.ts +2 -0
  127. package/src/tools/browser/tab-supervisor.ts +21 -11
  128. package/src/tools/browser/tab-worker.ts +199 -33
  129. package/src/tools/debug.ts +3 -0
  130. package/src/tools/gh.ts +40 -2
  131. package/src/tools/hub/index.ts +4 -1
  132. package/src/tools/hub/jobs.ts +42 -1
  133. package/src/tools/hub/types.ts +2 -0
  134. package/src/tools/tool-timeouts.ts +1 -1
  135. package/src/tools/xdev.ts +11 -4
  136. package/src/tools/yield.ts +4 -1
  137. package/src/utils/git.ts +237 -0
  138. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  139. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  140. package/src/vibe/runtime.ts +50 -0
  141. package/src/web/search/index.ts +9 -5
  142. package/src/web/search/providers/codex.ts +195 -99
  143. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -33,6 +33,7 @@ import { type AgentDefinition, type AgentProgress, oneLineLabel, type SingleResu
33
33
  import type { ToolSession } from "../tools";
34
34
  import { formatDuration } from "../tools/render-utils";
35
35
  import { ToolError } from "../tools/tool-errors";
36
+ import { calculateTokensPerSecond } from "../utils/token-rate";
36
37
 
37
38
  /** The two worker CLI flavors the director drives. */
38
39
  export type VibeCli = "fast" | "good";
@@ -207,6 +208,26 @@ export class VibeSessionRegistry {
207
208
  VibeSessionRegistry.#global = undefined;
208
209
  }
209
210
 
211
+ /**
212
+ * Insert a bare worker record without the spawn/job machinery. Test-only —
213
+ * lets {@link aggregateVibeWorkerTokensPerSecond} be exercised against a
214
+ * fake roster + AgentRegistry session without driving a real turn.
215
+ */
216
+ registerRecordForTests(record: { id: string; cli?: VibeCli; ownerId: string; state?: VibeSessionState }): void {
217
+ this.#records.set(record.id, {
218
+ id: record.id,
219
+ cli: record.cli ?? "fast",
220
+ ownerId: record.ownerId,
221
+ agent: getBundledAgent("sonic")!,
222
+ state: record.state ?? "running",
223
+ createdAt: Date.now(),
224
+ lastActivityAt: Date.now(),
225
+ queue: [],
226
+ turnCount: 0,
227
+ killed: false,
228
+ });
229
+ }
230
+
210
231
  readonly #records = new Map<string, VibeRecord>();
211
232
 
212
233
  #manager(session: ToolSession): AsyncJobManager {
@@ -708,3 +729,32 @@ export class VibeSessionRegistry {
708
729
  return text;
709
730
  }
710
731
  }
732
+
733
+ /**
734
+ * Aggregate tok/s across every live vibe worker session owned by `ownerId`.
735
+ * Returns null when no workers are streaming (so callers can fall back to
736
+ * their own rate unchanged). The director is often idle while workers stream,
737
+ * so without this aggregation the status-line tok/s badge would show a stale
738
+ * value while parallel work is actively generating tokens.
739
+ *
740
+ * Reads each worker's last assistant message via {@link calculateTokensPerSecond}
741
+ * — the same leaf calculator the main status line uses — so worker rates are
742
+ * computed identically to the main session's rate.
743
+ */
744
+ export function aggregateVibeWorkerTokensPerSecond(ownerId: string): number | null {
745
+ const ids = VibeSessionRegistry.global().listIds(ownerId);
746
+ if (ids.length === 0) return null;
747
+ let total = 0;
748
+ let any = false;
749
+ const registry = AgentRegistry.global();
750
+ for (const id of ids) {
751
+ const workerSession = registry.get(id)?.session;
752
+ if (!workerSession?.isStreaming) continue;
753
+ const rate = calculateTokensPerSecond(workerSession.state.messages, true);
754
+ if (rate !== null) {
755
+ total += rate;
756
+ any = true;
757
+ }
758
+ }
759
+ return any ? total : null;
760
+ }
@@ -134,10 +134,7 @@ async function executeSearch(
134
134
  const explicitProvider = params.provider;
135
135
  let candidates: SearchProviderCandidate[];
136
136
  if (explicitProvider && explicitProvider !== "auto") {
137
- const provider = await getSearchProvider(explicitProvider);
138
- candidates = (await provider.isExplicitlyAvailable(authStorage))
139
- ? [{ id: explicitProvider, explicit: true }]
140
- : resolveProviderCandidates("auto");
137
+ candidates = [{ id: explicitProvider, explicit: true }];
141
138
  } else if (explicitProvider === "auto") {
142
139
  // Explicit `--provider auto` bypasses the configured preferred provider
143
140
  // for this invocation; exclusions still apply.
@@ -175,7 +172,13 @@ async function executeSearch(
175
172
  const available = candidate.explicit
176
173
  ? await provider.isExplicitlyAvailable(authStorage)
177
174
  : await provider.isAvailable(authStorage);
178
- if (!available) continue;
175
+ if (!available && !candidate.explicit) continue;
176
+ if (!available && candidate.explicit) {
177
+ throw new SearchProviderError(
178
+ provider.id,
179
+ `${provider.label} web search is unavailable. Configure its credentials or select the automatic provider chain.`,
180
+ );
181
+ }
179
182
  availableProviderCount++;
180
183
  lastProvider = provider;
181
184
 
@@ -213,6 +216,7 @@ async function executeSearch(
213
216
  // summary error), masking the cancellation.
214
217
  throwIfAborted(signal);
215
218
  failures.push({ provider: provider ?? providerMeta, error });
219
+ if (candidate.explicit) break;
216
220
  }
217
221
  }
218
222
 
@@ -1,28 +1,40 @@
1
1
  /**
2
2
  * OpenAI Codex Web Search Provider
3
3
  *
4
- * Uses Codex's built-in web_search tool via the Responses API.
5
- * Auth is resolved through `AuthStorage.getOAuthAccess("openai-codex")` so the
6
- * broker is the sole refresh authority — this module never opens a sibling
7
- * SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
4
+ * Uses the configured Codex Responses transport for proxy/API-key setups and
5
+ * the official ChatGPT backend for OAuth logins.
8
6
  */
9
7
  import * as os from "node:os";
10
- import { type AuthStorage, type FetchImpl, type Model, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
11
- import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
8
+ import {
9
+ type AuthStorage,
10
+ type FetchImpl,
11
+ type Model,
12
+ type OAuthAccess,
13
+ withAuth,
14
+ withOAuthAccess,
15
+ } from "@oh-my-pi/pi-ai";
12
16
  import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
13
- import { createOpenAICodexCompatibilityMetadata } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
17
+ import {
18
+ createOpenAICodexCompatibilityMetadata,
19
+ resolveCodexResponsesUrl,
20
+ } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
14
21
  import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
15
- import { CODEX_CLIENT_VERSION, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "@oh-my-pi/pi-catalog/wire/codex";
22
+ import {
23
+ CODEX_BASE_URL,
24
+ CODEX_CLIENT_VERSION,
25
+ getCodexAccountId,
26
+ OPENAI_HEADER_VALUES,
27
+ OPENAI_HEADERS,
28
+ } from "@oh-my-pi/pi-catalog/wire/codex";
16
29
  import { $env, readSseJson } from "@oh-my-pi/pi-utils";
17
30
  import packageJson from "../../../../package.json" with { type: "json" };
31
+ import type { ModelRegistry } from "../../../config/model-registry";
18
32
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
19
33
  import { SearchProviderError } from "../../../web/search/types";
20
34
  import type { SearchParams } from "./base";
21
35
  import { SearchProvider } from "./base";
22
36
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
23
37
 
24
- const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
25
- const CODEX_RESPONSES_PATH = "/codex/responses";
26
38
  const FALLBACK_MODEL = "gpt-5.5";
27
39
  const DEFAULT_MODEL_PREFERENCES = [
28
40
  "gpt-5.6-luna",
@@ -37,7 +49,6 @@ const DEFAULT_MODEL_PREFERENCES = [
37
49
  "gpt-5.1-codex",
38
50
  "gpt-5-codex-mini",
39
51
  ];
40
- const JWT_CLAIM_PATH = "https://api.openai.com/auth";
41
52
  const DEFAULT_INSTRUCTIONS =
42
53
  "You are a helpful assistant with web search capabilities. Search the web to answer the user's question accurately and cite your sources.";
43
54
 
@@ -48,6 +59,21 @@ interface CodexModelCandidate {
48
59
  catalogModel?: CodexSearchModel;
49
60
  }
50
61
 
62
+ interface CodexSearchTransport {
63
+ baseUrl: string;
64
+ url: string;
65
+ headers: Record<string, string>;
66
+ customEndpoint: boolean;
67
+ }
68
+
69
+ interface CodexSearchResult {
70
+ answer: string;
71
+ sources: SearchSource[];
72
+ model: string;
73
+ requestId: string;
74
+ usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
75
+ }
76
+
51
77
  function getBundledCodexModels(): CodexSearchModel[] {
52
78
  const models: CodexSearchModel[] = [];
53
79
  for (const model of getBundledModels("openai-codex")) {
@@ -295,18 +321,6 @@ function extractTextSources(text: string): SearchSource[] {
295
321
  return sources;
296
322
  }
297
323
 
298
- /**
299
- * Extracts account ID from a Codex access token.
300
- * @param accessToken - JWT access token
301
- * @returns Account ID string, or null if not found
302
- */
303
- function getAccountIdFromJwt(accessToken: string): string | null {
304
- const payload = decodeJwt(accessToken);
305
- const auth = payload?.[JWT_CLAIM_PATH] as { chatgpt_account_id?: string } | undefined;
306
- const accountId = auth?.chatgpt_account_id;
307
- return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
308
- }
309
-
310
324
  /**
311
325
  * Resolve a Codex bearer + accountId through {@link AuthStorage} — the single
312
326
  * refresh authority. Returns `null` when no OAuth credential is configured,
@@ -320,25 +334,55 @@ async function findCodexAuth(
320
334
  ): Promise<{ access: OAuthAccess; accountId: string } | null> {
321
335
  const access = await authStorage.getOAuthAccess("openai-codex", sessionId, { signal });
322
336
  if (!access) return null;
323
- const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
337
+ const accountId = access.accountId ?? getCodexAccountId(access.accessToken);
324
338
  if (!accountId) return null;
325
339
  return { access, accountId };
326
340
  }
327
341
 
342
+ function resolveCodexSearchTransport(modelRegistry: ModelRegistry | undefined, modelId: string): CodexSearchTransport {
343
+ const registryModel = modelRegistry?.find("openai-codex", modelId);
344
+ const bundledModel = getBundledCodexModels().find(model => model.id === modelId);
345
+ const providerBaseUrl = modelRegistry?.getProviderBaseUrl("openai-codex");
346
+ let baseUrl = providerBaseUrl ?? registryModel?.baseUrl ?? CODEX_BASE_URL;
347
+ if (registryModel?.baseUrl && registryModel.baseUrl !== (bundledModel?.baseUrl ?? CODEX_BASE_URL)) {
348
+ baseUrl = registryModel.baseUrl;
349
+ }
350
+
351
+ const url = resolveCodexResponsesUrl(baseUrl);
352
+ return {
353
+ baseUrl,
354
+ url,
355
+ headers: {
356
+ ...(modelRegistry?.getProviderHeaders("openai-codex") ?? {}),
357
+ ...(registryModel?.headers ?? {}),
358
+ },
359
+ customEndpoint: url !== resolveCodexResponsesUrl(CODEX_BASE_URL),
360
+ };
361
+ }
362
+
328
363
  /**
329
364
  * Builds HTTP headers for Codex API requests.
330
365
  */
331
- function buildCodexHeaders(accessToken: string, accountId: string): Record<string, string> {
332
- return {
333
- Authorization: `Bearer ${accessToken}`,
334
- [OPENAI_HEADERS.ACCOUNT_ID]: accountId,
335
- [OPENAI_HEADERS.BETA]: OPENAI_HEADER_VALUES.BETA_RESPONSES,
336
- [OPENAI_HEADERS.ORIGINATOR]: OPENAI_HEADER_VALUES.ORIGINATOR_CODEX,
337
- [OPENAI_HEADERS.VERSION]: CODEX_CLIENT_VERSION,
338
- "User-Agent": `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`,
339
- Accept: "text/event-stream",
340
- "Content-Type": "application/json",
341
- };
366
+ function buildCodexHeaders(
367
+ accessToken: string,
368
+ accountId: string | undefined,
369
+ configuredHeaders: Record<string, string>,
370
+ ): Headers {
371
+ const headers = new Headers(configuredHeaders);
372
+ headers.delete("x-api-key");
373
+ headers.set("Authorization", `Bearer ${accessToken}`);
374
+ if (accountId) {
375
+ headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
376
+ } else {
377
+ headers.delete(OPENAI_HEADERS.ACCOUNT_ID);
378
+ }
379
+ headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
380
+ headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
381
+ headers.set(OPENAI_HEADERS.VERSION, CODEX_CLIENT_VERSION);
382
+ headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
383
+ headers.set("Accept", "text/event-stream");
384
+ headers.set("Content-Type", "application/json");
385
+ return headers;
342
386
  }
343
387
 
344
388
  /**
@@ -348,7 +392,7 @@ function buildCodexHeaders(accessToken: string, accountId: string): Record<strin
348
392
  * overrides from the default ChatGPT-account model-selection path.
349
393
  */
350
394
  async function callCodexSearch(
351
- auth: { accessToken: string; accountId: string },
395
+ auth: { accessToken: string; accountId?: string },
352
396
  query: string,
353
397
  options: {
354
398
  signal?: AbortSignal;
@@ -357,16 +401,10 @@ async function callCodexSearch(
357
401
  model: CodexModelCandidate;
358
402
  sessionId?: string;
359
403
  fetch?: FetchImpl;
404
+ transport: CodexSearchTransport;
360
405
  },
361
- ): Promise<{
362
- answer: string;
363
- sources: SearchSource[];
364
- model: string;
365
- requestId: string;
366
- usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
367
- }> {
368
- const url = `${CODEX_BASE_URL}${CODEX_RESPONSES_PATH}`;
369
- const headers = buildCodexHeaders(auth.accessToken, auth.accountId);
406
+ ): Promise<CodexSearchResult> {
407
+ const headers = buildCodexHeaders(auth.accessToken, auth.accountId, options.transport.headers);
370
408
 
371
409
  const requestedModel = options.model.modelId;
372
410
  const usesResponsesLite = options.model.catalogModel?.useResponsesLite === true;
@@ -397,15 +435,18 @@ async function callCodexSearch(
397
435
  requestKind: "turn",
398
436
  startNewTurn: true,
399
437
  });
400
- Object.assign(headers, metadata.headers);
401
- headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
438
+ for (const name in metadata.headers) {
439
+ const value = metadata.headers[name];
440
+ if (value !== undefined) headers.set(name, value);
441
+ }
442
+ headers.set(OPENAI_HEADERS.RESPONSES_LITE, "true");
402
443
  body.client_metadata = metadata.clientMetadata;
403
444
  body.reasoning = { context: "all_turns" };
404
445
  applyCodexResponsesLiteShape(body);
405
446
  }
406
447
 
407
448
  const fetchImpl = options.fetch ?? fetch;
408
- const response = await fetchImpl(url, {
449
+ const response = await fetchImpl(options.transport.url, {
409
450
  method: "POST",
410
451
  headers,
411
452
  body: JSON.stringify(body),
@@ -528,6 +569,39 @@ async function callCodexSearch(
528
569
  };
529
570
  }
530
571
 
572
+ async function runCodexSearchCandidates(options: {
573
+ auth: { accessToken: string; accountId?: string };
574
+ params: SearchParams;
575
+ modelCandidates: CodexModelCandidate[];
576
+ modelWasConfigured: boolean;
577
+ transport: CodexSearchTransport;
578
+ }): Promise<CodexSearchResult> {
579
+ let lastError: unknown;
580
+ for (let index = 0; index < options.modelCandidates.length; index += 1) {
581
+ const candidate = options.modelCandidates[index];
582
+ if (!candidate) continue;
583
+
584
+ try {
585
+ return await callCodexSearch(options.auth, options.params.query, {
586
+ signal: options.params.signal,
587
+ systemPrompt: options.params.systemPrompt,
588
+ searchContextSize: "high",
589
+ model: candidate,
590
+ sessionId: options.params.sessionId,
591
+ fetch: options.params.fetch,
592
+ transport: options.transport,
593
+ });
594
+ } catch (error) {
595
+ lastError = error;
596
+ const isLastCandidate = index === options.modelCandidates.length - 1;
597
+ if (options.modelWasConfigured || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
598
+ throw error;
599
+ }
600
+ }
601
+ }
602
+ throw lastError ?? new Error("Codex search failed without returning a result");
603
+ }
604
+
531
605
  /**
532
606
  * Executes a web search using OpenAI Codex's built-in web search tool.
533
607
  *
@@ -541,55 +615,81 @@ async function callCodexSearch(
541
615
  * rejects.
542
616
  */
543
617
  export async function searchCodex(params: SearchParams): Promise<SearchResponse> {
544
- const seed = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
545
- if (!seed) {
546
- throw new Error(
547
- "No Codex OAuth credentials found. Login with 'omp /login openai-codex' to enable Codex web search.",
548
- );
549
- }
550
-
551
618
  const configuredModel = getConfiguredModel();
552
619
  const modelCandidates = configuredModel ? [configuredModel] : getDefaultModelCandidates();
620
+ const firstCandidate = modelCandidates[0];
621
+ if (!firstCandidate) {
622
+ throw new SearchProviderError("codex", "No Codex web search model is configured.");
623
+ }
624
+ const transport = resolveCodexSearchTransport(params.modelRegistry, firstCandidate.modelId);
625
+
626
+ let result: CodexSearchResult;
627
+ if (transport.customEndpoint) {
628
+ // ModelRegistry resolves command-backed provider keys before consulting
629
+ // its AuthStorage, so a lower-priority OAuth origin is irrelevant when
630
+ // that command source is configured.
631
+ const credentialSource = params.modelRegistry?.authStorage ?? params.authStorage;
632
+ const credentialOrigin = credentialSource.getCredentialOrigin("openai-codex");
633
+ const hasCommandBackedKey = params.modelRegistry?.hasCommandBackedApiKey("openai-codex") === true;
634
+ if (!hasCommandBackedKey && (credentialOrigin?.kind === "oauth" || credentialOrigin?.kind === "env")) {
635
+ throw new SearchProviderError(
636
+ "codex",
637
+ `Refusing to send official Codex OAuth credentials to custom endpoint ${transport.baseUrl}. Configure an API key for provider "openai-codex".`,
638
+ );
639
+ }
553
640
 
554
- const result = await withOAuthAccess(
555
- params.authStorage,
556
- "openai-codex",
557
- async access => {
558
- // Derive ALL auth material from the access this attempt received —
559
- // a refreshed/rotated credential carries a different bearer and
560
- // ChatGPT account id than the seed.
561
- const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
562
- if (!accountId) {
563
- throw new Error("Codex OAuth credential is missing a ChatGPT account id");
564
- }
565
- const auth = { accessToken: access.accessToken, accountId };
566
-
567
- let lastError: unknown;
568
- for (let index = 0; index < modelCandidates.length; index += 1) {
569
- const candidate = modelCandidates[index];
570
- if (!candidate) continue;
571
-
572
- try {
573
- return await callCodexSearch(auth, params.query, {
574
- signal: params.signal,
575
- systemPrompt: params.systemPrompt,
576
- searchContextSize: "high",
577
- model: candidate,
578
- sessionId: params.sessionId,
579
- fetch: params.fetch,
580
- });
581
- } catch (error) {
582
- lastError = error;
583
- const isLastCandidate = index === modelCandidates.length - 1;
584
- if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
585
- throw error;
586
- }
641
+ const resolverOptions = {
642
+ sessionId: params.sessionId,
643
+ baseUrl: transport.baseUrl,
644
+ modelId: firstCandidate.modelId,
645
+ };
646
+ const keyOrResolver = params.modelRegistry
647
+ ? params.modelRegistry.resolver("openai-codex", resolverOptions)
648
+ : params.authStorage.resolver("openai-codex", resolverOptions);
649
+ result = await withAuth(
650
+ keyOrResolver,
651
+ accessToken =>
652
+ runCodexSearchCandidates({
653
+ auth: { accessToken },
654
+ params,
655
+ modelCandidates,
656
+ modelWasConfigured: configuredModel !== undefined,
657
+ transport,
658
+ }),
659
+ {
660
+ signal: params.signal,
661
+ missingKeyMessage: 'Codex credentials not found. Configure an API key for provider "openai-codex".',
662
+ },
663
+ );
664
+ } else {
665
+ const seed = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
666
+ if (!seed) {
667
+ throw new Error(
668
+ "No Codex OAuth credentials found. Login with 'omp /login openai-codex' to enable Codex web search.",
669
+ );
670
+ }
671
+
672
+ result = await withOAuthAccess(
673
+ params.authStorage,
674
+ "openai-codex",
675
+ access => {
676
+ // A refreshed/rotated credential can carry a different bearer and
677
+ // ChatGPT account id than the seed used to select the first attempt.
678
+ const accountId = access.accountId ?? getCodexAccountId(access.accessToken);
679
+ if (!accountId) {
680
+ throw new Error("Codex OAuth credential is missing a ChatGPT account id");
587
681
  }
588
- }
589
- throw lastError ?? new Error("Codex search failed without returning a result");
590
- },
591
- { sessionId: params.sessionId, signal: params.signal, seed: seed.access },
592
- );
682
+ return runCodexSearchCandidates({
683
+ auth: { accessToken: access.accessToken, accountId },
684
+ params,
685
+ modelCandidates,
686
+ modelWasConfigured: configuredModel !== undefined,
687
+ transport,
688
+ });
689
+ },
690
+ { sessionId: params.sessionId, signal: params.signal, seed: seed.access },
691
+ );
692
+ }
593
693
 
594
694
  let sources = result.sources;
595
695
 
@@ -615,14 +715,10 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
615
715
  }
616
716
 
617
717
  /**
618
- * Checks if Codex web search is available.
718
+ * Checks whether Codex web search has an API key or OAuth credential.
619
719
  */
620
720
  export async function hasCodexSearch(authStorage: AuthStorage): Promise<boolean> {
621
- // `isAvailable` runs before every request — keep the probe cheap.
622
- // `hasOAuth(...)` is a synchronous in-memory check that returns true as soon
623
- // as a Codex OAuth credential is loaded, without driving the refresh
624
- // pipeline. The actual refresh happens lazily in `searchCodex`.
625
- return authStorage.hasOAuth("openai-codex");
721
+ return authStorage.hasAuth("openai-codex");
626
722
  }
627
723
 
628
724
  /** Search provider for OpenAI Codex web search. */