@mewbleh/purrx 1.0.13 → 1.0.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mewbleh/purrx",
3
- "version": "1.0.13",
3
+ "version": "1.0.16",
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,4 +1,4 @@
1
- import { API_BASE, CHATGPT_BASE } from "../config.js";
1
+ import { API_BASE, CHATGPT_BASE, CODEX_CLI_VERSION } from "../config.js";
2
2
 
3
3
  // Models that take the Responses API reasoning fields (gpt-5 family, o-series,
4
4
  // and codex variants). The official Codex client always sends a reasoning block
@@ -9,10 +9,13 @@ function isReasoningModel(model) {
9
9
 
10
10
  // Builds the request URL and headers for the resolved auth mode.
11
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.
12
+ // IMPORTANT: For the ChatGPT-codex backend we deliberately send the MINIMAL
13
+ // header set that the proven-working community proxy uses
14
+ // (Authorization + ChatGPT-Account-ID + OpenAI-Beta). We do NOT send
15
+ // `originator`/`version`/`session-id`: claiming to be the official Codex CLI
16
+ // makes the backend apply stricter validation that rejects the request with a
17
+ // misleading "model not supported" error. Set PURRX_CODEX_HEADERS=1 to opt into
18
+ // the full Codex-style header set for experimentation.
16
19
  /**
17
20
  * @param {import("../types.js").AuthInfo} authInfo
18
21
  * @param {string} [sessionId]
@@ -25,14 +28,17 @@ function buildRequest(authInfo, sessionId) {
25
28
  "Content-Type": "application/json",
26
29
  Authorization: `Bearer ${authInfo.accessToken}`,
27
30
  "OpenAI-Beta": "responses=experimental",
28
- originator: "codex_cli_rs",
29
- "User-Agent": "codex_cli_rs/0.0.0 (purrx)",
30
31
  };
31
32
  if (authInfo.accountId) {
32
- // Casing matches the official client (BearerAuthProvider).
33
- headers["ChatGPT-Account-ID"] = authInfo.accountId;
33
+ headers["chatgpt-account-id"] = authInfo.accountId;
34
+ }
35
+ // Opt-in: full Codex-CLI-style headers (originator/version/session-id).
36
+ if (process.env.PURRX_CODEX_HEADERS) {
37
+ headers.originator = "codex_cli_rs";
38
+ headers.version = CODEX_CLI_VERSION;
39
+ headers["User-Agent"] = `codex_cli_rs/${CODEX_CLI_VERSION} (purrx)`;
40
+ if (sessionId) headers["session-id"] = sessionId;
34
41
  }
35
- if (sessionId) headers["session-id"] = sessionId;
36
42
  return { url: `${CHATGPT_BASE}/responses`, headers };
37
43
  }
38
44
  // API-key mode.
@@ -65,31 +71,45 @@ export async function streamResponse(
65
71
  handlers = {}
66
72
  ) {
67
73
  const { url, headers } = buildRequest(authInfo, sessionId);
74
+ const isChatGpt = authInfo.mode === "chatgpt";
75
+
76
+ // Minimal body matching the proven-working community proxy: instructions,
77
+ // store, stream, plus input/tools. We avoid reasoning/include/prompt_cache_key
78
+ // and server-side tools (web_search) on the ChatGPT path, since those can
79
+ // trigger the backend's misleading "model not supported" rejection.
80
+ // Set PURRX_FULL_BODY=1 to send the richer Codex-style body.
81
+ const fullBody = !isChatGpt || process.env.PURRX_FULL_BODY;
68
82
 
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.
72
83
  /** @type {Record<string, any>} */
73
84
  const payload = {
74
85
  model,
75
86
  instructions: instructions || "",
76
87
  input,
77
- tool_choice: "auto",
78
- parallel_tool_calls: false,
79
88
  store: false,
80
89
  stream: true,
81
- include: [],
82
90
  };
83
91
 
84
- if (isReasoningModel(model)) {
85
- payload.reasoning = { effort: "medium", summary: "auto" };
86
- payload.include = ["reasoning.encrypted_content"];
92
+ if (fullBody) {
93
+ payload.tool_choice = "auto";
94
+ payload.parallel_tool_calls = false;
95
+ payload.include = [];
96
+ if (isReasoningModel(model)) {
97
+ payload.reasoning = { effort: "medium", summary: "auto" };
98
+ payload.include = ["reasoning.encrypted_content"];
99
+ }
100
+ if (sessionId) payload.prompt_cache_key = sessionId;
87
101
  }
88
102
 
89
- if (sessionId) payload.prompt_cache_key = sessionId;
90
-
91
103
  if (tools && tools.length) {
92
- payload.tools = tools;
104
+ // On the ChatGPT path, only send function tools (drop server-side tools
105
+ // like web_search which the codex backend may reject).
106
+ const filtered = isChatGpt
107
+ ? tools.filter((t) => t.type === "function")
108
+ : tools;
109
+ if (filtered.length) {
110
+ payload.tools = filtered;
111
+ if (!payload.tool_choice) payload.tool_choice = "auto";
112
+ }
93
113
  }
94
114
 
95
115
  // Debug: dump the exact outgoing request when PURRX_DEBUG is set. Auth tokens
package/src/auth/login.js CHANGED
@@ -25,6 +25,9 @@ function buildAuthorizeUrl(redirectUri, pkce, state) {
25
25
  id_token_add_organizations: "true",
26
26
  codex_cli_simplified_flow: "true",
27
27
  state,
28
+ // The official Codex authorize request includes the originator; the issued
29
+ // token's Codex entitlement can depend on it.
30
+ originator: "codex_cli_rs",
28
31
  });
29
32
  return `${OAUTH_ISSUER}/oauth/authorize?${params.toString()}`;
30
33
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import {
3
3
  authFilePath,
4
+ resolveAuthFileForRead,
4
5
  purrxHome,
5
6
  OAUTH_CLIENT_ID,
6
7
  OAUTH_ISSUER,
@@ -15,7 +16,7 @@ import {
15
16
 
16
17
  /** @returns {AuthDotJson|null} */
17
18
  export function readAuth() {
18
- const file = authFilePath();
19
+ const file = resolveAuthFileForRead();
19
20
  try {
20
21
  const raw = fs.readFileSync(file, "utf8");
21
22
  return JSON.parse(raw);
package/src/config.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import path from "node:path";
2
+ import os from "node:os";
3
+ import fs from "node:fs";
2
4
  import { dataHome } from "./platform.js";
3
5
 
4
6
  // OAuth constants taken from the official OpenAI Codex CLI so we can reuse the
@@ -13,6 +15,12 @@ export const OAUTH_SCOPE =
13
15
  export const API_BASE = "https://api.openai.com/v1";
14
16
  export const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
15
17
 
18
+ // The official Codex client sends a `version` header (its CLI version) on every
19
+ // request. The ChatGPT-codex backend uses it to recognize a legitimate Codex
20
+ // client; omitting it causes a generic "model not supported" rejection.
21
+ // Override with PURRX_CODEX_VERSION to match your installed Codex CLI.
22
+ export const CODEX_CLI_VERSION = process.env.PURRX_CODEX_VERSION || "0.5.0";
23
+
16
24
  // Default model. gpt-5-codex is the default Codex coding model and works on
17
25
  // both API-key and ChatGPT-account auth. Override with PURRX_MODEL.
18
26
  export const DEFAULT_MODEL = process.env.PURRX_MODEL || "gpt-5-codex";
@@ -37,6 +45,26 @@ export function authFilePath() {
37
45
  return path.join(purrxHome(), "auth.json");
38
46
  }
39
47
 
48
+ // Resolves the auth.json to READ from. Prefers purrx's own file, then falls
49
+ // back to the official Codex CLI locations (CODEX_HOME, ~/.codex). This lets
50
+ // purrx reuse a known-good token minted by `codex login`.
51
+ export function resolveAuthFileForRead() {
52
+ const candidates = [
53
+ process.env.PURRX_HOME && path.join(process.env.PURRX_HOME, "auth.json"),
54
+ path.join(purrxHome(), "auth.json"),
55
+ process.env.CODEX_HOME && path.join(process.env.CODEX_HOME, "auth.json"),
56
+ path.join(os.homedir(), ".codex", "auth.json"),
57
+ ].filter(Boolean);
58
+ for (const candidate of candidates) {
59
+ try {
60
+ if (fs.existsSync(/** @type {string} */ (candidate))) return candidate;
61
+ } catch {
62
+ // ignore
63
+ }
64
+ }
65
+ return authFilePath();
66
+ }
67
+
40
68
  export function sessionsDir() {
41
69
  return path.join(purrxHome(), "sessions");
42
70
  }