@mewbleh/purrx 1.0.8 → 1.0.11
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/bin/purrx.js +1 -0
- package/package.json +1 -1
- package/src/api/client.js +18 -3
- package/src/api/models.js +13 -10
- package/src/auth/tokens.js +13 -1
- package/src/config.js +2 -2
- package/src/core/agent.js +23 -15
- package/src/ui/tui.js +1 -0
package/bin/purrx.js
CHANGED
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -7,12 +7,16 @@ import { API_BASE, CHATGPT_BASE } from "../config.js";
|
|
|
7
7
|
*/
|
|
8
8
|
function buildRequest(authInfo) {
|
|
9
9
|
if (authInfo.mode === "chatgpt") {
|
|
10
|
+
// These headers match the proven-working ChatGPT-codex request shape.
|
|
11
|
+
// The OpenAI-Beta header is required; the backend rejects requests without
|
|
12
|
+
// it (with a misleading "model not supported" error). Do NOT add an
|
|
13
|
+
// `originator` header here: the codex backend does not expect it on this
|
|
14
|
+
// path and its presence can cause the request to be rejected.
|
|
10
15
|
/** @type {Record<string, string>} */
|
|
11
16
|
const headers = {
|
|
12
17
|
"Content-Type": "application/json",
|
|
13
18
|
Authorization: `Bearer ${authInfo.accessToken}`,
|
|
14
19
|
"OpenAI-Beta": "responses=experimental",
|
|
15
|
-
originator: "codex_cli_rs",
|
|
16
20
|
};
|
|
17
21
|
if (authInfo.accountId) {
|
|
18
22
|
headers["chatgpt-account-id"] = authInfo.accountId;
|
|
@@ -40,20 +44,31 @@ function buildRequest(authInfo) {
|
|
|
40
44
|
* @param {import("../types.js").HistoryItem[]} req.input
|
|
41
45
|
* @param {import("../types.js").ToolDefinition[]} [req.tools]
|
|
42
46
|
* @param {string} [req.instructions]
|
|
47
|
+
* @param {string} [req.sessionId] stable id used for prompt caching
|
|
43
48
|
* @param {{ onText?: (delta: string) => void, onEvent?: (event: any) => void }} [handlers]
|
|
44
49
|
* @returns {Promise<any>}
|
|
45
50
|
*/
|
|
46
|
-
export async function streamResponse(
|
|
51
|
+
export async function streamResponse(
|
|
52
|
+
{ authInfo, model, input, tools, instructions, sessionId },
|
|
53
|
+
handlers = {}
|
|
54
|
+
) {
|
|
47
55
|
const { url, headers } = buildRequest(authInfo);
|
|
48
56
|
|
|
57
|
+
// Minimal request shape that the ChatGPT-codex backend accepts. The backend
|
|
58
|
+
// requires `instructions` to be present (a string, even if empty) and
|
|
59
|
+
// `store: false`. We deliberately avoid adding reasoning/include/cache fields
|
|
60
|
+
// here: the backend rejects requests it doesn't recognize as a valid Codex
|
|
61
|
+
// session with a misleading "model not supported" error, and the known-good
|
|
62
|
+
// client sends only these fields.
|
|
49
63
|
/** @type {Record<string, any>} */
|
|
50
64
|
const payload = {
|
|
51
65
|
model,
|
|
66
|
+
instructions: instructions || "",
|
|
52
67
|
input,
|
|
53
68
|
stream: true,
|
|
54
69
|
store: false,
|
|
55
70
|
};
|
|
56
|
-
|
|
71
|
+
|
|
57
72
|
if (tools && tools.length) {
|
|
58
73
|
payload.tools = tools;
|
|
59
74
|
payload.tool_choice = "auto";
|
package/src/api/models.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { API_BASE,
|
|
1
|
+
import { API_BASE, DEFAULT_MODEL } from "../config.js";
|
|
2
2
|
|
|
3
3
|
// Fetches the list of model ids the current account can actually use.
|
|
4
4
|
// - API-key mode: GET https://api.openai.com/v1/models
|
|
5
5
|
// - ChatGPT mode: the codex backend does not expose a public models list the
|
|
6
|
-
// same way, so we
|
|
6
|
+
// same way, so we report a known-good set.
|
|
7
7
|
export async function listModels(authInfo) {
|
|
8
8
|
if (authInfo.mode === "apikey") {
|
|
9
9
|
return listApiKeyModels(authInfo.apiKey);
|
|
@@ -24,18 +24,16 @@ async function listApiKeyModels(apiKey) {
|
|
|
24
24
|
// Keep models usable for chat/agent work (gpt*, o*, codex).
|
|
25
25
|
.filter((/** @type {string} */ id) => /^(gpt|o\d|codex|chatgpt)/i.test(id))
|
|
26
26
|
.sort();
|
|
27
|
-
return ids.length ? ids : fallbackModels();
|
|
27
|
+
return ids.length ? dedupeWithDefault(ids) : fallbackModels();
|
|
28
28
|
} catch {
|
|
29
29
|
return fallbackModels();
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// The ChatGPT/Codex backend exposes models tied to the plan. There is no
|
|
34
|
-
//
|
|
35
|
-
// models. DEFAULT_MODEL is always first.
|
|
33
|
+
// The ChatGPT/Codex backend exposes models tied to the plan. There is no stable
|
|
34
|
+
// public list endpoint, so we report the commonly available Codex models.
|
|
36
35
|
function listChatGptModels() {
|
|
37
|
-
|
|
38
|
-
return dedupeWithDefault(known);
|
|
36
|
+
return dedupeWithDefault(["gpt-5-codex", "gpt-5", "gpt-5-mini", "o4-mini", "o3"]);
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
function fallbackModels() {
|
|
@@ -47,8 +45,13 @@ function dedupeWithDefault(list) {
|
|
|
47
45
|
return [...set];
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
// Resolves the model to use: explicit override >
|
|
51
|
-
//
|
|
48
|
+
// Resolves the model to use: explicit override > a model the account actually
|
|
49
|
+
// has > DEFAULT_MODEL.
|
|
50
|
+
/**
|
|
51
|
+
* @param {import("../types.js").AuthInfo} authInfo
|
|
52
|
+
* @param {string} [preferred]
|
|
53
|
+
* @returns {Promise<string>}
|
|
54
|
+
*/
|
|
52
55
|
export async function resolveModel(authInfo, preferred) {
|
|
53
56
|
if (preferred) return preferred;
|
|
54
57
|
const available = await listModels(authInfo).catch(() => []);
|
package/src/auth/tokens.js
CHANGED
|
@@ -173,10 +173,22 @@ export function resolveAuthMode(auth) {
|
|
|
173
173
|
}
|
|
174
174
|
if (!auth) return null;
|
|
175
175
|
if (auth.tokens?.access_token) {
|
|
176
|
+
// The chatgpt-account-id header is required by the codex backend. Prefer
|
|
177
|
+
// the stored account_id, but fall back to deriving it from the access or
|
|
178
|
+
// id token claims if it is missing (older logins may not have saved it).
|
|
179
|
+
let accountId = auth.tokens.account_id || null;
|
|
180
|
+
if (!accountId) {
|
|
181
|
+
accountId =
|
|
182
|
+
jwtAuthClaims(auth.tokens.access_token)["chatgpt_account_id"] ||
|
|
183
|
+
(auth.tokens.id_token
|
|
184
|
+
? jwtAuthClaims(auth.tokens.id_token)["chatgpt_account_id"]
|
|
185
|
+
: null) ||
|
|
186
|
+
null;
|
|
187
|
+
}
|
|
176
188
|
return {
|
|
177
189
|
mode: "chatgpt",
|
|
178
190
|
accessToken: auth.tokens.access_token,
|
|
179
|
-
accountId
|
|
191
|
+
accountId,
|
|
180
192
|
};
|
|
181
193
|
}
|
|
182
194
|
if (auth.OPENAI_API_KEY) {
|
package/src/config.js
CHANGED
|
@@ -13,8 +13,8 @@ export const OAUTH_SCOPE =
|
|
|
13
13
|
export const API_BASE = "https://api.openai.com/v1";
|
|
14
14
|
export const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
|
|
15
15
|
|
|
16
|
-
// Default model.
|
|
17
|
-
//
|
|
16
|
+
// Default model. gpt-5-codex is the default Codex coding model and works on
|
|
17
|
+
// both API-key and ChatGPT-account auth. Override with PURRX_MODEL.
|
|
18
18
|
export const DEFAULT_MODEL = process.env.PURRX_MODEL || "gpt-5-codex";
|
|
19
19
|
|
|
20
20
|
// Approximate context window (in tokens) used to decide when to auto-compact
|
package/src/core/agent.js
CHANGED
|
@@ -45,6 +45,7 @@ Guidelines:
|
|
|
45
45
|
* @param {import("../tools/registry.js").ToolRegistry} opts.registry
|
|
46
46
|
* @param {{requestApproval: (name: string, label: string) => Promise<string>}} [opts.approval]
|
|
47
47
|
* @param {() => void} [opts.onChange]
|
|
48
|
+
* @param {string} [opts.sessionId] stable id used for prompt caching
|
|
48
49
|
* @returns {Promise<import("../types.js").HistoryItem[]>}
|
|
49
50
|
*/
|
|
50
51
|
export async function runTurn({
|
|
@@ -56,6 +57,7 @@ export async function runTurn({
|
|
|
56
57
|
registry,
|
|
57
58
|
approval,
|
|
58
59
|
onChange,
|
|
60
|
+
sessionId,
|
|
59
61
|
}) {
|
|
60
62
|
history.push({
|
|
61
63
|
type: "message",
|
|
@@ -84,22 +86,28 @@ export async function runTurn({
|
|
|
84
86
|
|
|
85
87
|
spinner.start();
|
|
86
88
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
buffered += delta;
|
|
89
|
+
let finalResponse;
|
|
90
|
+
try {
|
|
91
|
+
finalResponse = await streamResponse(
|
|
92
|
+
{
|
|
93
|
+
authInfo,
|
|
94
|
+
model,
|
|
95
|
+
input: history,
|
|
96
|
+
tools,
|
|
97
|
+
instructions: systemInstructions(cwd, registry),
|
|
98
|
+
sessionId,
|
|
98
99
|
},
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
{
|
|
101
|
+
onText: (delta) => {
|
|
102
|
+
buffered += delta;
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
} finally {
|
|
107
|
+
// Always stop the spinner, even if the request throws, so it never
|
|
108
|
+
// keeps spinning under an error message.
|
|
109
|
+
spinner.stop();
|
|
110
|
+
}
|
|
103
111
|
|
|
104
112
|
if (!finalResponse) {
|
|
105
113
|
throw new Error("No response received from the model.");
|
package/src/ui/tui.js
CHANGED
|
@@ -101,6 +101,7 @@ export async function startTui(authInfo, options = {}) {
|
|
|
101
101
|
registry,
|
|
102
102
|
approval: state.approval,
|
|
103
103
|
onChange: persist,
|
|
104
|
+
sessionId: session.id,
|
|
104
105
|
});
|
|
105
106
|
} catch (err) {
|
|
106
107
|
console.log(box(chalk.red(err.message), { title: "error", borderColor: "red" }));
|