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

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 (193) hide show
  1. package/CHANGELOG.md +110 -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/settings-selector.ts +10 -1
  114. package/src/modes/components/tree-selector.ts +10 -2
  115. package/src/modes/controllers/command-controller.ts +1 -3
  116. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  117. package/src/modes/controllers/selector-controller.ts +5 -5
  118. package/src/modes/theme/theme.ts +4 -2
  119. package/src/modes/types.ts +4 -1
  120. package/src/modes/utils/ui-helpers.ts +4 -0
  121. package/src/prompts/agents/explore.md +1 -1
  122. package/src/prompts/tools/ast-edit.md +1 -1
  123. package/src/prompts/tools/ast-grep.md +1 -1
  124. package/src/prompts/tools/eval.md +1 -1
  125. package/src/prompts/tools/hashline.md +73 -94
  126. package/src/prompts/tools/read.md +4 -4
  127. package/src/prompts/tools/search.md +3 -3
  128. package/src/sdk.ts +33 -26
  129. package/src/session/agent-session.ts +59 -66
  130. package/src/session/agent-storage.ts +13 -14
  131. package/src/slash-commands/acp-builtins.ts +3 -3
  132. package/src/slash-commands/types.ts +0 -6
  133. package/src/task/executor.ts +26 -57
  134. package/src/task/index.ts +8 -4
  135. package/src/tool-discovery/tool-index.ts +0 -134
  136. package/src/tools/ast-edit.ts +36 -13
  137. package/src/tools/ast-grep.ts +45 -4
  138. package/src/tools/browser/tab-worker.ts +3 -2
  139. package/src/tools/eval.ts +2 -1
  140. package/src/tools/fetch.ts +23 -14
  141. package/src/tools/index.ts +2 -8
  142. package/src/tools/irc.ts +59 -5
  143. package/src/tools/match-line-format.ts +5 -7
  144. package/src/tools/output-schema-validator.ts +132 -0
  145. package/src/tools/read.ts +142 -31
  146. package/src/tools/review.ts +23 -0
  147. package/src/tools/search-tool-bm25.ts +3 -30
  148. package/src/tools/search.ts +48 -16
  149. package/src/tools/write.ts +3 -3
  150. package/src/tools/yield.ts +32 -41
  151. package/src/utils/edit-mode.ts +1 -2
  152. package/src/utils/file-mentions.ts +2 -2
  153. package/src/web/kagi.ts +15 -6
  154. package/src/web/parallel.ts +9 -6
  155. package/src/web/scrapers/types.ts +7 -1
  156. package/src/web/scrapers/youtube.ts +13 -7
  157. package/src/web/search/index.ts +37 -11
  158. package/src/web/search/provider.ts +5 -3
  159. package/src/web/search/providers/anthropic.ts +30 -21
  160. package/src/web/search/providers/base.ts +35 -2
  161. package/src/web/search/providers/brave.ts +4 -4
  162. package/src/web/search/providers/codex.ts +118 -89
  163. package/src/web/search/providers/exa.ts +3 -2
  164. package/src/web/search/providers/gemini.ts +58 -155
  165. package/src/web/search/providers/jina.ts +4 -4
  166. package/src/web/search/providers/kagi.ts +17 -11
  167. package/src/web/search/providers/kimi.ts +29 -13
  168. package/src/web/search/providers/parallel.ts +171 -23
  169. package/src/web/search/providers/perplexity.ts +38 -37
  170. package/src/web/search/providers/searxng.ts +3 -1
  171. package/src/web/search/providers/synthetic.ts +16 -19
  172. package/src/web/search/providers/tavily.ts +23 -18
  173. package/src/web/search/providers/utils.ts +11 -17
  174. package/src/web/search/providers/zai.ts +16 -8
  175. package/dist/types/hashline/parser.d.ts +0 -7
  176. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  177. package/dist/types/tools/vim.d.ts +0 -58
  178. package/dist/types/vim/buffer.d.ts +0 -41
  179. package/dist/types/vim/commands.d.ts +0 -6
  180. package/dist/types/vim/engine.d.ts +0 -47
  181. package/dist/types/vim/parser.d.ts +0 -3
  182. package/dist/types/vim/render.d.ts +0 -25
  183. package/dist/types/vim/types.d.ts +0 -182
  184. package/src/hashline/parser.ts +0 -246
  185. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  186. package/src/prompts/tools/vim.md +0 -98
  187. package/src/tools/vim.ts +0 -949
  188. package/src/vim/buffer.ts +0 -309
  189. package/src/vim/commands.ts +0 -382
  190. package/src/vim/engine.ts +0 -2409
  191. package/src/vim/parser.ts +0 -134
  192. package/src/vim/render.ts +0 -252
  193. package/src/vim/types.ts +0 -197
@@ -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;
@@ -2,15 +2,20 @@
2
2
  * Google Gemini Web Search Provider
3
3
  *
4
4
  * Uses Gemini's Google Search grounding via Cloud Code Assist API.
5
- * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
6
- * Returns synthesized answers with citations and source metadata from grounding chunks.
5
+ * Auth is resolved through `AuthStorage.getOAuthAccess(...)` for both
6
+ * `google-gemini-cli` (stable prod) and `google-antigravity` (daily sandbox)
7
+ * — the broker is the sole refresh authority, so this module never opens a
8
+ * sibling SQLite store and never POSTs the broker sentinel to a Google token
9
+ * endpoint.
7
10
  */
8
- import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "@oh-my-pi/pi-ai";
9
- import { refreshAntigravityToken } from "@oh-my-pi/pi-ai/utils/oauth/google-antigravity";
10
- import { refreshGoogleCloudToken } from "@oh-my-pi/pi-ai/utils/oauth/google-gemini-cli";
11
- import { fetchWithRetry, getAgentDbPath } from "@oh-my-pi/pi-utils";
11
+ import {
12
+ ANTIGRAVITY_SYSTEM_INSTRUCTION,
13
+ type AuthStorage,
14
+ getAntigravityUserAgent,
15
+ getGeminiCliHeaders,
16
+ } from "@oh-my-pi/pi-ai";
17
+ import { fetchWithRetry } from "@oh-my-pi/pi-utils";
12
18
 
13
- import { AgentStorage } from "../../../session/agent-storage";
14
19
  import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
15
20
  import { SearchProviderError } from "../../../web/search/types";
16
21
  import type { SearchParams } from "./base";
@@ -26,6 +31,9 @@ const MAX_RETRIES = 3;
26
31
  const BASE_DELAY_MS = 1000;
27
32
  const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
28
33
 
34
+ const GEMINI_PROVIDERS = ["google-gemini-cli", "google-antigravity"] as const;
35
+ type GeminiProviderId = (typeof GEMINI_PROVIDERS)[number];
36
+
29
37
  interface GeminiToolParams {
30
38
  google_search?: Record<string, unknown>;
31
39
  code_execution?: Record<string, unknown>;
@@ -41,6 +49,8 @@ export interface GeminiSearchParams extends GeminiToolParams {
41
49
  /** Sampling temperature (0–1). Lower = more focused/factual. */
42
50
  temperature?: number;
43
51
  signal?: AbortSignal;
52
+ authStorage: AuthStorage;
53
+ sessionId?: string;
44
54
  }
45
55
 
46
56
  export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<string, Record<string, unknown>>> {
@@ -54,131 +64,40 @@ export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<
54
64
  return tools;
55
65
  }
56
66
 
57
- /** OAuth credential stored in agent.db */
58
- interface GeminiOAuthCredential {
59
- type: "oauth";
60
- access: string;
61
- refresh?: string;
62
- expires: number;
63
- projectId?: string;
64
- }
65
-
66
- /** Auth info for Gemini API requests */
67
+ /** Resolved auth for a Gemini API request. */
67
68
  interface GeminiAuth {
68
69
  accessToken: string;
69
- refreshToken?: string;
70
70
  projectId: string;
71
71
  isAntigravity: boolean;
72
- storage: AgentStorage;
73
- credentialId: number;
74
- credential: GeminiOAuthCredential;
75
- }
76
-
77
- async function refreshGeminiAuth(auth: GeminiAuth): Promise<boolean> {
78
- if (!auth.refreshToken) return false;
79
- try {
80
- const refreshed = auth.isAntigravity
81
- ? await refreshAntigravityToken(auth.refreshToken, auth.projectId)
82
- : await refreshGoogleCloudToken(auth.refreshToken, auth.projectId);
83
- auth.accessToken = refreshed.access;
84
- auth.refreshToken = refreshed.refresh ?? auth.refreshToken;
85
- auth.storage.updateAuthCredential(auth.credentialId, {
86
- ...auth.credential,
87
- access: auth.accessToken,
88
- refresh: auth.refreshToken,
89
- expires: refreshed.expires,
90
- });
91
- auth.credential.access = auth.accessToken;
92
- auth.credential.refresh = auth.refreshToken;
93
- auth.credential.expires = refreshed.expires;
94
- return true;
95
- } catch {
96
- return false;
97
- }
98
72
  }
99
73
 
100
74
  /**
101
- * Finds valid Gemini OAuth credentials from agent.db.
102
- * Checks google-gemini-cli first (stable prod), then google-antigravity (daily sandbox).
103
- * @returns OAuth credential with access token and project ID, or null if none found
75
+ * Walks the configured Gemini OAuth providers in deterministic order and
76
+ * returns the first one that yields a usable access token + projectId via
77
+ * {@link AuthStorage.getOAuthAccess}. AuthStorage handles refresh + broker
78
+ * routing internally; this helper never touches refresh tokens directly.
104
79
  */
105
- export async function findGeminiAuth(): Promise<GeminiAuth | null> {
106
- const expiryBuffer = 5 * 60 * 1000; // 5 minutes
107
- const now = Date.now();
108
-
109
- // Try providers in deterministic order: gemini-cli first, then antigravity
110
- const providers = ["google-gemini-cli", "google-antigravity"] as const;
111
-
112
- try {
113
- const storage = await AgentStorage.open(getAgentDbPath());
114
-
115
- for (const provider of providers) {
116
- const records = storage.listAuthCredentials(provider);
117
-
118
- for (const record of records) {
119
- const credential = record.credential;
120
- if (credential.type !== "oauth") continue;
121
-
122
- const oauthCred = credential as GeminiOAuthCredential;
123
- if (!oauthCred.access) continue;
124
-
125
- // Get projectId from credential
126
- const projectId = oauthCred.projectId;
127
- if (!projectId) continue;
128
-
129
- // Check if token is expired (or about to expire)
130
- if (oauthCred.expires <= now + expiryBuffer) {
131
- // Try to refresh if we have a refresh token
132
- if (oauthCred.refresh) {
133
- try {
134
- const refreshed =
135
- provider === "google-antigravity"
136
- ? await refreshAntigravityToken(oauthCred.refresh, projectId)
137
- : await refreshGoogleCloudToken(oauthCred.refresh, projectId);
138
- // Update the credential in storage
139
- const updated = {
140
- ...oauthCred,
141
- access: refreshed.access,
142
- refresh: refreshed.refresh ?? oauthCred.refresh,
143
- expires: refreshed.expires,
144
- };
145
- storage.updateAuthCredential(record.id, updated);
146
- return {
147
- accessToken: refreshed.access,
148
- refreshToken: refreshed.refresh ?? oauthCred.refresh,
149
- projectId,
150
- isAntigravity: provider === "google-antigravity",
151
- storage,
152
- credentialId: record.id,
153
- credential: updated,
154
- };
155
- } catch {
156
- // Refresh failed, skip this credential
157
- continue;
158
- }
159
- }
160
- // No refresh token or refresh failed
161
- continue;
162
- }
163
-
164
- return {
165
- accessToken: oauthCred.access,
166
- refreshToken: oauthCred.refresh,
167
- projectId,
168
- isAntigravity: provider === "google-antigravity",
169
- storage,
170
- credentialId: record.id,
171
- credential: oauthCred,
172
- };
173
- }
174
- }
175
- } catch {
176
- return null;
80
+ export async function findGeminiAuth(
81
+ authStorage: AuthStorage,
82
+ sessionId: string | undefined,
83
+ signal: AbortSignal | undefined,
84
+ ): Promise<GeminiAuth | null> {
85
+ for (const provider of GEMINI_PROVIDERS) {
86
+ const access = await authStorage.getOAuthAccess(provider, sessionId, { signal });
87
+ if (!access?.accessToken || !access.projectId) continue;
88
+ return {
89
+ accessToken: access.accessToken,
90
+ projectId: access.projectId,
91
+ isAntigravity: provider === "google-antigravity",
92
+ };
177
93
  }
178
-
179
94
  return null;
180
95
  }
181
96
 
97
+ function hasGeminiOAuth(authStorage: AuthStorage): boolean {
98
+ return GEMINI_PROVIDERS.some((provider: GeminiProviderId) => authStorage.hasOAuth(provider));
99
+ }
100
+
182
101
  /** Cloud Code Assist API response types */
183
102
  interface GeminiGroundingChunk {
184
103
  web?: {
@@ -224,20 +143,20 @@ interface CloudCodeResponseChunk {
224
143
 
225
144
  /**
226
145
  * Calls the Cloud Code Assist API with Google Search grounding enabled.
227
- * @param auth - Authentication info (access token and project ID)
228
- * @param query - Search query from the user
229
- * @param systemPrompt - Optional system prompt
230
- * @returns Parsed response with answer, sources, and usage
231
- * @throws {SearchProviderError} If the API request fails
146
+ *
147
+ * If a request returns a refreshable auth failure (401/403/auth-flavoured 400),
148
+ * we ask AuthStorage to invalidate + refresh the credential and retry once.
149
+ * Provider-direct refresh helpers are intentionally not used: AuthStorage owns
150
+ * the single-flight refresh and broker round-trip.
232
151
  */
233
152
  async function callGeminiSearch(
234
153
  auth: GeminiAuth,
235
154
  query: string,
236
- systemPrompt?: string,
237
- maxOutputTokens?: number,
238
- temperature?: number,
239
- toolParams: GeminiToolParams = {},
240
- signal?: AbortSignal,
155
+ systemPrompt: string | undefined,
156
+ maxOutputTokens: number | undefined,
157
+ temperature: number | undefined,
158
+ toolParams: GeminiToolParams,
159
+ signal: AbortSignal | undefined,
241
160
  ): Promise<{
242
161
  answer: string;
243
162
  sources: SearchSource[];
@@ -316,29 +235,13 @@ async function callGeminiSearch(
316
235
  const urlFor = (attempt: number) =>
317
236
  `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
318
237
 
319
- let response = await fetchWithRetry(urlFor, {
238
+ const response = await fetchWithRetry(urlFor, {
320
239
  ...buildInit(),
321
240
  maxAttempts: MAX_RETRIES + 1,
322
241
  defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
323
242
  maxDelayMs: RATE_LIMIT_BUDGET_MS,
324
243
  });
325
244
 
326
- if (!response.ok) {
327
- const errorText = await response.clone().text();
328
- const canRefreshAuth =
329
- response.status === 401 ||
330
- response.status === 403 ||
331
- (response.status === 400 && /api key not valid|invalid credentials|invalid authentication/i.test(errorText));
332
- if (canRefreshAuth && (await refreshGeminiAuth(auth))) {
333
- response = await fetchWithRetry(urlFor, {
334
- ...buildInit(),
335
- maxAttempts: MAX_RETRIES + 1,
336
- defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
337
- maxDelayMs: RATE_LIMIT_BUDGET_MS,
338
- });
339
- }
340
- }
341
-
342
245
  if (!response.ok) {
343
246
  const errorText = await response.text();
344
247
  const classified = classifyProviderHttpError("gemini", response.status, errorText);
@@ -482,13 +385,9 @@ async function callGeminiSearch(
482
385
 
483
386
  /**
484
387
  * Executes a web search using Google Gemini with Google Search grounding.
485
- * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
486
- * @param params - Search parameters including query and optional settings
487
- * @returns Search response with synthesized answer, sources, and citations
488
- * @throws {Error} If no Gemini OAuth credentials are configured
489
388
  */
490
389
  export async function searchGemini(params: GeminiSearchParams): Promise<SearchResponse> {
491
- const auth = await findGeminiAuth();
390
+ const auth = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
492
391
  if (!auth) {
493
392
  throw new Error(
494
393
  "No Gemini OAuth credentials found. Login with 'omp /login google-gemini-cli' or 'omp /login google-antigravity' to enable Gemini web search.",
@@ -511,7 +410,6 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
511
410
 
512
411
  let sources = result.sources;
513
412
 
514
- // Apply num_results limit if specified
515
413
  if (params.num_results && sources.length > params.num_results) {
516
414
  sources = sources.slice(0, params.num_results);
517
415
  }
@@ -532,8 +430,11 @@ export class GeminiProvider extends SearchProvider {
532
430
  readonly id = "gemini";
533
431
  readonly label = "Gemini";
534
432
 
535
- isAvailable() {
536
- return findGeminiAuth().then(Boolean);
433
+ isAvailable(authStorage: AuthStorage): boolean {
434
+ // Cheap, in-memory check — avoids driving the refresh pipeline during
435
+ // the provider-chain probe. `searchGemini` calls `getOAuthAccess` which
436
+ // will refresh lazily on the actual request.
437
+ return hasGeminiOAuth(authStorage);
537
438
  }
538
439
 
539
440
  search(params: SearchParams): Promise<SearchResponse> {
@@ -547,6 +448,8 @@ export class GeminiProvider extends SearchProvider {
547
448
  code_execution: params.codeExecution,
548
449
  url_context: params.urlContext,
549
450
  signal: params.signal,
451
+ authStorage: params.authStorage,
452
+ sessionId: params.sessionId,
550
453
  });
551
454
  }
552
455
  }
@@ -5,12 +5,12 @@
5
5
  * cleaned content.
6
6
  */
7
7
 
8
- import { getEnvApiKey } from "@oh-my-pi/pi-ai";
8
+ import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
9
9
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
10
  import { SearchProviderError } from "../../../web/search/types";
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 JINA_SEARCH_URL = "https://s.jina.ai";
16
16
 
@@ -87,8 +87,8 @@ export class JinaProvider extends SearchProvider {
87
87
  readonly id = "jina";
88
88
  readonly label = "Jina";
89
89
 
90
- isAvailable() {
91
- return isApiKeyAvailable(findApiKey);
90
+ isAvailable(_authStorage: AuthStorage): boolean {
91
+ return !!findApiKey();
92
92
  }
93
93
 
94
94
  search(params: SearchParams): Promise<SearchResponse> {