@oh-my-pi/pi-coding-agent 15.3.2 → 15.4.1

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 (191) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/types/cli/file-processor.d.ts +1 -1
  3. package/dist/types/config/settings-schema.d.ts +45 -3
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/debug/raw-sse.d.ts +2 -0
  6. package/dist/types/edit/file-read-cache.d.ts +15 -4
  7. package/dist/types/edit/index.d.ts +3 -8
  8. package/dist/types/edit/renderer.d.ts +1 -2
  9. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  10. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  11. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  12. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  13. package/dist/types/eval/py/executor.d.ts +1 -2
  14. package/dist/types/eval/py/kernel.d.ts +6 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  16. package/dist/types/eval/session-id.d.ts +3 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  18. package/dist/types/hashline/anchors.d.ts +15 -9
  19. package/dist/types/hashline/constants.d.ts +0 -2
  20. package/dist/types/hashline/diff.d.ts +1 -2
  21. package/dist/types/hashline/executor.d.ts +52 -0
  22. package/dist/types/hashline/hash.d.ts +44 -93
  23. package/dist/types/hashline/index.d.ts +2 -1
  24. package/dist/types/hashline/input.d.ts +2 -9
  25. package/dist/types/hashline/recovery.d.ts +3 -9
  26. package/dist/types/hashline/tokenizer.d.ts +91 -0
  27. package/dist/types/hashline/types.d.ts +5 -7
  28. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  29. package/dist/types/modes/types.d.ts +1 -0
  30. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +2 -0
  32. package/dist/types/session/agent-session.d.ts +11 -15
  33. package/dist/types/session/agent-storage.d.ts +11 -10
  34. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  35. package/dist/types/slash-commands/types.d.ts +0 -5
  36. package/dist/types/task/executor.d.ts +2 -0
  37. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  38. package/dist/types/tools/index.d.ts +2 -8
  39. package/dist/types/tools/match-line-format.d.ts +4 -4
  40. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  41. package/dist/types/tools/review.d.ts +13 -0
  42. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  43. package/dist/types/tools/search.d.ts +4 -3
  44. package/dist/types/utils/edit-mode.d.ts +1 -1
  45. package/dist/types/web/kagi.d.ts +4 -2
  46. package/dist/types/web/parallel.d.ts +4 -3
  47. package/dist/types/web/scrapers/types.d.ts +2 -1
  48. package/dist/types/web/search/index.d.ts +12 -4
  49. package/dist/types/web/search/provider.d.ts +2 -1
  50. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  51. package/dist/types/web/search/providers/base.d.ts +34 -2
  52. package/dist/types/web/search/providers/brave.d.ts +8 -1
  53. package/dist/types/web/search/providers/codex.d.ts +13 -9
  54. package/dist/types/web/search/providers/exa.d.ts +10 -1
  55. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  56. package/dist/types/web/search/providers/jina.d.ts +2 -1
  57. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  58. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  59. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  60. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  61. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  62. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  63. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  64. package/dist/types/web/search/providers/utils.d.ts +8 -6
  65. package/dist/types/web/search/providers/zai.d.ts +12 -3
  66. package/package.json +7 -7
  67. package/src/cli/file-processor.ts +12 -2
  68. package/src/cli.ts +0 -8
  69. package/src/commands/commit.ts +8 -8
  70. package/src/config/prompt-templates.ts +6 -6
  71. package/src/config/settings-schema.ts +47 -3
  72. package/src/config/settings.ts +5 -5
  73. package/src/debug/raw-sse.ts +68 -3
  74. package/src/edit/file-read-cache.ts +68 -25
  75. package/src/edit/index.ts +6 -37
  76. package/src/edit/renderer.ts +9 -47
  77. package/src/edit/streaming.ts +43 -56
  78. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  79. package/src/eval/js/context-manager.ts +64 -53
  80. package/src/eval/js/shared/local-module-loader.ts +265 -0
  81. package/src/eval/js/shared/prelude.txt +4 -0
  82. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  83. package/src/eval/js/shared/runtime.ts +129 -86
  84. package/src/eval/js/worker-core.ts +23 -38
  85. package/src/eval/py/executor.ts +155 -84
  86. package/src/eval/py/kernel.ts +10 -1
  87. package/src/eval/py/prelude.py +22 -24
  88. package/src/eval/py/runner.py +203 -85
  89. package/src/eval/py/tool-bridge.ts +17 -10
  90. package/src/eval/session-id.ts +8 -0
  91. package/src/exec/bash-executor.ts +27 -16
  92. package/src/extensibility/extensions/runner.ts +0 -1
  93. package/src/extensibility/extensions/types.ts +1 -3
  94. package/src/hashline/anchors.ts +56 -65
  95. package/src/hashline/apply.ts +29 -31
  96. package/src/hashline/constants.ts +0 -3
  97. package/src/hashline/diff-preview.ts +4 -5
  98. package/src/hashline/diff.ts +30 -4
  99. package/src/hashline/execute.ts +91 -26
  100. package/src/hashline/executor.ts +239 -0
  101. package/src/hashline/grammar.lark +12 -10
  102. package/src/hashline/hash.ts +69 -114
  103. package/src/hashline/index.ts +2 -1
  104. package/src/hashline/input.ts +48 -41
  105. package/src/hashline/prefixes.ts +21 -11
  106. package/src/hashline/recovery.ts +63 -71
  107. package/src/hashline/stream.ts +2 -2
  108. package/src/hashline/tokenizer.ts +467 -0
  109. package/src/hashline/types.ts +6 -8
  110. package/src/internal-urls/docs-index.generated.ts +7 -7
  111. package/src/modes/components/extensions/types.ts +0 -5
  112. package/src/modes/components/session-observer-overlay.ts +11 -2
  113. package/src/modes/components/tree-selector.ts +10 -2
  114. package/src/modes/controllers/command-controller.ts +1 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  116. package/src/modes/controllers/selector-controller.ts +5 -5
  117. package/src/modes/types.ts +4 -1
  118. package/src/modes/utils/ui-helpers.ts +4 -0
  119. package/src/prompts/agents/explore.md +1 -1
  120. package/src/prompts/tools/ast-edit.md +1 -1
  121. package/src/prompts/tools/ast-grep.md +1 -1
  122. package/src/prompts/tools/eval.md +1 -1
  123. package/src/prompts/tools/hashline.md +73 -94
  124. package/src/prompts/tools/read.md +4 -4
  125. package/src/prompts/tools/search.md +3 -3
  126. package/src/sdk.ts +17 -23
  127. package/src/session/agent-session.ts +59 -66
  128. package/src/session/agent-storage.ts +13 -14
  129. package/src/slash-commands/acp-builtins.ts +3 -3
  130. package/src/slash-commands/types.ts +0 -6
  131. package/src/task/executor.ts +26 -57
  132. package/src/task/index.ts +8 -4
  133. package/src/tool-discovery/tool-index.ts +0 -134
  134. package/src/tools/ast-edit.ts +36 -13
  135. package/src/tools/ast-grep.ts +45 -4
  136. package/src/tools/browser/tab-worker.ts +3 -2
  137. package/src/tools/eval.ts +2 -1
  138. package/src/tools/fetch.ts +23 -14
  139. package/src/tools/index.ts +2 -8
  140. package/src/tools/irc.ts +59 -5
  141. package/src/tools/match-line-format.ts +5 -7
  142. package/src/tools/output-schema-validator.ts +132 -0
  143. package/src/tools/read.ts +142 -31
  144. package/src/tools/review.ts +23 -0
  145. package/src/tools/search-tool-bm25.ts +3 -30
  146. package/src/tools/search.ts +48 -16
  147. package/src/tools/write.ts +3 -3
  148. package/src/tools/yield.ts +32 -41
  149. package/src/utils/edit-mode.ts +1 -2
  150. package/src/utils/file-mentions.ts +2 -2
  151. package/src/web/kagi.ts +15 -6
  152. package/src/web/parallel.ts +9 -6
  153. package/src/web/scrapers/types.ts +7 -1
  154. package/src/web/scrapers/youtube.ts +13 -7
  155. package/src/web/search/index.ts +37 -11
  156. package/src/web/search/provider.ts +5 -3
  157. package/src/web/search/providers/anthropic.ts +30 -21
  158. package/src/web/search/providers/base.ts +35 -2
  159. package/src/web/search/providers/brave.ts +4 -4
  160. package/src/web/search/providers/codex.ts +118 -89
  161. package/src/web/search/providers/exa.ts +3 -2
  162. package/src/web/search/providers/gemini.ts +58 -155
  163. package/src/web/search/providers/jina.ts +4 -4
  164. package/src/web/search/providers/kagi.ts +17 -11
  165. package/src/web/search/providers/kimi.ts +29 -13
  166. package/src/web/search/providers/parallel.ts +171 -23
  167. package/src/web/search/providers/perplexity.ts +38 -37
  168. package/src/web/search/providers/searxng.ts +3 -1
  169. package/src/web/search/providers/synthetic.ts +16 -19
  170. package/src/web/search/providers/tavily.ts +23 -18
  171. package/src/web/search/providers/utils.ts +11 -17
  172. package/src/web/search/providers/zai.ts +16 -8
  173. package/dist/types/hashline/parser.d.ts +0 -7
  174. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  175. package/dist/types/tools/vim.d.ts +0 -58
  176. package/dist/types/vim/buffer.d.ts +0 -41
  177. package/dist/types/vim/commands.d.ts +0 -6
  178. package/dist/types/vim/engine.d.ts +0 -47
  179. package/dist/types/vim/parser.d.ts +0 -3
  180. package/dist/types/vim/render.d.ts +0 -25
  181. package/dist/types/vim/types.d.ts +0 -182
  182. package/src/hashline/parser.ts +0 -246
  183. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  184. package/src/prompts/tools/vim.md +0 -98
  185. package/src/tools/vim.ts +0 -949
  186. package/src/vim/buffer.ts +0 -309
  187. package/src/vim/commands.ts +0 -382
  188. package/src/vim/engine.ts +0 -2409
  189. package/src/vim/parser.ts +0 -134
  190. package/src/vim/render.ts +0 -252
  191. package/src/vim/types.ts +0 -197
@@ -8,6 +8,7 @@
8
8
  // The `label`/`id` metadata is kept inline so callers needing a display name
9
9
  // (error formatting, UI listings) do not force a load.
10
10
 
11
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
11
12
  import type { SearchProvider } from "./providers/base";
12
13
  import type { SearchProviderId } from "./types";
13
14
 
@@ -64,7 +65,7 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
64
65
  },
65
66
  codex: {
66
67
  id: "codex",
67
- label: "Codex",
68
+ label: "OpenAI",
68
69
  load: async () => new (await import("./providers/codex")).CodexProvider(),
69
70
  },
70
71
  tavily: {
@@ -148,13 +149,14 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"):
148
149
  * is walked, so unconfigured providers never pay the load cost.
149
150
  */
150
151
  export async function resolveProviderChain(
152
+ authStorage: AuthStorage,
151
153
  preferredProvider: SearchProviderId | "auto" = preferredProvId,
152
154
  ): Promise<SearchProvider[]> {
153
155
  const providers: SearchProvider[] = [];
154
156
 
155
157
  if (preferredProvider !== "auto") {
156
158
  const provider = await getSearchProvider(preferredProvider);
157
- if (await provider.isAvailable()) {
159
+ if (await provider.isAvailable(authStorage)) {
158
160
  providers.push(provider);
159
161
  }
160
162
  }
@@ -162,7 +164,7 @@ export async function resolveProviderChain(
162
164
  for (const id of SEARCH_PROVIDER_ORDER) {
163
165
  if (id === preferredProvider) continue;
164
166
  const provider = await getSearchProvider(id);
165
- if (await provider.isAvailable()) {
167
+ if (await provider.isAvailable(authStorage)) {
166
168
  providers.push(provider);
167
169
  }
168
170
  }
@@ -7,10 +7,11 @@
7
7
  import {
8
8
  type AnthropicAuthConfig,
9
9
  type AnthropicSystemBlock,
10
+ type AuthStorage,
11
+ buildAnthropicAuthConfig,
10
12
  buildAnthropicSearchHeaders,
11
13
  buildAnthropicSystemBlocks,
12
14
  buildAnthropicUrl,
13
- findAnthropicAuth,
14
15
  stripClaudeToolPrefix,
15
16
  } from "@oh-my-pi/pi-ai";
16
17
  import { $env } from "@oh-my-pi/pi-utils";
@@ -34,9 +35,7 @@ export interface AnthropicSearchParams {
34
35
  query: string;
35
36
  system_prompt?: string;
36
37
  num_results?: number;
37
- /** Maximum output tokens. Defaults to 4096. */
38
38
  max_tokens?: number;
39
- /** Sampling temperature (0–1). Lower = more focused/factual. */
40
39
  temperature?: number;
41
40
  signal?: AbortSignal;
42
41
  }
@@ -242,30 +241,47 @@ function parseResponse(response: AnthropicApiResponse): SearchResponse {
242
241
  * @returns Search response with synthesized answer, sources, and citations
243
242
  * @throws {Error} If no Anthropic credentials are configured
244
243
  */
245
- export async function searchAnthropic(params: AnthropicSearchParams): Promise<SearchResponse> {
246
- const auth = await findAnthropicAuth();
244
+ export async function searchAnthropic(
245
+ params: SearchParams | AnthropicSearchParams,
246
+ _legacyStorage?: unknown,
247
+ ): Promise<SearchResponse> {
248
+ const searchApiKey = $env.ANTHROPIC_SEARCH_API_KEY;
249
+ const searchBaseUrl = $env.ANTHROPIC_SEARCH_BASE_URL;
250
+ let auth: AnthropicAuthConfig | undefined;
251
+
252
+ if (searchApiKey) {
253
+ auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
254
+ } else if ("authStorage" in params) {
255
+ const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
256
+ signal: params.signal,
257
+ });
258
+ if (apiKey) auth = buildAnthropicAuthConfig(apiKey);
259
+ }
260
+
247
261
  if (!auth) {
248
262
  throw new Error(
249
- "No Anthropic credentials found. Set ANTHROPIC_API_KEY or configure OAuth in ~/.omp/agent/agent.db",
263
+ "No Anthropic credentials found. Set ANTHROPIC_SEARCH_API_KEY or ANTHROPIC_API_KEY, or configure Anthropic OAuth.",
250
264
  );
251
265
  }
252
266
 
253
267
  const model = getModel();
268
+ const systemPrompt = "authStorage" in params ? params.systemPrompt : params.system_prompt;
269
+ const maxTokens = "authStorage" in params ? params.maxOutputTokens : params.max_tokens;
254
270
  const response = await callSearch(
255
271
  auth,
256
272
  model,
257
273
  params.query,
258
- params.system_prompt,
259
- params.max_tokens,
274
+ systemPrompt,
275
+ maxTokens,
260
276
  params.temperature,
261
277
  params.signal,
262
278
  );
263
279
 
264
280
  const result = parseResponse(response);
265
281
 
266
- // Apply num_results limit if specified
267
- if (params.num_results && result.sources.length > params.num_results) {
268
- result.sources = result.sources.slice(0, params.num_results);
282
+ const numResults = "authStorage" in params ? (params.numSearchResults ?? params.limit) : params.num_results;
283
+ if (numResults && result.sources.length > numResults) {
284
+ result.sources = result.sources.slice(0, numResults);
269
285
  }
270
286
 
271
287
  return result;
@@ -276,18 +292,11 @@ export class AnthropicProvider extends SearchProvider {
276
292
  readonly id = "anthropic";
277
293
  readonly label = "Anthropic";
278
294
 
279
- isAvailable() {
280
- return findAnthropicAuth().then(Boolean);
295
+ isAvailable(authStorage: AuthStorage): Promise<boolean> | boolean {
296
+ return Boolean($env.ANTHROPIC_SEARCH_API_KEY) || authStorage.hasAuth("anthropic");
281
297
  }
282
298
 
283
299
  search(params: SearchParams): Promise<SearchResponse> {
284
- return searchAnthropic({
285
- query: params.query,
286
- system_prompt: params.systemPrompt,
287
- num_results: params.numSearchResults ?? params.limit,
288
- max_tokens: params.maxOutputTokens,
289
- temperature: params.temperature,
290
- signal: params.signal,
291
- });
300
+ return searchAnthropic(params);
292
301
  }
293
302
  }
@@ -1,6 +1,16 @@
1
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
1
2
  import type { SearchProviderId, SearchResponse } from "../types";
2
3
 
3
- /** Shared web search parameters passed to providers. */
4
+ /**
5
+ * Shared web search parameters passed to providers.
6
+ *
7
+ * `authStorage` is the **only** credential source providers may consult.
8
+ * Opening a sibling SQLite handle or calling provider-direct refresh helpers
9
+ * (e.g. `refreshOpenAICodexToken`, `refreshGoogleCloudToken`) is prohibited:
10
+ * it races the broker's per-credential refresh and POSTs the broker sentinel
11
+ * (`REMOTE_REFRESH_SENTINEL`) to the upstream token endpoint, which classifies
12
+ * as `invalid_grant` and disables the row.
13
+ */
4
14
  export interface SearchParams {
5
15
  query: string;
6
16
  limit?: number;
@@ -26,6 +36,20 @@ export interface SearchParams {
26
36
  googleSearch?: Record<string, unknown>;
27
37
  codeExecution?: Record<string, unknown>;
28
38
  urlContext?: Record<string, unknown>;
39
+ /**
40
+ * The single source of truth for credentials. Providers MUST consult this
41
+ * handle exclusively (`getApiKey` for bearer-style auth, `getOAuthAccess`
42
+ * when identity metadata is required). Do not open `AgentStorage` or any
43
+ * `AuthCredentialStore` directly — that bypasses the broker pipeline and
44
+ * the per-credential single-flight refresh.
45
+ */
46
+ authStorage: AuthStorage;
47
+ /**
48
+ * Optional session id used as the round-robin / sticky key when selecting
49
+ * among multiple credentials for the same provider. Pass through from the
50
+ * caller's agent session when available; otherwise omit.
51
+ */
52
+ sessionId?: string;
29
53
  }
30
54
 
31
55
  /** Base class for web search providers. */
@@ -33,6 +57,15 @@ export abstract class SearchProvider {
33
57
  abstract readonly id: SearchProviderId;
34
58
  abstract readonly label: string;
35
59
 
36
- abstract isAvailable(): Promise<boolean> | boolean;
60
+ /**
61
+ * Indicates whether this provider has the credentials/config it needs to
62
+ * service a request right now. Implementations consult the passed
63
+ * {@link AuthStorage} — never a sibling store.
64
+ */
65
+ abstract isAvailable(authStorage: AuthStorage): Promise<boolean> | boolean;
66
+
67
+ /**
68
+ * Execute a search. Credentials MUST be resolved through `params.authStorage`.
69
+ */
37
70
  abstract search(params: SearchParams): Promise<SearchResponse>;
38
71
  }
@@ -4,13 +4,13 @@
4
4
  * Calls Brave's web search REST API and maps results into the unified
5
5
  * SearchResponse shape used by the web search tool.
6
6
  */
7
- import { getEnvApiKey } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
9
9
  import { SearchProviderError } from "../../../web/search/types";
10
10
  import { clampNumResults, dateToAgeSeconds } from "../utils";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
- import { classifyProviderHttpError, isApiKeyAvailable, withHardTimeout } from "./utils";
13
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
14
14
 
15
15
  const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
16
16
  const DEFAULT_NUM_RESULTS = 10;
@@ -134,8 +134,8 @@ export class BraveProvider extends SearchProvider {
134
134
  readonly id = "brave";
135
135
  readonly label = "Brave";
136
136
 
137
- isAvailable() {
138
- return isApiKeyAvailable(findApiKey);
137
+ isAvailable(_authStorage: AuthStorage): boolean {
138
+ return !!findApiKey();
139
139
  }
140
140
 
141
141
  search(params: SearchParams): Promise<SearchResponse> {
@@ -2,15 +2,15 @@
2
2
  * OpenAI Codex Web Search Provider
3
3
  *
4
4
  * Uses Codex's built-in web_search tool via the Responses API.
5
- * Requires OAuth credentials stored in agent.db for provider "openai-codex".
6
- * Returns synthesized answers with web search sources.
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.
7
8
  */
8
9
  import * as os from "node:os";
9
- import { getBundledModels } from "@oh-my-pi/pi-ai";
10
+ import { type AuthStorage, getBundledModels } from "@oh-my-pi/pi-ai";
10
11
  import { decodeJwt } from "@oh-my-pi/pi-ai/utils/oauth/openai-codex";
11
- import { $env, getAgentDbPath, readSseJson } from "@oh-my-pi/pi-utils";
12
+ import { $env, readSseJson } from "@oh-my-pi/pi-utils";
12
13
  import packageJson from "../../../../package.json" with { type: "json" };
13
- import { AgentStorage } from "../../../session/agent-storage";
14
14
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
15
15
  import { SearchProviderError } from "../../../web/search/types";
16
16
  import type { SearchParams } from "./base";
@@ -19,29 +19,48 @@ import { classifyProviderHttpError, withHardTimeout } from "./utils";
19
19
 
20
20
  const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
21
21
  const CODEX_RESPONSES_PATH = "/codex/responses";
22
- const FALLBACK_MODEL = "gpt-5-codex-mini";
22
+ const FALLBACK_MODEL = "gpt-5.4";
23
23
  const DEFAULT_MODEL_PREFERENCES = [
24
- "gpt-5-codex-mini",
25
24
  "gpt-5.4",
25
+ "gpt-5-codex",
26
+ "gpt-5",
26
27
  "gpt-5.3-codex",
27
28
  "gpt-5.2-codex",
28
29
  "gpt-5.1-codex",
29
- "gpt-5-codex",
30
+ "gpt-5-codex-mini",
30
31
  ];
31
32
  const JWT_CLAIM_PATH = "https://api.openai.com/auth";
32
33
  const DEFAULT_INSTRUCTIONS =
33
34
  "You are a helpful assistant with web search capabilities. Search the web to answer the user's question accurately and cite your sources.";
34
35
 
35
- function getModel(): string {
36
+ function getConfiguredModel(): string | undefined {
36
37
  const configuredModel = $env.PI_CODEX_WEB_SEARCH_MODEL?.trim();
37
- if (configuredModel) return configuredModel;
38
+ return configuredModel ? configuredModel : undefined;
39
+ }
38
40
 
41
+ function getDefaultModelCandidates(): string[] {
39
42
  const bundledModels = getBundledModels("openai-codex");
40
43
  const bundledIds = new Set(bundledModels.map(model => model.id));
41
- const preferred = DEFAULT_MODEL_PREFERENCES.find(modelId => bundledIds.has(modelId));
42
- if (preferred) return preferred;
44
+ const candidates = DEFAULT_MODEL_PREFERENCES.filter(modelId => bundledIds.has(modelId));
45
+
46
+ if (candidates.length > 0) {
47
+ return candidates;
48
+ }
49
+
43
50
  const nonMini = bundledModels.find(model => !model.id.includes("mini") && !model.id.includes("spark"));
44
- return nonMini?.id ?? bundledModels[0]?.id ?? FALLBACK_MODEL;
51
+ if (nonMini) {
52
+ return [nonMini.id];
53
+ }
54
+
55
+ return bundledModels[0]?.id ? [bundledModels[0].id] : [FALLBACK_MODEL];
56
+ }
57
+
58
+ function shouldRetryWithNextDefaultModel(error: unknown): boolean {
59
+ if (!(error instanceof SearchProviderError)) return false;
60
+ if (error.provider !== "codex" || error.status !== 400) return false;
61
+ return /model is not supported|requested model is not supported|not supported when using codex with a chatgpt account/i.test(
62
+ error.message,
63
+ );
45
64
  }
46
65
 
47
66
  export interface CodexSearchParams {
@@ -53,15 +72,6 @@ export interface CodexSearchParams {
53
72
  search_context_size?: "low" | "medium" | "high";
54
73
  }
55
74
 
56
- /** OAuth credential stored in agent.db */
57
- interface CodexOAuthCredential {
58
- type: "oauth";
59
- access: string;
60
- refresh?: string;
61
- expires: number;
62
- accountId?: string;
63
- }
64
-
65
75
  /** Codex API response structure */
66
76
  interface CodexResponseItem {
67
77
  type: string;
@@ -231,7 +241,7 @@ function extractTextSources(text: string): SearchSource[] {
231
241
  * @param accessToken - JWT access token
232
242
  * @returns Account ID string, or null if not found
233
243
  */
234
- function getAccountId(accessToken: string): string | null {
244
+ function getAccountIdFromJwt(accessToken: string): string | null {
235
245
  const payload = decodeJwt(accessToken);
236
246
  const auth = payload?.[JWT_CLAIM_PATH] as { chatgpt_account_id?: string } | undefined;
237
247
  const accountId = auth?.chatgpt_account_id;
@@ -239,43 +249,25 @@ function getAccountId(accessToken: string): string | null {
239
249
  }
240
250
 
241
251
  /**
242
- * Finds valid Codex OAuth credentials from agent.db.
243
- * Checks agent credentials and returns the first non-expired credential.
244
- * @returns OAuth credential with access token and account ID, or null if none found
252
+ * Resolve a Codex bearer + accountId through {@link AuthStorage} — the single
253
+ * refresh authority. Returns `null` when no OAuth credential is configured,
254
+ * when the credential cannot be refreshed (broker error, revoked token, etc.),
255
+ * or when the access token carries no `chatgpt_account_id` claim.
245
256
  */
246
- async function findCodexAuth(): Promise<{ accessToken: string; accountId: string } | null> {
247
- const expiryBuffer = 5 * 60 * 1000; // 5 minutes
248
- const now = Date.now();
249
-
250
- try {
251
- const storage = await AgentStorage.open(getAgentDbPath());
252
- const records = storage.listAuthCredentials("openai-codex");
253
-
254
- for (const record of records) {
255
- const credential = record.credential;
256
- if (credential.type !== "oauth") continue;
257
-
258
- const oauthCred = credential as CodexOAuthCredential;
259
- if (!oauthCred.access) continue;
260
- if (oauthCred.expires <= now + expiryBuffer) continue;
261
-
262
- const accountId = oauthCred.accountId ?? getAccountId(oauthCred.access);
263
- if (!accountId) continue;
264
-
265
- return { accessToken: oauthCred.access, accountId };
266
- }
267
- } catch {
268
- return null;
269
- }
270
-
271
- return null;
257
+ async function findCodexAuth(
258
+ authStorage: AuthStorage,
259
+ sessionId: string | undefined,
260
+ signal: AbortSignal | undefined,
261
+ ): Promise<{ accessToken: string; accountId: string } | null> {
262
+ const access = await authStorage.getOAuthAccess("openai-codex", sessionId, { signal });
263
+ if (!access) return null;
264
+ const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
265
+ if (!accountId) return null;
266
+ return { accessToken: access.accessToken, accountId };
272
267
  }
273
268
 
274
269
  /**
275
270
  * Builds HTTP headers for Codex API requests.
276
- * @param accessToken - OAuth access token
277
- * @param accountId - ChatGPT account ID
278
- * @returns Headers object for fetch requests
279
271
  */
280
272
  function buildCodexHeaders(accessToken: string, accountId: string): Record<string, string> {
281
273
  return {
@@ -291,17 +283,19 @@ function buildCodexHeaders(accessToken: string, accountId: string): Record<strin
291
283
 
292
284
  /**
293
285
  * Calls the Codex Responses API with web search tool enabled.
294
- * Streams the response and collects all events.
295
- * @param auth - Authentication info (access token and account ID)
296
- * @param query - Search query from the user
297
- * @param options - Search options including system prompt and context size
298
- * @returns Parsed response with answer, sources, and usage
299
- * @throws {SearchProviderError} If the API request fails
286
+ * The caller provides the exact model id to send; retry / fallback policy
287
+ * lives one layer up in `searchCodex()` so we can distinguish explicit user
288
+ * overrides from the default ChatGPT-account model-selection path.
300
289
  */
301
290
  async function callCodexSearch(
302
291
  auth: { accessToken: string; accountId: string },
303
292
  query: string,
304
- options: { signal?: AbortSignal; systemPrompt?: string; searchContextSize?: "low" | "medium" | "high" },
293
+ options: {
294
+ signal?: AbortSignal;
295
+ systemPrompt?: string;
296
+ searchContextSize?: "low" | "medium" | "high";
297
+ modelId: string;
298
+ },
305
299
  ): Promise<{
306
300
  answer: string;
307
301
  sources: SearchSource[];
@@ -312,7 +306,7 @@ async function callCodexSearch(
312
306
  const url = `${CODEX_BASE_URL}${CODEX_RESPONSES_PATH}`;
313
307
  const headers = buildCodexHeaders(auth.accessToken, auth.accountId);
314
308
 
315
- const requestedModel = getModel();
309
+ const requestedModel = options.modelId;
316
310
 
317
311
  const body: Record<string, unknown> = {
318
312
  model: requestedModel,
@@ -457,29 +451,67 @@ async function callCodexSearch(
457
451
 
458
452
  /**
459
453
  * Executes a web search using OpenAI Codex's built-in web search tool.
460
- * Requires OAuth credentials stored in agent.db for provider "openai-codex".
461
- * @param params - Search parameters including query and optional settings
462
- * @returns Search response with synthesized answer, sources, and usage
463
- * @throws {Error} If no Codex OAuth credentials are configured
454
+ *
455
+ * Default-model behavior:
456
+ * - If `PI_CODEX_WEB_SEARCH_MODEL` is set, use it exactly once and surface any
457
+ * upstream error verbatim.
458
+ * - Otherwise prefer ChatGPT-account-safe bundled defaults (GPT-5.4, GPT-5
459
+ * Codex, GPT-5, …) and retry the next candidate only when Codex returns the
460
+ * known 400 "model is not supported" family. This avoids selecting
461
+ * `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI rejects.
464
462
  */
465
- export async function searchCodex(params: CodexSearchParams): Promise<SearchResponse> {
466
- const auth = await findCodexAuth();
463
+ export async function searchCodex(params: SearchParams): Promise<SearchResponse> {
464
+ const auth = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
467
465
  if (!auth) {
468
466
  throw new Error(
469
467
  "No Codex OAuth credentials found. Login with 'omp /login openai-codex' to enable Codex web search.",
470
468
  );
471
469
  }
472
470
 
473
- const result = await callCodexSearch(auth, params.query, {
474
- systemPrompt: params.system_prompt,
475
- searchContextSize: params.search_context_size ?? "high",
476
- });
471
+ const configuredModel = getConfiguredModel();
472
+ const modelCandidates = configuredModel ? [configuredModel] : getDefaultModelCandidates();
473
+
474
+ let result:
475
+ | {
476
+ answer: string;
477
+ sources: SearchSource[];
478
+ model: string;
479
+ requestId: string;
480
+ usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
481
+ }
482
+ | undefined;
483
+ let lastError: unknown;
484
+
485
+ for (let index = 0; index < modelCandidates.length; index += 1) {
486
+ const modelId = modelCandidates[index];
487
+ if (!modelId) continue;
488
+
489
+ try {
490
+ result = await callCodexSearch(auth, params.query, {
491
+ signal: params.signal,
492
+ systemPrompt: params.systemPrompt,
493
+ searchContextSize: "high",
494
+ modelId,
495
+ });
496
+ break;
497
+ } catch (error) {
498
+ lastError = error;
499
+ const isLastCandidate = index === modelCandidates.length - 1;
500
+ if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
501
+ throw error;
502
+ }
503
+ }
504
+ }
505
+
506
+ if (!result) {
507
+ throw lastError ?? new Error("Codex search failed without returning a result");
508
+ }
477
509
 
478
510
  let sources = result.sources;
479
511
 
480
- // Apply num_results limit if specified
481
- if (params.num_results && sources.length > params.num_results) {
482
- sources = sources.slice(0, params.num_results);
512
+ const numResults = params.numSearchResults ?? params.limit;
513
+ if (numResults && sources.length > numResults) {
514
+ sources = sources.slice(0, numResults);
483
515
  }
484
516
 
485
517
  return {
@@ -500,28 +532,25 @@ export async function searchCodex(params: CodexSearchParams): Promise<SearchResp
500
532
 
501
533
  /**
502
534
  * Checks if Codex web search is available.
503
- * @returns True if valid OAuth credentials exist for openai-codex
504
535
  */
505
- export async function hasCodexSearch(): Promise<boolean> {
506
- const auth = await findCodexAuth();
507
- return auth !== null;
536
+ export async function hasCodexSearch(authStorage: AuthStorage): Promise<boolean> {
537
+ // `isAvailable` runs before every request — keep the probe cheap.
538
+ // `hasOAuth(...)` is a synchronous in-memory check that returns true as soon
539
+ // as a Codex OAuth credential is loaded, without driving the refresh
540
+ // pipeline. The actual refresh happens lazily in `searchCodex`.
541
+ return authStorage.hasOAuth("openai-codex");
508
542
  }
509
543
 
510
544
  /** Search provider for OpenAI Codex web search. */
511
545
  export class CodexProvider extends SearchProvider {
512
546
  readonly id = "codex";
513
- readonly label = "Codex";
547
+ readonly label = "OpenAI";
514
548
 
515
- isAvailable(): Promise<boolean> {
516
- return Promise.resolve(hasCodexSearch());
549
+ isAvailable(authStorage: AuthStorage): Promise<boolean> | boolean {
550
+ return hasCodexSearch(authStorage);
517
551
  }
518
552
 
519
553
  search(params: SearchParams): Promise<SearchResponse> {
520
- return searchCodex({
521
- signal: params.signal,
522
- query: params.query,
523
- system_prompt: params.systemPrompt,
524
- num_results: params.numSearchResults ?? params.limit,
525
- });
554
+ return searchCodex(params);
526
555
  }
527
556
  }
@@ -6,9 +6,10 @@
6
6
  * Requests per-result summaries via `contents.summary` and synthesizes
7
7
  * them into a combined `answer` string on the SearchResponse.
8
8
  */
9
- import { getEnvApiKey } from "@oh-my-pi/pi-ai";
9
+ import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
10
10
  import { settings } from "../../../config/settings";
11
11
  import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
12
+
12
13
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
13
14
  import { SearchProviderError } from "../../../web/search/types";
14
15
  import { dateToAgeSeconds } from "../utils";
@@ -249,7 +250,7 @@ export class ExaProvider extends SearchProvider {
249
250
  readonly id = "exa";
250
251
  readonly label = "Exa";
251
252
 
252
- isAvailable(): boolean {
253
+ isAvailable(_authStorage: AuthStorage): boolean {
253
254
  try {
254
255
  if (settings.get("exa.enabled") === false || settings.get("exa.enableSearch") === false) {
255
256
  return false;