@mewbleh/purrx 1.0.10 → 1.0.13

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
@@ -335,6 +335,15 @@ async function main() {
335
335
  case "status":
336
336
  cmdStatus();
337
337
  break;
338
+ case "version":
339
+ case "--version":
340
+ case "-v": {
341
+ const { createRequire } = await import("node:module");
342
+ const require = createRequire(import.meta.url);
343
+ const pkg = require("../package.json");
344
+ console.log(`purrx ${pkg.version}`);
345
+ break;
346
+ }
338
347
  case "help":
339
348
  case "--help":
340
349
  case "-h":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mewbleh/purrx",
3
- "version": "1.0.10",
3
+ "version": "1.0.13",
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
@@ -1,29 +1,38 @@
1
1
  import { API_BASE, CHATGPT_BASE } from "../config.js";
2
2
 
3
- // Models that use the Responses API reasoning fields. gpt-5 family, o-series,
4
- // and any *-codex variant are reasoning models.
3
+ // Models that take the Responses API reasoning fields (gpt-5 family, o-series,
4
+ // and codex variants). The official Codex client always sends a reasoning block
5
+ // for these and requests encrypted reasoning back since store is false.
5
6
  function isReasoningModel(model) {
6
7
  return /^(gpt-5|o\d|codex)/i.test(model) || /-codex\b/i.test(model);
7
8
  }
8
9
 
9
10
  // Builds the request URL and headers for the resolved auth mode.
11
+ //
12
+ // The ChatGPT-codex header set mirrors the official Codex CLI
13
+ // (codex-rs default_client + BearerAuthProvider + build_session_headers):
14
+ // Authorization, ChatGPT-Account-ID, originator, User-Agent,
15
+ // OpenAI-Beta, session-id.
10
16
  /**
11
17
  * @param {import("../types.js").AuthInfo} authInfo
18
+ * @param {string} [sessionId]
12
19
  * @returns {{ url: string, headers: Record<string, string> }}
13
20
  */
14
- function buildRequest(authInfo) {
21
+ function buildRequest(authInfo, sessionId) {
15
22
  if (authInfo.mode === "chatgpt") {
16
23
  /** @type {Record<string, string>} */
17
24
  const headers = {
18
25
  "Content-Type": "application/json",
19
26
  Authorization: `Bearer ${authInfo.accessToken}`,
20
- // Codex backend identifies the client via originator + session headers.
27
+ "OpenAI-Beta": "responses=experimental",
21
28
  originator: "codex_cli_rs",
22
- "User-Agent": "purrx",
29
+ "User-Agent": "codex_cli_rs/0.0.0 (purrx)",
23
30
  };
24
31
  if (authInfo.accountId) {
25
- headers["chatgpt-account-id"] = authInfo.accountId;
32
+ // Casing matches the official client (BearerAuthProvider).
33
+ headers["ChatGPT-Account-ID"] = authInfo.accountId;
26
34
  }
35
+ if (sessionId) headers["session-id"] = sessionId;
27
36
  return { url: `${CHATGPT_BASE}/responses`, headers };
28
37
  }
29
38
  // API-key mode.
@@ -47,7 +56,7 @@ function buildRequest(authInfo) {
47
56
  * @param {import("../types.js").HistoryItem[]} req.input
48
57
  * @param {import("../types.js").ToolDefinition[]} [req.tools]
49
58
  * @param {string} [req.instructions]
50
- * @param {string} [req.sessionId] stable id used for prompt caching
59
+ * @param {string} [req.sessionId] stable id, sent as session-id + prompt_cache_key
51
60
  * @param {{ onText?: (delta: string) => void, onEvent?: (event: any) => void }} [handlers]
52
61
  * @returns {Promise<any>}
53
62
  */
@@ -55,33 +64,44 @@ export async function streamResponse(
55
64
  { authInfo, model, input, tools, instructions, sessionId },
56
65
  handlers = {}
57
66
  ) {
58
- const { url, headers } = buildRequest(authInfo);
67
+ const { url, headers } = buildRequest(authInfo, sessionId);
59
68
 
69
+ // Request body matching the official Codex ResponsesApiRequest. These fields
70
+ // are always present in the real client; sending a stripped-down body is what
71
+ // triggers the backend's misleading "model is not supported" rejection.
60
72
  /** @type {Record<string, any>} */
61
73
  const payload = {
62
74
  model,
75
+ instructions: instructions || "",
63
76
  input,
64
- stream: true,
65
- // The Codex backend requires store:false; responses are not persisted.
77
+ tool_choice: "auto",
78
+ parallel_tool_calls: false,
66
79
  store: false,
67
- // Stable cache key improves latency/cost across turns (matches Codex).
68
- prompt_cache_key: sessionId || "purrx",
80
+ stream: true,
81
+ include: [],
69
82
  };
70
- if (instructions) payload.instructions = instructions;
71
83
 
72
- // Reasoning models (gpt-5*, o-series, *-codex) require a reasoning block and
73
- // must request the encrypted reasoning content back since we don't persist
74
- // responses (store:false). This mirrors the official Codex client and is the
75
- // shape the ChatGPT/Codex backend expects.
76
84
  if (isReasoningModel(model)) {
77
85
  payload.reasoning = { effort: "medium", summary: "auto" };
78
86
  payload.include = ["reasoning.encrypted_content"];
79
87
  }
80
88
 
89
+ if (sessionId) payload.prompt_cache_key = sessionId;
90
+
81
91
  if (tools && tools.length) {
82
92
  payload.tools = tools;
83
- payload.tool_choice = "auto";
84
- payload.parallel_tool_calls = true;
93
+ }
94
+
95
+ // Debug: dump the exact outgoing request when PURRX_DEBUG is set. Auth tokens
96
+ // are redacted. This is the fastest way to diagnose backend rejections.
97
+ if (process.env.PURRX_DEBUG) {
98
+ const safeHeaders = { ...headers };
99
+ if (safeHeaders.Authorization) safeHeaders.Authorization = "Bearer <redacted>";
100
+ console.error(`\n[purrx] POST ${url}`);
101
+ console.error(`[purrx] headers: ${JSON.stringify(safeHeaders)}`);
102
+ console.error(
103
+ `[purrx] body: ${JSON.stringify({ ...payload, input: `<${input.length} items>` })}`
104
+ );
85
105
  }
86
106
 
87
107
  const resp = await fetch(url, {
@@ -92,6 +112,9 @@ export async function streamResponse(
92
112
 
93
113
  if (!resp.ok || !resp.body) {
94
114
  const text = await resp.text().catch(() => "");
115
+ if (process.env.PURRX_DEBUG) {
116
+ console.error(`[purrx] response ${resp.status}: ${text}`);
117
+ }
95
118
  throw new Error(`API request failed (${resp.status}): ${text}`);
96
119
  }
97
120
 
@@ -111,7 +111,7 @@ export async function refreshTokens(refreshToken) {
111
111
  client_id: OAUTH_CLIENT_ID,
112
112
  grant_type: "refresh_token",
113
113
  refresh_token: refreshToken,
114
- scope: "openid profile email",
114
+ scope: "openid profile email offline_access",
115
115
  }),
116
116
  });
117
117
  if (!resp.ok) {
@@ -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) {