@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 CHANGED
@@ -173,6 +173,7 @@ async function cmdExec(args) {
173
173
  registry,
174
174
  approval,
175
175
  onChange: () => saveSession(session),
176
+ sessionId: session.id,
176
177
  });
177
178
  } finally {
178
179
  registry.shutdown();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mewbleh/purrx",
3
- "version": "1.0.8",
3
+ "version": "1.0.11",
4
4
  "description": "purrx, a lightweight AI coding agent for your terminal",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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({ authInfo, model, input, tools, instructions }, handlers = {}) {
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
- if (instructions) payload.instructions = instructions;
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, CHATGPT_BASE, DEFAULT_MODEL } from "../config.js";
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 probe a known-good set and fall back to the default.
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
- // stable public list endpoint, so we report the commonly available Codex
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
- const known = ["gpt-5-codex", "gpt-5", "gpt-5-mini", "o4-mini", "o3"];
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 > config/session > a model the
51
- // account actually has > DEFAULT_MODEL.
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(() => []);
@@ -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: auth.tokens.account_id || null,
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. Used as a fallback when discovery is unavailable.
17
- // gpt-5-codex is the default Codex coding model.
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
- const finalResponse = await streamResponse(
88
- {
89
- authInfo,
90
- model,
91
- input: history,
92
- tools,
93
- instructions: systemInstructions(cwd, registry),
94
- },
95
- {
96
- onText: (delta) => {
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
- spinner.stop();
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" }));