tau-coding-agent 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,3 +1,5 @@
1
+ import undici, { EnvHttpProxyAgent, type Dispatcher, type RequestInit } from "undici";
2
+
1
3
  import type { BrowserCookie, BrowserSession, WebsearchResult } from "../types.js";
2
4
  import {
3
5
  browserHeaders,
@@ -7,13 +9,11 @@ import {
7
9
  hasCookie,
8
10
  } from "../normalize.js";
9
11
  import { buildWebsearchPrompt } from "./search-prompt.shared.js";
10
- import { fetchText, withTimeout } from "./shared.js";
12
+ import { withTimeout } from "./shared.js";
11
13
 
12
14
  const GEMINI_APP_URL = "https://gemini.google.com/app";
13
15
  const GEMINI_STREAM_GENERATE_URL =
14
16
  "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
15
- const GOOGLE_LIST_ACCOUNTS_URL =
16
- "https://accounts.google.com/ListAccounts?gpsia=1&source=ChromiumBrowser&laf=b64bin&json=standard";
17
17
  const REQUIRED_COOKIE_NAMES = ["__Secure-1PSID", "__Secure-1PSIDTS"];
18
18
 
19
19
  export const browserGemini = {
@@ -30,27 +30,25 @@ export const browserGemini = {
30
30
  }
31
31
  }
32
32
 
33
- const accessToken = await fetchAccessToken(session.cookies, signal);
34
- let accountLabel: string | null | undefined;
35
- void getActiveGoogleEmail(session.cookies, signal)
36
- .then((value) => {
37
- accountLabel = value;
38
- })
39
- .catch(() => {
40
- accountLabel = null;
41
- });
42
-
43
- const answer = await queryGemini(query, session.cookies, accessToken, signal);
44
-
45
- return {
46
- backend: "gemini",
47
- authSource: session.profile.family,
48
- browserName: session.profile.browserName,
49
- profile: session.profile.profileName,
50
- accountLabel: accountLabel ?? undefined,
51
- answer,
52
- sources: dedupeSources(extractMarkdownSources(answer)),
53
- };
33
+ const dispatcher = new EnvHttpProxyAgent({
34
+ maxHeaderSize: 64 * 1024,
35
+ allowH2: false,
36
+ proxyTunnel: true,
37
+ });
38
+ try {
39
+ const accessToken = await fetchAccessToken(session.cookies, dispatcher, signal);
40
+ const answer = await queryGemini(query, session.cookies, accessToken, dispatcher, signal);
41
+ return {
42
+ backend: "gemini",
43
+ authSource: session.profile.family,
44
+ browserName: session.profile.browserName,
45
+ profile: session.profile.profileName,
46
+ answer,
47
+ sources: dedupeSources(extractMarkdownSources(answer)),
48
+ };
49
+ } finally {
50
+ await dispatcher.close();
51
+ }
54
52
  },
55
53
  };
56
54
 
@@ -58,6 +56,7 @@ async function queryGemini(
58
56
  query: string,
59
57
  cookies: BrowserCookie[],
60
58
  accessToken: string,
59
+ dispatcher: Dispatcher,
61
60
  signal?: AbortSignal,
62
61
  ): Promise<string> {
63
62
  const body = new URLSearchParams();
@@ -67,7 +66,7 @@ async function queryGemini(
67
66
  JSON.stringify([null, JSON.stringify([[buildWebsearchPrompt(query)], null, null])]),
68
67
  );
69
68
 
70
- const rawText = await fetchText(GEMINI_STREAM_GENERATE_URL, {
69
+ const rawText = await fetchGeminiText(GEMINI_STREAM_GENERATE_URL, dispatcher, {
71
70
  method: "POST",
72
71
  headers: {
73
72
  ...browserHeaders({
@@ -83,16 +82,15 @@ async function queryGemini(
83
82
  signal: withTimeout(signal, 120_000),
84
83
  });
85
84
 
86
- const parsed = parseGeminiResponse(rawText);
87
- if (!parsed.trim()) {
88
- throw new Error("Gemini Web returned an empty response.");
89
- }
90
-
91
- return parsed.trim();
85
+ return parseGeminiResponse(rawText);
92
86
  }
93
87
 
94
- async function fetchAccessToken(cookies: BrowserCookie[], signal?: AbortSignal): Promise<string> {
95
- const html = await fetchText(GEMINI_APP_URL, {
88
+ async function fetchAccessToken(
89
+ cookies: BrowserCookie[],
90
+ dispatcher: Dispatcher,
91
+ signal?: AbortSignal,
92
+ ): Promise<string> {
93
+ const html = await fetchGeminiText(GEMINI_APP_URL, dispatcher, {
96
94
  headers: browserHeaders({
97
95
  cookieHeader: buildCookieHeader(GEMINI_APP_URL, cookies),
98
96
  origin: "https://gemini.google.com",
@@ -110,24 +108,17 @@ async function fetchAccessToken(cookies: BrowserCookie[], signal?: AbortSignal):
110
108
  throw new Error("Could not authenticate with Gemini Web.");
111
109
  }
112
110
 
113
- async function getActiveGoogleEmail(
114
- cookies: BrowserCookie[],
115
- signal?: AbortSignal,
116
- ): Promise<string | null> {
117
- try {
118
- const response = await fetchText(GOOGLE_LIST_ACCOUNTS_URL, {
119
- headers: browserHeaders({
120
- cookieHeader: buildCookieHeader(GOOGLE_LIST_ACCOUNTS_URL, cookies),
121
- origin: "https://accounts.google.com",
122
- referer: "https://accounts.google.com/",
123
- }),
124
- signal: withTimeout(signal, 10_000),
125
- });
126
-
127
- return findFirstEmail(response);
128
- } catch {
129
- return null;
111
+ async function fetchGeminiText(
112
+ url: string,
113
+ dispatcher: Dispatcher,
114
+ options: RequestInit,
115
+ ): Promise<string> {
116
+ const response = await undici.fetch(url, { ...options, dispatcher });
117
+ const text = await response.text();
118
+ if (!response.ok) {
119
+ throw new Error(`${response.status} ${response.statusText}${text ? `\n${text}` : ""}`);
130
120
  }
121
+ return text;
131
122
  }
132
123
 
133
124
  function parseGeminiResponse(rawText: string): string {
@@ -140,29 +131,39 @@ function parseGeminiResponse(rawText: string): string {
140
131
  const responseJson = JSON.parse(rawText.slice(start, end + 1));
141
132
  const parts = Array.isArray(responseJson) ? responseJson : [];
142
133
 
134
+ let answer: string | undefined;
135
+ let completed = false;
143
136
  for (const part of parts) {
137
+ const errorCode = getNestedValue(part, [5, 2, 0, 1, 0]);
138
+ if (getNestedValue(part, [0]) === "er" || (typeof errorCode === "number" && errorCode !== 0)) {
139
+ throw new Error(
140
+ `Gemini Web request failed${typeof errorCode === "number" ? ` (code ${errorCode})` : ""}.`,
141
+ );
142
+ }
143
+
144
144
  const payload = getNestedValue(part, [2]);
145
145
  if (typeof payload !== "string") continue;
146
146
 
147
- try {
148
- const parsed = JSON.parse(payload);
149
- const candidateList = getNestedValue(parsed, [4]);
150
- const firstCandidate = Array.isArray(candidateList) ? candidateList[0] : undefined;
151
- const text = getNestedValue(firstCandidate, [1, 0]);
152
- if (typeof text === "string" && text.trim().length > 0) {
153
- return text;
154
- }
155
-
156
- const alternateText = getNestedValue(firstCandidate, [22, 0]);
157
- if (typeof alternateText === "string" && alternateText.trim().length > 0) {
158
- return alternateText;
159
- }
160
- } catch {
161
- // Ignore non-message chunks.
162
- }
147
+ const parsed: unknown = JSON.parse(payload);
148
+ const candidate = getNestedValue(parsed, [4, 0]);
149
+ if (!Array.isArray(candidate)) continue;
150
+
151
+ // Candidates are cumulative snapshots. Status 1 is streaming, while 2 is complete.
152
+ completed = getNestedValue(candidate, [8, 0]) === 2;
153
+ const text = getNestedValue(candidate, [1, 0]);
154
+ const alternateText = getNestedValue(candidate, [22, 0]);
155
+ answer =
156
+ typeof text === "string" && text.trim()
157
+ ? text
158
+ : typeof alternateText === "string"
159
+ ? alternateText
160
+ : undefined;
163
161
  }
164
162
 
165
- throw new Error("Gemini Web returned no assistant text.");
163
+ if (!completed) throw new Error("Gemini Web response ended before generation completed.");
164
+ const text = answer?.trim();
165
+ if (!text) throw new Error("Gemini Web returned no assistant text.");
166
+ return text;
166
167
  }
167
168
 
168
169
  function getNestedValue(value: unknown, path: number[]): unknown {
@@ -173,13 +174,3 @@ function getNestedValue(value: unknown, path: number[]): unknown {
173
174
  }
174
175
  return current;
175
176
  }
176
-
177
- function findFirstEmail(value: string): string | null {
178
- const normalized = value
179
- .replace(/\\u0040/gi, "@")
180
- .replace(/\\x40/gi, "@")
181
- .replace(/&#64;/gi, "@")
182
- .replace(/&commat;/gi, "@");
183
- const match = normalized.match(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i);
184
- return match?.[0] ?? null;
185
- }
@@ -4,7 +4,13 @@ import { dedupeSources, extractMarkdownSources, normalizeSourceTitle } from "../
4
4
  import type { WebsearchResult, WebsearchSource } from "../types.js";
5
5
  import type { PiModelSelection } from "./pi-model.shared.js";
6
6
  import { buildWebsearchPrompt } from "./search-prompt.shared.js";
7
- import { fetchJson, withTimeout } from "./shared.js";
7
+ import { applyResolvedHeaders, fetchJson, withTimeout } from "./shared.js";
8
+
9
+ interface GeminiSearchRequest {
10
+ model: string;
11
+ input: string;
12
+ tools: Array<{ type: "google_search" }>;
13
+ }
8
14
 
9
15
  export async function searchWithPiGemini(
10
16
  selection: PiModelSelection,
@@ -19,8 +25,8 @@ export async function searchWithPiGemini(
19
25
  body: JSON.stringify({
20
26
  model: selection.model.id,
21
27
  input: buildWebsearchPrompt(query),
22
- tools: [{ googleSearch: {} }],
23
- }),
28
+ tools: [{ type: "google_search" }],
29
+ } satisfies GeminiSearchRequest),
24
30
  signal: withTimeout(signal, 120_000),
25
31
  },
26
32
  );
@@ -65,20 +71,14 @@ function resolveGeminiInteractionsUrl(baseUrl?: string): string {
65
71
  }
66
72
 
67
73
  function buildGeminiHeaders(selection: PiModelSelection): Record<string, string> {
68
- const headers = { ...selection.headers };
69
-
70
- return {
71
- ...headers,
72
- ...(hasHeader(headers, "x-goog-api-key") || !selection.apiKey
73
- ? {}
74
- : { "x-goog-api-key": selection.apiKey }),
75
- "content-type": "application/json",
76
- accept: "application/json",
77
- };
78
- }
79
-
80
- function hasHeader(headers: Record<string, string>, name: string): boolean {
81
- return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
74
+ return applyResolvedHeaders(
75
+ {
76
+ ...(selection.apiKey ? { "x-goog-api-key": selection.apiKey } : {}),
77
+ "content-type": "application/json",
78
+ accept: "application/json",
79
+ },
80
+ selection.headers,
81
+ );
82
82
  }
83
83
 
84
84
  function extractAnnotationSources(output: {
@@ -1,8 +1,10 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
 
3
- import type { WebsearchResult } from "../types.js";
4
- import { decodeJwtAccountId, runOpenAICodexSearch } from "./openai-codex.shared.js";
3
+ import { dedupeSources, extractMarkdownSources } from "../normalize.js";
4
+ import type { WebsearchResult, WebsearchSource } from "../types.js";
5
5
  import type { PiModelSelection } from "./pi-model.shared.js";
6
+ import { buildWebsearchPrompt, WEBSEARCH_SYSTEM_PROMPT } from "./search-prompt.shared.js";
7
+ import { applyResolvedHeaders, getResolvedHeader, readEventStream, withTimeout } from "./shared.js";
6
8
 
7
9
  export async function searchWithPiOpenAICodex(
8
10
  selection: PiModelSelection,
@@ -32,18 +34,154 @@ export async function searchWithPiOpenAICodex(
32
34
  };
33
35
  }
34
36
 
37
+ export function isPiOpenAICodexModel(model: Model<Api>): boolean {
38
+ return model.api === "openai-codex-responses" && model.provider === "openai-codex";
39
+ }
40
+
35
41
  function resolveApiKey(selection: PiModelSelection): string | undefined {
36
42
  if (selection.apiKey) return selection.apiKey;
37
43
 
38
- const authorization = Object.entries(selection.headers ?? {}).find(
39
- ([name]) => name.toLowerCase() === "authorization",
40
- )?.[1];
44
+ const authorization = getResolvedHeader(selection.headers, "authorization");
41
45
  if (!authorization) return undefined;
42
46
 
43
47
  const match = authorization.match(/^Bearer\s+(.+)$/i);
44
48
  return match?.[1];
45
49
  }
46
50
 
47
- export function isPiOpenAICodexModel(model: Model<Api>): boolean {
48
- return model.api === "openai-codex-responses" && model.provider === "openai-codex";
51
+ async function runOpenAICodexSearch(options: {
52
+ apiKey: string;
53
+ model: string;
54
+ query: string;
55
+ baseUrl?: string;
56
+ accountId?: string;
57
+ headers?: Record<string, string | null>;
58
+ signal?: AbortSignal;
59
+ }): Promise<{ answer: string; sources: WebsearchSource[] }> {
60
+ const response = await fetch(resolveCodexUrl(options.baseUrl), {
61
+ method: "POST",
62
+ headers: applyResolvedHeaders(
63
+ {
64
+ authorization: `Bearer ${options.apiKey}`,
65
+ ...(options.accountId ? { "chatgpt-account-id": options.accountId } : {}),
66
+ "content-type": "application/json",
67
+ accept: "text/event-stream",
68
+ "OpenAI-Beta": "responses=experimental",
69
+ originator: "pi-websearch",
70
+ },
71
+ options.headers,
72
+ ),
73
+ body: JSON.stringify({
74
+ model: options.model,
75
+ store: false,
76
+ stream: true,
77
+ instructions: WEBSEARCH_SYSTEM_PROMPT,
78
+ input: [{ role: "user", content: buildWebsearchPrompt(options.query) }],
79
+ tools: [{ type: "web_search" }],
80
+ tool_choice: "auto",
81
+ }),
82
+ signal: withTimeout(options.signal, 120_000),
83
+ });
84
+
85
+ let answer = "";
86
+ let fallbackAnswer = "";
87
+ let completed = false;
88
+
89
+ await readEventStream(response, ({ data }) => {
90
+ if (!data.trim()) return;
91
+
92
+ try {
93
+ const event = JSON.parse(data) as Record<string, unknown>;
94
+
95
+ if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
96
+ answer += event.delta;
97
+ }
98
+
99
+ if (event.type === "response.output_item.done") {
100
+ const item = event.item as Record<string, unknown> | undefined;
101
+ const content = Array.isArray(item?.content) ? item.content : [];
102
+ const fullText = content
103
+ .filter(
104
+ (part) =>
105
+ part &&
106
+ typeof part === "object" &&
107
+ (part as Record<string, unknown>).type === "output_text",
108
+ )
109
+ .map((part) => (part as Record<string, unknown>).text)
110
+ .filter((text): text is string => typeof text === "string")
111
+ .join("\n");
112
+ if (fullText) fallbackAnswer = fullText;
113
+ }
114
+
115
+ if (
116
+ event.type === "response.completed" ||
117
+ event.type === "response.done" ||
118
+ event.type === "response.incomplete"
119
+ ) {
120
+ const result = event.response as Record<string, unknown> | undefined;
121
+ if (event.type === "response.incomplete" || result?.status !== "completed") {
122
+ throw new Error("OpenAI Codex search did not complete successfully.");
123
+ }
124
+ completed = true;
125
+ }
126
+
127
+ if (event.type === "response.failed" || event.type === "error") {
128
+ const failedResponse = event.response;
129
+ const eventMessage = typeof event.message === "string" ? event.message : undefined;
130
+ if (failedResponse && typeof failedResponse === "object") {
131
+ const error = (failedResponse as Record<string, unknown>).error;
132
+ if (
133
+ error &&
134
+ typeof error === "object" &&
135
+ typeof (error as Record<string, unknown>).message === "string"
136
+ ) {
137
+ throw new Error((error as Record<string, unknown>).message as string);
138
+ }
139
+ }
140
+ throw new Error(eventMessage ?? "OpenAI Codex search failed.");
141
+ }
142
+ } catch (error) {
143
+ if (error instanceof SyntaxError) return;
144
+ throw error;
145
+ }
146
+ });
147
+
148
+ if (!completed) {
149
+ throw new Error("OpenAI Codex stream ended before search completed.");
150
+ }
151
+
152
+ const finalAnswer = (answer || fallbackAnswer).trim();
153
+ if (!finalAnswer) {
154
+ throw new Error("OpenAI Codex returned an empty response.");
155
+ }
156
+
157
+ return {
158
+ answer: finalAnswer,
159
+ sources: dedupeSources(extractMarkdownSources(finalAnswer)),
160
+ };
161
+ }
162
+
163
+ function resolveCodexUrl(baseUrl = "https://chatgpt.com/backend-api"): string {
164
+ const normalized = String(baseUrl || "https://chatgpt.com/backend-api").replace(/\/+$/, "");
165
+ if (normalized.endsWith("/codex/responses")) return normalized;
166
+ if (normalized.endsWith("/codex")) return `${normalized}/responses`;
167
+ return `${normalized}/codex/responses`;
168
+ }
169
+
170
+ function decodeJwtAccountId(jwt: string | undefined): string | undefined {
171
+ if (!jwt || typeof jwt !== "string") return undefined;
172
+
173
+ try {
174
+ const parts = jwt.split(".");
175
+ if (parts.length !== 3) return undefined;
176
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<
177
+ string,
178
+ unknown
179
+ >;
180
+ const auth = payload["https://api.openai.com/auth"];
181
+ return auth && typeof auth === "object"
182
+ ? ((auth as Record<string, unknown>).chatgpt_account_id as string | undefined)
183
+ : undefined;
184
+ } catch {
185
+ return undefined;
186
+ }
49
187
  }
@@ -1,39 +1,43 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
1
+ import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  export interface PiModelSelection {
5
5
  model: Model<Api>;
6
6
  apiKey?: string;
7
- headers?: Record<string, string>;
7
+ headers?: ProviderHeaders;
8
+ env?: Record<string, string>;
8
9
  }
9
10
 
10
- export async function selectCurrentPiModel(
11
+ export function getPiModelCandidates(
11
12
  ctx: ExtensionContext,
12
13
  predicate: (model: Model<Api>) => boolean,
13
- ): Promise<PiModelSelection | null> {
14
- if (!ctx.model || !predicate(ctx.model)) return null;
15
-
16
- return resolvePiModelSelection(ctx.model, ctx);
14
+ fallbackModels?: readonly string[],
15
+ ): Model<Api>[] {
16
+ const available = ctx.modelRegistry.getAvailable().filter(predicate);
17
+ const fallbacks = fallbackModels
18
+ ? fallbackModels.flatMap((id) => available.filter((model) => model.id === id))
19
+ : available.sort(comparePiModels);
20
+ const candidates = ctx.model && predicate(ctx.model) ? [ctx.model, ...fallbacks] : fallbacks;
21
+ const seen = new Set<string>();
22
+ return candidates.filter((model) => {
23
+ const key = `${model.provider}:${model.id}`;
24
+ if (seen.has(key)) return false;
25
+ seen.add(key);
26
+ return true;
27
+ });
17
28
  }
18
29
 
19
- export async function selectFallbackPiModel(
30
+ /** Consume candidates lazily so unused routes and alternate models never resolve credentials. */
31
+ export async function selectNextPiModel(
20
32
  ctx: ExtensionContext,
21
- predicate: (model: Model<Api>) => boolean,
33
+ remaining: Model<Api>[],
34
+ signal?: AbortSignal,
22
35
  ): Promise<PiModelSelection | null> {
23
- const currentKey =
24
- ctx.model && predicate(ctx.model) ? `${ctx.model.provider}:${ctx.model.id}` : null;
25
- const candidates = rankPiModels(
26
- ctx.modelRegistry.getAvailable().filter((model) => {
27
- if (!predicate(model)) return false;
28
- return `${model.provider}:${model.id}` !== currentKey;
29
- }),
30
- );
31
-
32
- for (const model of candidates) {
33
- const selection = await resolvePiModelSelection(model, ctx);
36
+ while (remaining.length > 0) {
37
+ signal?.throwIfAborted();
38
+ const selection = await resolvePiModelSelection(remaining.shift()!, ctx);
34
39
  if (selection) return selection;
35
40
  }
36
-
37
41
  return null;
38
42
  }
39
43
 
@@ -42,20 +46,28 @@ async function resolvePiModelSelection(
42
46
  ctx: Pick<ExtensionContext, "modelRegistry">,
43
47
  ): Promise<PiModelSelection | null> {
44
48
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
45
- return auth.ok ? { model, apiKey: auth.apiKey, headers: auth.headers } : null;
49
+ return auth.ok
50
+ ? {
51
+ model: resolveModelEndpoint(model, auth.baseUrl, auth.env),
52
+ apiKey: auth.apiKey,
53
+ headers: auth.headers,
54
+ env: auth.env,
55
+ }
56
+ : null;
46
57
  }
47
58
 
48
- function rankPiModels(models: Model<Api>[]): Model<Api>[] {
49
- const seen = new Set<string>();
59
+ function resolveModelEndpoint(
60
+ model: Model<Api>,
61
+ resolvedBaseUrl?: string,
62
+ env?: Record<string, string>,
63
+ ): Model<Api> {
64
+ let baseUrl = resolvedBaseUrl ?? model.baseUrl;
65
+ if (!baseUrl) return model;
50
66
 
51
- return [...models]
52
- .filter((model) => {
53
- const key = `${model.provider}:${model.id}`;
54
- if (seen.has(key)) return false;
55
- seen.add(key);
56
- return true;
57
- })
58
- .sort(comparePiModels);
67
+ for (const [name, value] of Object.entries(env ?? {})) {
68
+ baseUrl = baseUrl.replaceAll(`{${name}}`, value);
69
+ }
70
+ return baseUrl === model.baseUrl ? model : { ...model, baseUrl };
59
71
  }
60
72
 
61
73
  function comparePiModels(left: Model<Api>, right: Model<Api>): number {
@@ -1,3 +1,69 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+
3
+ export function isModelUnavailableError(error: unknown, model: Model<Api>): boolean {
4
+ if (!(error instanceof HttpError) || (error.status !== 400 && error.status !== 404)) {
5
+ return false;
6
+ }
7
+ if (!isRecord(error.body)) return false;
8
+
9
+ const detail = isRecord(error.body.error) ? error.body.error : undefined;
10
+ if (model.provider === "openai-codex" && model.api === "openai-codex-responses") {
11
+ return (
12
+ (error.status === 400 &&
13
+ Boolean(model.id) &&
14
+ error.body.detail ===
15
+ `The '${model.id}' model is not supported when using Codex with a ChatGPT account.`) ||
16
+ detail?.code === "model_not_found" ||
17
+ detail?.code === "model_not_supported" ||
18
+ detail?.code === "unsupported_model"
19
+ );
20
+ }
21
+
22
+ if (error.status !== 404 || !detail) return false;
23
+ if (model.provider === "anthropic" && model.api === "anthropic-messages") {
24
+ return (
25
+ error.body.type === "error" &&
26
+ detail.type === "not_found_error" &&
27
+ Boolean(model.id) &&
28
+ typeof detail.message === "string" &&
29
+ detail.message.trim() === `model: ${model.id}`
30
+ );
31
+ }
32
+
33
+ if (model.provider === "google" && model.api === "google-generative-ai") {
34
+ return detail.code === "model_not_found";
35
+ }
36
+
37
+ return false;
38
+ }
39
+
40
+ export function applyResolvedHeaders(
41
+ defaults: Record<string, string>,
42
+ overrides?: Record<string, string | null>,
43
+ ): Record<string, string> {
44
+ const headers = { ...defaults };
45
+
46
+ for (const [name, value] of Object.entries(overrides ?? {})) {
47
+ const existingName = Object.keys(headers).find(
48
+ (candidate) => candidate.toLowerCase() === name.toLowerCase(),
49
+ );
50
+ if (existingName) delete headers[existingName];
51
+ if (value !== null) headers[name] = value;
52
+ }
53
+
54
+ return headers;
55
+ }
56
+
57
+ export function getResolvedHeader(
58
+ headers: Record<string, string | null> | undefined,
59
+ name: string,
60
+ ): string | undefined {
61
+ const entry = Object.entries(headers ?? {}).find(
62
+ ([candidate]) => candidate.toLowerCase() === name.toLowerCase(),
63
+ );
64
+ return entry?.[1] ?? undefined;
65
+ }
66
+
1
67
  export function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
2
68
  return signal
3
69
  ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
@@ -12,7 +78,7 @@ export async function fetchText(url: string, options: RequestInit = {}): Promise
12
78
  const response = await fetch(url, options);
13
79
  const text = await response.text();
14
80
  if (!response.ok) {
15
- throw new Error(`${response.status} ${response.statusText}${text ? `\n${text}` : ""}`);
81
+ throw new HttpError(response, text);
16
82
  }
17
83
  return text;
18
84
  }
@@ -23,7 +89,7 @@ export async function readEventStream(
23
89
  ): Promise<void> {
24
90
  if (!response.ok) {
25
91
  const text = await response.text();
26
- throw new Error(`${response.status} ${response.statusText}${text ? `\n${text}` : ""}`);
92
+ throw new HttpError(response, text);
27
93
  }
28
94
 
29
95
  if (!response.body) {
@@ -53,6 +119,25 @@ export async function readEventStream(
53
119
  }
54
120
  }
55
121
 
122
+ class HttpError extends Error {
123
+ readonly status: number;
124
+ readonly body: unknown;
125
+
126
+ constructor(response: Response, text: string) {
127
+ super(`${response.status} ${response.statusText}${text ? `\n${text}` : ""}`);
128
+ this.status = response.status;
129
+ try {
130
+ this.body = JSON.parse(text);
131
+ } catch {
132
+ this.body = undefined;
133
+ }
134
+ }
135
+ }
136
+
137
+ function isRecord(value: unknown): value is Record<string, unknown> {
138
+ return value !== null && typeof value === "object" && !Array.isArray(value);
139
+ }
140
+
56
141
  function emitEventBlock(
57
142
  block: string,
58
143
  onEvent: (event: { event?: string; data: string }) => void,
@@ -5,9 +5,7 @@ export type WebsearchRouteId =
5
5
  | "pi:openai-codex"
6
6
  | "pi:anthropic"
7
7
  | "pi:gemini"
8
- | "firefox:openai-codex"
9
8
  | "firefox:gemini"
10
- | "chromium:openai-codex"
11
9
  | "chromium:gemini";
12
10
 
13
11
  export interface BrowserCookie {
@@ -46,7 +44,6 @@ export interface WebsearchResult {
46
44
  authSource: WebsearchAuthSource;
47
45
  browserName?: string;
48
46
  profile?: string;
49
- accountLabel?: string;
50
47
  answer: string;
51
48
  sources: WebsearchSource[];
52
49
  }