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.
- package/README.md +15 -12
- package/extensions/answer.ts +129 -73
- package/extensions/branch-term/README.md +7 -0
- package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
- package/extensions/btw.ts +90 -4
- package/extensions/caffeinate/README.md +5 -0
- package/extensions/caffeinate/index.ts +144 -0
- package/extensions/fast.ts +292 -0
- package/extensions/ghostty.ts +214 -211
- package/extensions/git-diff-stats.ts +124 -84
- package/extensions/git-pr-status.ts +274 -208
- package/extensions/insights.ts +138 -183
- package/extensions/loop.ts +150 -135
- package/extensions/memory.ts +172 -97
- package/extensions/notify.ts +14 -26
- package/extensions/openai-verbosity.ts +108 -42
- package/extensions/review/fix.ts +15 -6
- package/extensions/review/git.ts +93 -103
- package/extensions/review/index.ts +60 -62
- package/extensions/review/interrupt.ts +117 -0
- package/extensions/review/message-queue.ts +45 -12
- package/extensions/review/models.ts +79 -3
- package/extensions/review/prompts.ts +43 -40
- package/extensions/review/review.ts +161 -166
- package/extensions/review/runner.ts +139 -147
- package/extensions/review/runtime.ts +173 -120
- package/extensions/review/submit-review-tool.ts +1 -5
- package/extensions/review/submit-triage-tool.ts +1 -0
- package/extensions/review/triage.ts +41 -44
- package/extensions/sandbox/bash.ts +775 -0
- package/extensions/sandbox/command.ts +605 -0
- package/extensions/sandbox/config.ts +764 -0
- package/extensions/sandbox/index.ts +138 -2850
- package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
- package/extensions/sandbox/permissions/dialog.ts +118 -0
- package/extensions/sandbox/permissions/filesystem.ts +559 -0
- package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
- package/extensions/sandbox/permissions/network.ts +164 -0
- package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
- package/extensions/sandbox/runtime.ts +616 -0
- package/extensions/stash.ts +28 -15
- package/extensions/subagent/README.md +73 -0
- package/extensions/subagent/index.ts +822 -0
- package/extensions/subagent/interrupt.ts +117 -0
- package/extensions/subagent/permissions.ts +101 -0
- package/extensions/subagent/rpc.ts +177 -0
- package/extensions/tool-display-mode.ts +267 -64
- package/extensions/usage/anthropic.ts +5 -6
- package/extensions/usage/github-copilot.ts +30 -9
- package/extensions/usage/index.ts +250 -357
- package/extensions/usage/openai-codex.ts +2 -2
- package/extensions/usage/openrouter.ts +10 -2
- package/extensions/websearch/README.md +12 -47
- package/extensions/websearch/config.ts +5 -2
- package/extensions/websearch/index.ts +94 -109
- package/extensions/websearch/output.ts +51 -0
- package/extensions/websearch/providers/anthropic.pi.ts +27 -44
- package/extensions/websearch/providers/gemini.browser.ts +68 -77
- package/extensions/websearch/providers/gemini.pi.ts +17 -17
- package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
- package/extensions/websearch/providers/pi-model.shared.ts +44 -32
- package/extensions/websearch/providers/shared.ts +87 -2
- package/extensions/websearch/types.ts +0 -3
- package/extensions/worktree.ts +132 -172
- package/package.json +10 -5
- package/skills/browser-tools/SKILL.md +29 -234
- package/skills/browser-tools/references/cookies.md +36 -0
- package/skills/browser-tools/references/interaction.md +90 -0
- package/skills/browser-tools/references/logging.md +34 -0
- package/skills/git-clean-history/SKILL.md +6 -6
- package/skills/git-commit/SKILL.md +5 -3
- package/skills/github-pull-request/SKILL.md +60 -0
- package/skills/github-pull-request/references/create.md +40 -0
- package/skills/github-pull-request/references/stewardship.md +60 -0
- package/skills/oracle/SKILL.md +4 -4
- package/skills/oracle/scripts/oracle +64 -51
- package/skills/sentry/SKILL.md +15 -185
- package/skills/sentry/references/events.md +79 -0
- package/skills/sentry/references/issues.md +62 -0
- package/skills/sentry/references/logs.md +46 -0
- package/skills/update-changelog/SKILL.md +27 -121
- package/skills/web-design/SKILL.md +16 -105
- package/themes/tau-dark.json +4 -0
- package/extensions/openai-fast.ts +0 -229
- package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
- 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 {
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
95
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
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(/@/gi, "@")
|
|
182
|
-
.replace(/@/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: [{
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
4
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
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?:
|
|
7
|
+
headers?: ProviderHeaders;
|
|
8
|
+
env?: Record<string, string>;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export
|
|
11
|
+
export function getPiModelCandidates(
|
|
11
12
|
ctx: ExtensionContext,
|
|
12
13
|
predicate: (model: Model<Api>) => boolean,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
30
|
+
/** Consume candidates lazily so unused routes and alternate models never resolve credentials. */
|
|
31
|
+
export async function selectNextPiModel(
|
|
20
32
|
ctx: ExtensionContext,
|
|
21
|
-
|
|
33
|
+
remaining: Model<Api>[],
|
|
34
|
+
signal?: AbortSignal,
|
|
22
35
|
): Promise<PiModelSelection | null> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
.
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
}
|