@mewbleh/purrx 1.0.8
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/AGENTS.md +31 -0
- package/LICENSE +201 -0
- package/README.md +314 -0
- package/bin/purrx.js +352 -0
- package/package.json +64 -0
- package/src/api/client.js +121 -0
- package/src/api/models.js +57 -0
- package/src/auth/login.js +199 -0
- package/src/auth/pkce.js +34 -0
- package/src/auth/tokens.js +186 -0
- package/src/config.js +57 -0
- package/src/core/agent.js +197 -0
- package/src/core/approval.js +101 -0
- package/src/core/compact.js +207 -0
- package/src/core/context.js +245 -0
- package/src/core/session.js +101 -0
- package/src/index.js +24 -0
- package/src/platform.js +94 -0
- package/src/tools/builtin.js +476 -0
- package/src/tools/mcp.js +223 -0
- package/src/tools/registry.js +62 -0
- package/src/types.js +68 -0
- package/src/ui/render.js +47 -0
- package/src/ui/theme.js +114 -0
- package/src/ui/tui.js +317 -0
package/bin/purrx.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { loginWithChatGPT, loginWithApiKey } from "../src/auth/login.js";
|
|
4
|
+
import {
|
|
5
|
+
clearAuth,
|
|
6
|
+
ensureFreshAuth,
|
|
7
|
+
resolveAuthMode,
|
|
8
|
+
readAuth,
|
|
9
|
+
} from "../src/auth/tokens.js";
|
|
10
|
+
import { startTui } from "../src/ui/tui.js";
|
|
11
|
+
import { runTurn } from "../src/core/agent.js";
|
|
12
|
+
import { createApprovalManager } from "../src/core/approval.js";
|
|
13
|
+
import { ToolRegistry } from "../src/tools/registry.js";
|
|
14
|
+
import { authFilePath } from "../src/config.js";
|
|
15
|
+
import { resolveModel, listModels } from "../src/api/models.js";
|
|
16
|
+
import { detectPlatform } from "../src/platform.js";
|
|
17
|
+
import {
|
|
18
|
+
listSessions,
|
|
19
|
+
loadSession,
|
|
20
|
+
latestSession,
|
|
21
|
+
deleteSession,
|
|
22
|
+
createSession,
|
|
23
|
+
saveSession,
|
|
24
|
+
} from "../src/core/session.js";
|
|
25
|
+
|
|
26
|
+
function promptLine(question) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const rl = readline.createInterface({
|
|
29
|
+
input: process.stdin,
|
|
30
|
+
output: process.stdout,
|
|
31
|
+
});
|
|
32
|
+
rl.question(question, (answer) => {
|
|
33
|
+
rl.close();
|
|
34
|
+
resolve(answer.trim());
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Tiny flag parser: returns { _: positional[], flags: {name: value|true} }.
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const out = { _: [], flags: {} };
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a.startsWith("--")) {
|
|
45
|
+
const name = a.slice(2);
|
|
46
|
+
const next = argv[i + 1];
|
|
47
|
+
if (next && !next.startsWith("--")) {
|
|
48
|
+
out.flags[name] = next;
|
|
49
|
+
i++;
|
|
50
|
+
} else {
|
|
51
|
+
out.flags[name] = true;
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
out._.push(a);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function cmdLogin(args) {
|
|
61
|
+
const { flags } = parseArgs(args);
|
|
62
|
+
if (flags["api-key"]) {
|
|
63
|
+
let key = typeof flags["api-key"] === "string" ? flags["api-key"] : "";
|
|
64
|
+
if (!key) key = await promptLine("Enter your OpenAI API key: ");
|
|
65
|
+
if (!key) {
|
|
66
|
+
console.error("No API key provided.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const file = loginWithApiKey(key);
|
|
70
|
+
console.log(`Saved API key to ${file}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const { file, plan } = await loginWithChatGPT();
|
|
76
|
+
console.log(`\n✓ Signed in with ChatGPT (plan: ${plan}).`);
|
|
77
|
+
console.log(`Credentials saved to ${file}`);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`\nLogin failed: ${err.message}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cmdLogout() {
|
|
85
|
+
const ok = clearAuth();
|
|
86
|
+
console.log(ok ? "Logged out." : "No credentials found.");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cmdStatus() {
|
|
90
|
+
const auth = readAuth();
|
|
91
|
+
console.log(`platform: ${detectPlatform()}`);
|
|
92
|
+
if (!auth) {
|
|
93
|
+
console.log("Not signed in. Run `purrx login`.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const info = resolveAuthMode(auth);
|
|
97
|
+
if (!info) {
|
|
98
|
+
console.log("Credentials present but unusable. Try `purrx login` again.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
console.log(
|
|
102
|
+
`signed in: ${info.mode === "chatgpt" ? "ChatGPT account" : "API key"}`
|
|
103
|
+
);
|
|
104
|
+
console.log(`auth file: ${authFilePath()}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function ensureAuthOrExit() {
|
|
108
|
+
const auth = await ensureFreshAuth();
|
|
109
|
+
const info = resolveAuthMode(auth);
|
|
110
|
+
if (!info) {
|
|
111
|
+
console.error("You are not signed in. Run `purrx login` first.");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
return info;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function cmdChat(args) {
|
|
118
|
+
const { flags } = parseArgs(args);
|
|
119
|
+
const authInfo = await ensureAuthOrExit();
|
|
120
|
+
|
|
121
|
+
// Resume logic.
|
|
122
|
+
let session = null;
|
|
123
|
+
if (flags.continue) {
|
|
124
|
+
session = latestSession();
|
|
125
|
+
if (session) console.log(`resuming latest session ${session.id}`);
|
|
126
|
+
} else if (flags.resume) {
|
|
127
|
+
session = loadSession(flags.resume);
|
|
128
|
+
if (!session) {
|
|
129
|
+
console.error(`session not found: ${flags.resume}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
console.log(`resuming session ${session.id}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const model = await resolveModel(authInfo, flags.model);
|
|
136
|
+
if (!session) session = createSession({ cwd: process.cwd(), model });
|
|
137
|
+
|
|
138
|
+
await startTui(authInfo, {
|
|
139
|
+
session,
|
|
140
|
+
model,
|
|
141
|
+
policy: flags.approval || session.policy || "suggest",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function cmdExec(args) {
|
|
146
|
+
const { _, flags } = parseArgs(args);
|
|
147
|
+
const message = _.join(" ").trim();
|
|
148
|
+
if (!message) {
|
|
149
|
+
console.error('Usage: purrx exec "your request" [--model M] [--approval P]');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
const authInfo = await ensureAuthOrExit();
|
|
153
|
+
const model = await resolveModel(authInfo, flags.model);
|
|
154
|
+
|
|
155
|
+
// In non-interactive exec, default to full-auto unless told otherwise.
|
|
156
|
+
const policy = flags.approval || "full-auto";
|
|
157
|
+
const approval = createApprovalManager(policy);
|
|
158
|
+
|
|
159
|
+
const registry = new ToolRegistry();
|
|
160
|
+
await registry.init({
|
|
161
|
+
onLog: (msg) => console.error(` · ${msg}`),
|
|
162
|
+
webSearch: flags["no-web-search"] ? false : true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const session = createSession({ cwd: process.cwd(), model });
|
|
166
|
+
try {
|
|
167
|
+
await runTurn({
|
|
168
|
+
authInfo,
|
|
169
|
+
history: session.history,
|
|
170
|
+
userMessage: message,
|
|
171
|
+
cwd: process.cwd(),
|
|
172
|
+
model,
|
|
173
|
+
registry,
|
|
174
|
+
approval,
|
|
175
|
+
onChange: () => saveSession(session),
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
registry.shutdown();
|
|
179
|
+
}
|
|
180
|
+
console.log(`\nsession saved: ${session.id}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function cmdModels(args) {
|
|
184
|
+
const { flags } = parseArgs(args);
|
|
185
|
+
const authInfo = await ensureAuthOrExit();
|
|
186
|
+
const models = await listModels(authInfo);
|
|
187
|
+
const active = await resolveModel(authInfo, flags.model);
|
|
188
|
+
console.log("available models:");
|
|
189
|
+
for (const m of models) {
|
|
190
|
+
console.log(` ${m}${m === active ? " (default)" : ""}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function cmdSessions(args) {
|
|
195
|
+
const { _, flags } = parseArgs(args);
|
|
196
|
+
const sub = _[0];
|
|
197
|
+
|
|
198
|
+
if (sub === "rm" || flags.rm) {
|
|
199
|
+
const id = sub === "rm" ? _[1] : flags.rm;
|
|
200
|
+
if (!id) {
|
|
201
|
+
console.error("usage: purrx sessions rm <id>");
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
console.log(deleteSession(id) ? `deleted ${id}` : `not found: ${id}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sessions = listSessions();
|
|
209
|
+
if (!sessions.length) {
|
|
210
|
+
console.log("no saved sessions.");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.log("saved sessions (newest first):\n");
|
|
214
|
+
for (const s of sessions) {
|
|
215
|
+
console.log(` ${s.id}`);
|
|
216
|
+
console.log(` updated: ${s.updated_at} turns: ${s.turns}`);
|
|
217
|
+
if (s.preview) console.log(` "${s.preview}"`);
|
|
218
|
+
console.log();
|
|
219
|
+
}
|
|
220
|
+
console.log("resume with: purrx chat --resume <id>");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function cmdMcp(args) {
|
|
224
|
+
const { _ } = parseArgs(args);
|
|
225
|
+
const sub = _[0];
|
|
226
|
+
const { readMcpConfig } = await import("../src/tools/mcp.js");
|
|
227
|
+
const { configFilePath } = await import("../src/config.js");
|
|
228
|
+
|
|
229
|
+
if (sub === "init") {
|
|
230
|
+
const fs = await import("node:fs");
|
|
231
|
+
const path = configFilePath();
|
|
232
|
+
let existing = {};
|
|
233
|
+
try {
|
|
234
|
+
existing = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
235
|
+
} catch {
|
|
236
|
+
// none yet
|
|
237
|
+
}
|
|
238
|
+
if (!existing.mcpServers) existing.mcpServers = {};
|
|
239
|
+
if (Object.keys(existing.mcpServers).length === 0) {
|
|
240
|
+
existing.mcpServers.example = {
|
|
241
|
+
command: "npx",
|
|
242
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "."],
|
|
243
|
+
disabled: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
fs.mkdirSync(path.replace(/[^\\/]+$/, ""), { recursive: true });
|
|
247
|
+
fs.writeFileSync(path, JSON.stringify(existing, null, 2));
|
|
248
|
+
console.log(`wrote MCP config scaffold to ${path}`);
|
|
249
|
+
console.log("edit it to add servers, then set disabled: false.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Default: list configured servers.
|
|
254
|
+
const config = readMcpConfig();
|
|
255
|
+
const names = Object.keys(config);
|
|
256
|
+
if (!names.length) {
|
|
257
|
+
console.log("no MCP servers configured.");
|
|
258
|
+
console.log(`add some in ${configFilePath()} (run: purrx mcp init)`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
console.log("configured MCP servers:");
|
|
262
|
+
for (const name of names) {
|
|
263
|
+
const s = config[name];
|
|
264
|
+
const status = s.disabled ? " (disabled)" : "";
|
|
265
|
+
console.log(` ${name}${status}: ${s.command} ${(s.args || []).join(" ")}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function printHelp() {
|
|
270
|
+
console.log(`purrx lightweight AI coding agent
|
|
271
|
+
|
|
272
|
+
Usage:
|
|
273
|
+
purrx Start interactive chat (default)
|
|
274
|
+
purrx chat [opts] Start interactive chat
|
|
275
|
+
purrx exec "<request>" [opts] Run a single request non-interactively
|
|
276
|
+
purrx models List models your account can use
|
|
277
|
+
purrx mcp List configured MCP servers
|
|
278
|
+
purrx mcp init Write an MCP config scaffold
|
|
279
|
+
purrx sessions List saved sessions
|
|
280
|
+
purrx sessions rm <id> Delete a saved session
|
|
281
|
+
purrx login Sign in with your ChatGPT account (OAuth)
|
|
282
|
+
purrx login --api-key [KEY] Sign in with an OpenAI API key
|
|
283
|
+
purrx logout Remove stored credentials
|
|
284
|
+
purrx status Show platform and sign-in status
|
|
285
|
+
purrx help Show this help
|
|
286
|
+
|
|
287
|
+
Chat options:
|
|
288
|
+
--model <id> Model to use for this session
|
|
289
|
+
--approval <policy> suggest | auto-edit | full-auto (default: suggest)
|
|
290
|
+
--continue Resume the most recent session
|
|
291
|
+
--resume <id> Resume a specific session
|
|
292
|
+
|
|
293
|
+
Exec options:
|
|
294
|
+
--model <id> Model to use
|
|
295
|
+
--approval <policy> default: full-auto for non-interactive use
|
|
296
|
+
|
|
297
|
+
Environment:
|
|
298
|
+
OPENAI_API_KEY Use this API key directly (overrides stored auth)
|
|
299
|
+
PURRX_MODEL Default model (default: gpt-5-codex)
|
|
300
|
+
PURRX_HOME Data dir override (default: OS-appropriate location)
|
|
301
|
+
CODEX_HOME Shared with the official Codex CLI auth.json
|
|
302
|
+
NO_COLOR Disable colored output
|
|
303
|
+
|
|
304
|
+
Supported platforms: Windows, macOS, Linux, Android (Termux)
|
|
305
|
+
`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function main() {
|
|
309
|
+
const [, , command, ...rest] = process.argv;
|
|
310
|
+
|
|
311
|
+
switch (command) {
|
|
312
|
+
case undefined:
|
|
313
|
+
case "chat":
|
|
314
|
+
await cmdChat(rest);
|
|
315
|
+
break;
|
|
316
|
+
case "exec":
|
|
317
|
+
await cmdExec(rest);
|
|
318
|
+
break;
|
|
319
|
+
case "models":
|
|
320
|
+
await cmdModels(rest);
|
|
321
|
+
break;
|
|
322
|
+
case "mcp":
|
|
323
|
+
await cmdMcp(rest);
|
|
324
|
+
break;
|
|
325
|
+
case "sessions":
|
|
326
|
+
cmdSessions(rest);
|
|
327
|
+
break;
|
|
328
|
+
case "login":
|
|
329
|
+
await cmdLogin(rest);
|
|
330
|
+
break;
|
|
331
|
+
case "logout":
|
|
332
|
+
cmdLogout();
|
|
333
|
+
break;
|
|
334
|
+
case "status":
|
|
335
|
+
cmdStatus();
|
|
336
|
+
break;
|
|
337
|
+
case "help":
|
|
338
|
+
case "--help":
|
|
339
|
+
case "-h":
|
|
340
|
+
printHelp();
|
|
341
|
+
break;
|
|
342
|
+
default:
|
|
343
|
+
console.error(`Unknown command: ${command}\n`);
|
|
344
|
+
printHelp();
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
main().catch((err) => {
|
|
350
|
+
console.error(`Fatal: ${err.message}`);
|
|
351
|
+
process.exit(1);
|
|
352
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mewbleh/purrx",
|
|
3
|
+
"version": "1.0.8",
|
|
4
|
+
"description": "purrx, a lightweight AI coding agent for your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"purrx": "bin/purrx.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"AGENTS.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/purrx.js",
|
|
19
|
+
"check": "node scripts/check.js",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "node scripts/check.js && tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"agent",
|
|
26
|
+
"coding-agent",
|
|
27
|
+
"cli",
|
|
28
|
+
"codex",
|
|
29
|
+
"chatgpt",
|
|
30
|
+
"openai",
|
|
31
|
+
"mcp",
|
|
32
|
+
"llm",
|
|
33
|
+
"tui"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/mewbleh/purrx.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/mewbleh/purrx#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/mewbleh/purrx/issues"
|
|
45
|
+
},
|
|
46
|
+
"author": "mewbleh",
|
|
47
|
+
"license": "Apache-2.0",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^26.0.1",
|
|
53
|
+
"typescript": "^6.0.3"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@inquirer/prompts": "^8.5.2",
|
|
57
|
+
"boxen": "^8.0.1",
|
|
58
|
+
"chalk": "^5.6.2",
|
|
59
|
+
"cli-highlight": "^2.1.11",
|
|
60
|
+
"marked": "^15.0.12",
|
|
61
|
+
"marked-terminal": "^7.3.0",
|
|
62
|
+
"ora": "^9.4.1"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { API_BASE, CHATGPT_BASE } from "../config.js";
|
|
2
|
+
|
|
3
|
+
// Builds the request URL and headers for the resolved auth mode.
|
|
4
|
+
/**
|
|
5
|
+
* @param {import("../types.js").AuthInfo} authInfo
|
|
6
|
+
* @returns {{ url: string, headers: Record<string, string> }}
|
|
7
|
+
*/
|
|
8
|
+
function buildRequest(authInfo) {
|
|
9
|
+
if (authInfo.mode === "chatgpt") {
|
|
10
|
+
/** @type {Record<string, string>} */
|
|
11
|
+
const headers = {
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
Authorization: `Bearer ${authInfo.accessToken}`,
|
|
14
|
+
"OpenAI-Beta": "responses=experimental",
|
|
15
|
+
originator: "codex_cli_rs",
|
|
16
|
+
};
|
|
17
|
+
if (authInfo.accountId) {
|
|
18
|
+
headers["chatgpt-account-id"] = authInfo.accountId;
|
|
19
|
+
}
|
|
20
|
+
return { url: `${CHATGPT_BASE}/responses`, headers };
|
|
21
|
+
}
|
|
22
|
+
// API-key mode.
|
|
23
|
+
return {
|
|
24
|
+
url: `${API_BASE}/responses`,
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
Authorization: `Bearer ${authInfo.apiKey}`,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Streams a Responses API request. Calls handlers as SSE events arrive.
|
|
33
|
+
// onText(deltaString) -> assistant text deltas
|
|
34
|
+
// onEvent(parsedEvent) -> every parsed event (for tool call assembly)
|
|
35
|
+
// Returns the final "response.completed" payload.
|
|
36
|
+
/**
|
|
37
|
+
* @param {Object} req
|
|
38
|
+
* @param {import("../types.js").AuthInfo} req.authInfo
|
|
39
|
+
* @param {string} req.model
|
|
40
|
+
* @param {import("../types.js").HistoryItem[]} req.input
|
|
41
|
+
* @param {import("../types.js").ToolDefinition[]} [req.tools]
|
|
42
|
+
* @param {string} [req.instructions]
|
|
43
|
+
* @param {{ onText?: (delta: string) => void, onEvent?: (event: any) => void }} [handlers]
|
|
44
|
+
* @returns {Promise<any>}
|
|
45
|
+
*/
|
|
46
|
+
export async function streamResponse({ authInfo, model, input, tools, instructions }, handlers = {}) {
|
|
47
|
+
const { url, headers } = buildRequest(authInfo);
|
|
48
|
+
|
|
49
|
+
/** @type {Record<string, any>} */
|
|
50
|
+
const payload = {
|
|
51
|
+
model,
|
|
52
|
+
input,
|
|
53
|
+
stream: true,
|
|
54
|
+
store: false,
|
|
55
|
+
};
|
|
56
|
+
if (instructions) payload.instructions = instructions;
|
|
57
|
+
if (tools && tools.length) {
|
|
58
|
+
payload.tools = tools;
|
|
59
|
+
payload.tool_choice = "auto";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resp = await fetch(url, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers,
|
|
65
|
+
body: JSON.stringify(payload),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!resp.ok || !resp.body) {
|
|
69
|
+
const text = await resp.text().catch(() => "");
|
|
70
|
+
throw new Error(`API request failed (${resp.status}): ${text}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const reader = resp.body.getReader();
|
|
74
|
+
const decoder = new TextDecoder();
|
|
75
|
+
let buffer = "";
|
|
76
|
+
let finalResponse = null;
|
|
77
|
+
|
|
78
|
+
while (true) {
|
|
79
|
+
const { value, done } = await reader.read();
|
|
80
|
+
if (done) break;
|
|
81
|
+
buffer += decoder.decode(value, { stream: true });
|
|
82
|
+
|
|
83
|
+
// SSE frames are separated by a blank line.
|
|
84
|
+
let idx;
|
|
85
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
86
|
+
const frame = buffer.slice(0, idx);
|
|
87
|
+
buffer = buffer.slice(idx + 2);
|
|
88
|
+
|
|
89
|
+
const dataLines = frame
|
|
90
|
+
.split("\n")
|
|
91
|
+
.filter((l) => l.startsWith("data:"))
|
|
92
|
+
.map((l) => l.slice(5).trim());
|
|
93
|
+
if (!dataLines.length) continue;
|
|
94
|
+
|
|
95
|
+
const data = dataLines.join("\n");
|
|
96
|
+
if (data === "[DONE]") continue;
|
|
97
|
+
|
|
98
|
+
let event;
|
|
99
|
+
try {
|
|
100
|
+
event = JSON.parse(data);
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (handlers.onEvent) handlers.onEvent(event);
|
|
106
|
+
|
|
107
|
+
if (event.type === "response.output_text.delta" && handlers.onText) {
|
|
108
|
+
handlers.onText(event.delta || "");
|
|
109
|
+
}
|
|
110
|
+
if (event.type === "response.completed") {
|
|
111
|
+
finalResponse = event.response;
|
|
112
|
+
}
|
|
113
|
+
if (event.type === "error" || event.type === "response.failed") {
|
|
114
|
+
const msg = event.error?.message || event.response?.error?.message || "unknown error";
|
|
115
|
+
throw new Error(`API stream error: ${msg}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return finalResponse;
|
|
121
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { API_BASE, CHATGPT_BASE, DEFAULT_MODEL } from "../config.js";
|
|
2
|
+
|
|
3
|
+
// Fetches the list of model ids the current account can actually use.
|
|
4
|
+
// - API-key mode: GET https://api.openai.com/v1/models
|
|
5
|
+
// - ChatGPT mode: the codex backend does not expose a public models list the
|
|
6
|
+
// same way, so we probe a known-good set and fall back to the default.
|
|
7
|
+
export async function listModels(authInfo) {
|
|
8
|
+
if (authInfo.mode === "apikey") {
|
|
9
|
+
return listApiKeyModels(authInfo.apiKey);
|
|
10
|
+
}
|
|
11
|
+
return listChatGptModels();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function listApiKeyModels(apiKey) {
|
|
15
|
+
try {
|
|
16
|
+
const resp = await fetch(`${API_BASE}/models`, {
|
|
17
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
18
|
+
});
|
|
19
|
+
if (!resp.ok) return fallbackModels();
|
|
20
|
+
const json = /** @type {any} */ (await resp.json());
|
|
21
|
+
const ids = (json.data || [])
|
|
22
|
+
.map((/** @type {any} */ m) => m.id)
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
// Keep models usable for chat/agent work (gpt*, o*, codex).
|
|
25
|
+
.filter((/** @type {string} */ id) => /^(gpt|o\d|codex|chatgpt)/i.test(id))
|
|
26
|
+
.sort();
|
|
27
|
+
return ids.length ? ids : fallbackModels();
|
|
28
|
+
} catch {
|
|
29
|
+
return fallbackModels();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The ChatGPT/Codex backend exposes models tied to the plan. There is no
|
|
34
|
+
// stable public list endpoint, so we report the commonly available Codex
|
|
35
|
+
// models. DEFAULT_MODEL is always first.
|
|
36
|
+
function listChatGptModels() {
|
|
37
|
+
const known = ["gpt-5-codex", "gpt-5", "gpt-5-mini", "o4-mini", "o3"];
|
|
38
|
+
return dedupeWithDefault(known);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fallbackModels() {
|
|
42
|
+
return dedupeWithDefault(["gpt-5-codex", "gpt-5", "gpt-5-mini", "gpt-4.1"]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dedupeWithDefault(list) {
|
|
46
|
+
const set = new Set([DEFAULT_MODEL, ...list]);
|
|
47
|
+
return [...set];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Resolves the model to use: explicit override > config/session > a model the
|
|
51
|
+
// account actually has > DEFAULT_MODEL.
|
|
52
|
+
export async function resolveModel(authInfo, preferred) {
|
|
53
|
+
if (preferred) return preferred;
|
|
54
|
+
const available = await listModels(authInfo).catch(() => []);
|
|
55
|
+
if (available.includes(DEFAULT_MODEL)) return DEFAULT_MODEL;
|
|
56
|
+
return available[0] || DEFAULT_MODEL;
|
|
57
|
+
}
|