@yanzc/claude-pro 0.0.0-stage → 0.4.0
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/.env.example +41 -0
- package/LICENSE +21 -0
- package/README.md +314 -3
- package/bin/claude-pro.js +32 -0
- package/package.json +40 -6
- package/src/agent.js +752 -0
- package/src/agents.js +186 -0
- package/src/api.js +488 -0
- package/src/cache.js +150 -0
- package/src/checkpoints.js +184 -0
- package/src/claude-code.js +60 -0
- package/src/codex/client.js +300 -0
- package/src/codex/translate-request.js +239 -0
- package/src/codex/translate-stream.js +328 -0
- package/src/commands.js +803 -0
- package/src/config.js +453 -0
- package/src/context.js +109 -0
- package/src/filestate.js +57 -0
- package/src/gateway.js +110 -0
- package/src/hooks.js +81 -0
- package/src/main.js +193 -0
- package/src/mcp.js +290 -0
- package/src/modes.js +154 -0
- package/src/oauth.js +287 -0
- package/src/peers.js +156 -0
- package/src/print.js +106 -0
- package/src/prompts.js +179 -0
- package/src/ratelimit.js +244 -0
- package/src/restart.js +115 -0
- package/src/runtime.js +162 -0
- package/src/selfcheck.js +71 -0
- package/src/session.js +139 -0
- package/src/supervisor.js +110 -0
- package/src/tasks.js +76 -0
- package/src/tools/bash.js +227 -0
- package/src/tools/effort.js +41 -0
- package/src/tools/files.js +352 -0
- package/src/tools/index.js +52 -0
- package/src/tools/interaction.js +157 -0
- package/src/tools/notebook.js +91 -0
- package/src/tools/peers.js +45 -0
- package/src/tools/restart.js +50 -0
- package/src/tools/search.js +458 -0
- package/src/tools/skill.js +32 -0
- package/src/tools/task.js +139 -0
- package/src/tools/web.js +283 -0
- package/src/ui/ansi.js +228 -0
- package/src/ui/dialogs.js +122 -0
- package/src/ui/editor.js +389 -0
- package/src/ui/markdown.js +249 -0
- package/src/ui/render.js +155 -0
- package/src/ui/repl.js +1450 -0
- package/src/ui/screen.js +259 -0
- package/src/updater.js +93 -0
- package/src/utils/clipboard.js +197 -0
- package/src/utils/diff.js +95 -0
- package/src/utils/paths.js +37 -0
- package/src/utils/shell.js +251 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Checkpoints: before the agent edits a file (Write / Edit / NotebookEdit), the file's
|
|
2
|
+
// previous content is saved, grouped by the user prompt that led to the edit. /rewind can
|
|
3
|
+
// then put files back, rewind the conversation, or both. Changes made by shell commands
|
|
4
|
+
// (Bash) are not tracked.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { estimateTokens } from './agent.js';
|
|
10
|
+
|
|
11
|
+
const MAX_SNAPSHOT_BYTES = 200 * 1024 * 1024;
|
|
12
|
+
const keyOf = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
13
|
+
|
|
14
|
+
export class CheckpointStore {
|
|
15
|
+
// dir: where snapshot files are kept (created lazily).
|
|
16
|
+
constructor(dir) {
|
|
17
|
+
this.dir = dir;
|
|
18
|
+
// {id, label, time, messageIndex, conversationValid, orphaned, files: [{path, existed, snap, skipped}]}
|
|
19
|
+
this.entries = [];
|
|
20
|
+
this.counter = 0;
|
|
21
|
+
this.current = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Start a checkpoint for a new user prompt. messageIndex is where its message will sit in
|
|
25
|
+
// the conversation, so rewinding the conversation can cut it off there.
|
|
26
|
+
beginTurn({ label, messageIndex }) {
|
|
27
|
+
const entry = {
|
|
28
|
+
id: ++this.counter,
|
|
29
|
+
label: String(label || '').replace(/\s+/g, ' ').trim().slice(0, 200),
|
|
30
|
+
time: Date.now(),
|
|
31
|
+
messageIndex,
|
|
32
|
+
conversationValid: true,
|
|
33
|
+
orphaned: false,
|
|
34
|
+
files: [],
|
|
35
|
+
};
|
|
36
|
+
this.entries.push(entry);
|
|
37
|
+
this.current = entry;
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Record `file` as it is now, once per checkpoint. Call before the file is changed.
|
|
42
|
+
track(file) {
|
|
43
|
+
const entry = this.current;
|
|
44
|
+
if (!entry) return;
|
|
45
|
+
const abs = path.resolve(file);
|
|
46
|
+
if (entry.files.some((f) => keyOf(f.path) === keyOf(abs))) return;
|
|
47
|
+
let stat = null;
|
|
48
|
+
try { stat = fs.statSync(abs); } catch { /* does not exist yet */ }
|
|
49
|
+
if (!stat) { entry.files.push({ path: abs, existed: false }); this.writeIndex(); return; }
|
|
50
|
+
if (!stat.isFile()) return;
|
|
51
|
+
if (stat.size > MAX_SNAPSHOT_BYTES) { entry.files.push({ path: abs, existed: true, skipped: true }); return; }
|
|
52
|
+
const snap = `${entry.id}-${crypto.createHash('sha1').update(abs).digest('hex').slice(0, 12)}`;
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
55
|
+
fs.copyFileSync(abs, path.join(this.dir, snap));
|
|
56
|
+
entry.files.push({ path: abs, existed: true, snap });
|
|
57
|
+
this.writeIndex();
|
|
58
|
+
} catch {
|
|
59
|
+
entry.files.push({ path: abs, existed: true, skipped: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The state to hand to the next process of a restarted session (see adopt).
|
|
64
|
+
snapshot() { return { dir: this.dir, entries: this.entries, counter: this.counter }; }
|
|
65
|
+
|
|
66
|
+
// Take over what the process before a restart had: its snapshots stay where they are on disk.
|
|
67
|
+
adopt({ dir, entries, counter } = {}) {
|
|
68
|
+
if (!dir || !Array.isArray(entries)) return false;
|
|
69
|
+
this.dir = dir;
|
|
70
|
+
this.entries = entries;
|
|
71
|
+
this.counter = Math.max(Number(counter) || 0, ...entries.map((e) => e.id || 0));
|
|
72
|
+
this.current = entries[entries.length - 1] || null;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Checkpoints that are part of the current conversation, newest first.
|
|
77
|
+
list() { return this.entries.filter((e) => !e.orphaned).reverse(); }
|
|
78
|
+
get(id) { return this.entries.find((e) => e.id === id); }
|
|
79
|
+
|
|
80
|
+
// Files that rewinding to `id` would put back: every file touched by that checkpoint or a later one.
|
|
81
|
+
filesSince(id) {
|
|
82
|
+
const seen = new Map();
|
|
83
|
+
for (const e of this.entries) {
|
|
84
|
+
if (e.id < id) continue;
|
|
85
|
+
for (const f of e.files) if (!seen.has(keyOf(f.path))) seen.set(keyOf(f.path), f.path);
|
|
86
|
+
}
|
|
87
|
+
return [...seen.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Put every file touched since `id` back to how it was before that checkpoint.
|
|
91
|
+
// The oldest snapshot of each file decides its final state. Returns {restored, removed, skipped}.
|
|
92
|
+
restoreCode(id) {
|
|
93
|
+
const idx = this.entries.findIndex((e) => e.id === id);
|
|
94
|
+
if (idx < 0) throw new Error(`No checkpoint ${id}`);
|
|
95
|
+
const oldest = new Map();
|
|
96
|
+
for (let i = idx; i < this.entries.length; i++) {
|
|
97
|
+
for (const f of this.entries[i].files) if (!oldest.has(keyOf(f.path))) oldest.set(keyOf(f.path), f);
|
|
98
|
+
}
|
|
99
|
+
const result = { restored: [], removed: [], skipped: [] };
|
|
100
|
+
for (const f of oldest.values()) {
|
|
101
|
+
try {
|
|
102
|
+
if (f.skipped) { result.skipped.push(f.path); continue; }
|
|
103
|
+
if (f.existed) {
|
|
104
|
+
fs.mkdirSync(path.dirname(f.path), { recursive: true });
|
|
105
|
+
fs.copyFileSync(path.join(this.dir, f.snap), f.path);
|
|
106
|
+
result.restored.push(f.path);
|
|
107
|
+
} else {
|
|
108
|
+
fs.rmSync(f.path, { force: true });
|
|
109
|
+
result.removed.push(f.path);
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
result.skipped.push(`${f.path} (${e.message})`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Those changes no longer exist on disk, so later checkpoints have nothing left to undo.
|
|
116
|
+
for (let i = idx; i < this.entries.length; i++) this.entries[i].files = [];
|
|
117
|
+
this.writeIndex();
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Forget checkpoint `id` and everything after it (their turns were removed from the conversation).
|
|
122
|
+
discardFrom(id) {
|
|
123
|
+
this.entries = this.entries.filter((e) => e.id < id);
|
|
124
|
+
this.current = this.entries[this.entries.length - 1] || null;
|
|
125
|
+
this.writeIndex();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The conversation was rewound to before `id` but the files were left alone: keep the
|
|
129
|
+
// snapshots (the edits are still on disk) but stop listing those turns.
|
|
130
|
+
orphanFrom(id) {
|
|
131
|
+
for (const e of this.entries) if (e.id >= id) e.orphaned = true;
|
|
132
|
+
this.writeIndex();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The conversation was compacted or replaced: it can no longer be rewound to older points,
|
|
136
|
+
// but file snapshots remain valid.
|
|
137
|
+
markConversationStale() {
|
|
138
|
+
for (const e of this.entries) e.conversationValid = false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
reset() {
|
|
142
|
+
this.entries = [];
|
|
143
|
+
this.current = null;
|
|
144
|
+
this.writeIndex();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
writeIndex() {
|
|
148
|
+
try {
|
|
149
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
150
|
+
fs.writeFileSync(path.join(this.dir, 'index.json'), JSON.stringify(this.entries, null, 1));
|
|
151
|
+
} catch { /* best effort: the index only helps manual recovery */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Apply a rewind to a running agent. mode: 'both' | 'code' | 'conversation'.
|
|
156
|
+
// Returns {restore, prompt, conversation}: what was restored, the prompt text (so the UI can put it
|
|
157
|
+
// back in the input box) and whether the conversation was cut.
|
|
158
|
+
export function rewind(agent, store, id, mode = 'both') {
|
|
159
|
+
const entry = store.get(id);
|
|
160
|
+
if (!entry) throw new Error(`No checkpoint ${id}`);
|
|
161
|
+
if (mode !== 'code' && !entry.conversationValid) {
|
|
162
|
+
throw new Error('The conversation was compacted after this point, so it can no longer be rewound to it. You can still restore the code.');
|
|
163
|
+
}
|
|
164
|
+
const out = { restore: null, prompt: entry.label, conversation: false };
|
|
165
|
+
if (mode !== 'conversation') {
|
|
166
|
+
out.restore = store.restoreCode(id);
|
|
167
|
+
// Files changed behind the agent's back: make it read them again before editing.
|
|
168
|
+
agent.fileState.clear();
|
|
169
|
+
}
|
|
170
|
+
if (mode !== 'code') {
|
|
171
|
+
agent.replaceMessages(agent.messages.slice(0, entry.messageIndex));
|
|
172
|
+
agent.todos = [];
|
|
173
|
+
agent.lastContextTokens = estimateTokens(agent.messages);
|
|
174
|
+
agent.tokensSinceUsage = 0;
|
|
175
|
+
if (mode === 'both') store.discardFrom(id); else store.orphanFrom(id);
|
|
176
|
+
out.conversation = true;
|
|
177
|
+
} else {
|
|
178
|
+
agent.pushMessage({
|
|
179
|
+
role: 'user',
|
|
180
|
+
content: [{ type: 'text', text: `<system-reminder>The user rewound the code: ${out.restore.restored.length} file(s) were restored to their state before the prompt "${entry.label}" and ${out.restore.removed.length} file(s) created since then were deleted. Files may differ from what you last read; read them again before editing.</system-reminder>` }],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Read an existing Claude Code subscription login. There is no login flow here and no subprocess: signing
|
|
2
|
+
// in is the official application's. Renewing the access token with the refresh token stored beside it is
|
|
3
|
+
// done by oauth.js, which is the only code that writes the credentials file; this reads it.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
export const CLAUDE_CODE_API_URL = 'https://api.anthropic.com';
|
|
9
|
+
export const CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
10
|
+
export const CLAUDE_CODE_BETAS = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14';
|
|
11
|
+
const sessionIds = new WeakMap();
|
|
12
|
+
let installedVersion;
|
|
13
|
+
|
|
14
|
+
function readJson(file, label) {
|
|
15
|
+
let text;
|
|
16
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
17
|
+
catch { throw new Error(`Could not read existing Claude Code ${label}: ${file}. Select the existing login with CLAUDE_PRO_CLAUDE_CONFIG_FILE / CLAUDE_PRO_CLAUDE_CREDENTIALS_FILE.`); }
|
|
18
|
+
// JSON.parse errors can contain an excerpt of the token. Never echo them.
|
|
19
|
+
try { return JSON.parse(text.replace(/^\uFEFF/, '')); }
|
|
20
|
+
catch { throw new Error(`Invalid JSON in Claude Code ${label}: ${file}.`); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clientVersion(config, metadata) {
|
|
24
|
+
if (config.env.CLAUDE_PRO_CLAUDE_CODE_VERSION) return config.env.CLAUDE_PRO_CLAUDE_CODE_VERSION;
|
|
25
|
+
if (installedVersion) return installedVersion;
|
|
26
|
+
for (const dir of (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)) {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir.replace(/^"|"$/g, ''), 'node_modules', '@anthropic-ai', 'claude-code', 'package.json'), 'utf8'));
|
|
29
|
+
if (/^\d+\.\d+\.\d+$/.test(pkg.version)) return (installedVersion = pkg.version);
|
|
30
|
+
} catch { /* native installs do not have an npm package */ }
|
|
31
|
+
}
|
|
32
|
+
return /^\d+\.\d+\.\d+$/.test(metadata.lastOnboardingVersion || '') ? metadata.lastOnboardingVersion : '2.1.289';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readClaudeCodeLogin(config) {
|
|
36
|
+
const metadata = readJson(config.claudeCodeConfigFile, 'account configuration');
|
|
37
|
+
const credentials = readJson(config.claudeCodeCredentialsFile, 'credentials');
|
|
38
|
+
const oauth = credentials?.claudeAiOauth;
|
|
39
|
+
if (!oauth || typeof oauth.accessToken !== 'string' || !oauth.accessToken.trim()) {
|
|
40
|
+
throw new Error(`No existing Claude Code subscription access token in ${config.claudeCodeCredentialsFile}. The .claude.json file contains account metadata; the token is in .claude/.credentials.json.`);
|
|
41
|
+
}
|
|
42
|
+
if (oauth.expiresAt && Number(oauth.expiresAt) <= Date.now()) {
|
|
43
|
+
throw new Error('The existing Claude Code access token has expired and cannot be renewed from here (the credentials file has no refresh token, or CLAUDE_PRO_DISABLE_TOKEN_REFRESH is set). Run `claude` and use /login to sign in again; this project will read the updated file automatically.');
|
|
44
|
+
}
|
|
45
|
+
if (!sessionIds.has(config)) sessionIds.set(config, crypto.randomUUID());
|
|
46
|
+
return {
|
|
47
|
+
accessToken: oauth.accessToken,
|
|
48
|
+
expiresAt: Number(oauth.expiresAt) || null, // ms since epoch; the token lives 8 hours
|
|
49
|
+
userId: JSON.stringify({ device_id: metadata.userID || '', account_uuid: metadata.oauthAccount?.accountUuid || '', session_id: sessionIds.get(config) }),
|
|
50
|
+
version: clientVersion(config, metadata),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function claudeCodeHeaders(login) {
|
|
55
|
+
return {
|
|
56
|
+
'anthropic-beta': CLAUDE_CODE_BETAS,
|
|
57
|
+
'user-agent': `claude-cli/${login.version} (external, cli)`,
|
|
58
|
+
'x-app': 'cli',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// The local Codex (ChatGPT) subscription login as a provider: Anthropic Messages requests from the SDK are
|
|
2
|
+
// translated to OpenAI Responses requests for the Codex backend, and the Responses stream back to Anthropic SSE,
|
|
3
|
+
// so that the rest of the program (api.js, the agent loop) works unchanged. Hooked in as the SDK's `fetch`.
|
|
4
|
+
// The login is the one `codex login` wrote to $CODEX_HOME/auth.json; its access token is renewed with the
|
|
5
|
+
// refresh token stored there, and the renewed tokens are written back for the Codex CLI to use too.
|
|
6
|
+
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { buildResponsesRequest, toolNameMap } from './translate-request.js';
|
|
12
|
+
import { ResponsesToAnthropic, parseSSE } from './translate-stream.js';
|
|
13
|
+
|
|
14
|
+
export const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
|
15
|
+
const OAUTH_URL = 'https://auth.openai.com/oauth/token';
|
|
16
|
+
const OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
17
|
+
const REFRESH_MARGIN_MS = 5 * 60_000;
|
|
18
|
+
const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
|
|
19
|
+
const LOWER_EFFORT = { ultra: 'max', max: 'xhigh', xhigh: 'high', high: 'medium', minimal: 'low', none: 'low', low: 'medium' };
|
|
20
|
+
|
|
21
|
+
export const codexHome = (env = process.env) => env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
22
|
+
export const codexAuthFile = (env = process.env) => path.join(codexHome(env), 'auth.json');
|
|
23
|
+
|
|
24
|
+
export function decodeJwt(token) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const expiresSoon = (token) => {
|
|
33
|
+
const exp = decodeJwt(token)?.exp;
|
|
34
|
+
return !exp || exp * 1000 - Date.now() < REFRESH_MARGIN_MS;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function readAuth(file) {
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = fs.readFileSync(file, 'utf8');
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e.code === 'ENOENT') throw Object.assign(new Error(`No Codex login: ${file} does not exist. Run \`codex login\` (ChatGPT plan), then try again.`), { status: 401 });
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw Object.assign(new Error(`Cannot parse ${file}: ${e.message}`), { status: 401 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeAuth(file, data) {
|
|
53
|
+
const body = JSON.stringify(data, null, 2);
|
|
54
|
+
const tmp = `${file}.claude-pro-${process.pid}.tmp`;
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
57
|
+
fs.renameSync(tmp, file);
|
|
58
|
+
} catch {
|
|
59
|
+
try { fs.unlinkSync(tmp); } catch { /* not there */ }
|
|
60
|
+
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const accountIdOf = (tokens) => tokens.account_id
|
|
65
|
+
|| decodeJwt(tokens.id_token)?.['https://api.openai.com/auth']?.chatgpt_account_id
|
|
66
|
+
|| decodeJwt(tokens.access_token)?.['https://api.openai.com/auth']?.chatgpt_account_id
|
|
67
|
+
|| '';
|
|
68
|
+
|
|
69
|
+
// What /status shows about the login (nothing secret).
|
|
70
|
+
export function codexLoginSummary(env = process.env) {
|
|
71
|
+
const file = codexAuthFile(env);
|
|
72
|
+
try {
|
|
73
|
+
const data = readAuth(file);
|
|
74
|
+
const claims = decodeJwt(data.tokens?.id_token)?.['https://api.openai.com/auth'] || {};
|
|
75
|
+
const email = decodeJwt(data.tokens?.id_token)?.email || null;
|
|
76
|
+
if (data.tokens?.access_token) return { ok: true, file, plan: claims.chatgpt_plan_type || null, email };
|
|
77
|
+
if (data.OPENAI_API_KEY) return { ok: true, file, plan: 'api key' };
|
|
78
|
+
return { ok: false, file, error: 'no tokens in it' };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, file, error: e.message };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let refreshing = null;
|
|
85
|
+
|
|
86
|
+
async function refresh(file, stale, fetchImpl) {
|
|
87
|
+
const data = readAuth(file);
|
|
88
|
+
const tokens = data.tokens || {};
|
|
89
|
+
// The Codex CLI or app may have renewed it already.
|
|
90
|
+
if (tokens.access_token && tokens.access_token !== stale && !expiresSoon(tokens.access_token)) return;
|
|
91
|
+
if (!tokens.refresh_token) throw Object.assign(new Error('The Codex login has no refresh token. Run `codex login` again.'), { status: 401 });
|
|
92
|
+
const res = await fetchImpl(OAUTH_URL, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
95
|
+
body: JSON.stringify({ client_id: OAUTH_CLIENT_ID, grant_type: 'refresh_token', refresh_token: tokens.refresh_token, scope: 'openid profile email' }),
|
|
96
|
+
});
|
|
97
|
+
const text = await res.text();
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
let reason = text.slice(0, 200);
|
|
100
|
+
try { const j = JSON.parse(text); reason = j.error_description || j.error?.message || j.error || reason; } catch { /* as it is */ }
|
|
101
|
+
throw Object.assign(new Error(`Renewing the Codex login failed (HTTP ${res.status}: ${reason}). Run \`codex login\` again.`), { status: 401 });
|
|
102
|
+
}
|
|
103
|
+
const j = JSON.parse(text);
|
|
104
|
+
const next = { ...data, tokens: { ...tokens } };
|
|
105
|
+
for (const k of ['access_token', 'id_token', 'refresh_token']) if (j[k]) next.tokens[k] = j[k];
|
|
106
|
+
next.last_refresh = new Date().toISOString();
|
|
107
|
+
writeAuth(file, next);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function codexCredentials(file, { force = false, fetchImpl = globalThis.fetch } = {}) {
|
|
111
|
+
const data = readAuth(file);
|
|
112
|
+
const tokens = data.tokens;
|
|
113
|
+
if (tokens?.access_token && data.auth_mode !== 'apikey') {
|
|
114
|
+
if (force || expiresSoon(tokens.access_token)) {
|
|
115
|
+
refreshing ??= refresh(file, tokens.access_token, fetchImpl).finally(() => { refreshing = null; });
|
|
116
|
+
await refreshing;
|
|
117
|
+
const fresh = readAuth(file).tokens;
|
|
118
|
+
return { mode: 'chatgpt', accessToken: fresh.access_token, accountId: accountIdOf(fresh) };
|
|
119
|
+
}
|
|
120
|
+
return { mode: 'chatgpt', accessToken: tokens.access_token, accountId: accountIdOf(tokens) };
|
|
121
|
+
}
|
|
122
|
+
if (data.OPENAI_API_KEY) return { mode: 'apikey', apiKey: data.OPENAI_API_KEY };
|
|
123
|
+
throw Object.assign(new Error(`No usable credentials in ${file}. Run \`codex login\`.`), { status: 401 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A file's parsed contents, read again only when it changes (the footer asks for the model on every frame).
|
|
127
|
+
const fileCache = new Map();
|
|
128
|
+
function readCached(file, parse, fallback) {
|
|
129
|
+
let st;
|
|
130
|
+
try { st = fs.statSync(file); } catch { return fallback; }
|
|
131
|
+
const hit = fileCache.get(file);
|
|
132
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.value;
|
|
133
|
+
let value;
|
|
134
|
+
try { value = parse(fs.readFileSync(file, 'utf8')); } catch { value = fallback; }
|
|
135
|
+
fileCache.set(file, { mtimeMs: st.mtimeMs, size: st.size, value });
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The models the local Codex install knows (models_cache.json): efforts, context window, base instructions.
|
|
140
|
+
export function codexModels(env = process.env) {
|
|
141
|
+
return readCached(path.join(codexHome(env), 'models_cache.json'), parseModelsCache, { clientVersion: null, models: [] });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseModelsCache(text) {
|
|
145
|
+
{
|
|
146
|
+
const j = JSON.parse(text);
|
|
147
|
+
const list = Array.isArray(j) ? j : j.models || [];
|
|
148
|
+
return {
|
|
149
|
+
clientVersion: /^\d+\.\d+\.\d+$/.test(String(j.client_version)) ? j.client_version : null,
|
|
150
|
+
models: list.filter((m) => m && typeof m.slug === 'string').map((m) => ({
|
|
151
|
+
slug: m.slug,
|
|
152
|
+
hidden: /^hid/i.test(m.visibility || ''),
|
|
153
|
+
efforts: (m.supported_reasoning_levels || []).map((r) => (typeof r === 'string' ? r : r?.effort)).filter(Boolean),
|
|
154
|
+
defaultEffort: m.default_reasoning_level || null,
|
|
155
|
+
contextWindow: m.context_window || null,
|
|
156
|
+
baseInstructions: typeof m.base_instructions === 'string' ? m.base_instructions : null,
|
|
157
|
+
})),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The model and effort set in the Codex config.toml (top-level `model` / `model_reasoning_effort`).
|
|
163
|
+
export function codexConfigDefaults(env = process.env) {
|
|
164
|
+
return readCached(path.join(codexHome(env), 'config.toml'), (text) => {
|
|
165
|
+
const top = text.split(/^\s*\[/m)[0];
|
|
166
|
+
const pick = (key) => new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, 'm').exec(top)?.[1] || null;
|
|
167
|
+
return { model: pick('model'), effort: pick('model_reasoning_effort') };
|
|
168
|
+
}, { model: null, effort: null });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function clampEffort(effort, supported) {
|
|
172
|
+
if (!effort || !supported?.length || supported.includes(effort)) return effort;
|
|
173
|
+
const want = EFFORTS.indexOf(effort);
|
|
174
|
+
return supported.reduce((best, s) => (Math.abs(EFFORTS.indexOf(s) - want) < Math.abs(EFFORTS.indexOf(best) - want) ? s : best), supported[0]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// An Anthropic-shaped error response, which the SDK turns into an APIError with this status.
|
|
178
|
+
function errorResponse(status, type, message, headers = {}) {
|
|
179
|
+
return new Response(JSON.stringify({ type: 'error', error: { type, message } }), { status, headers: { 'content-type': 'application/json', ...headers } });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function upstreamError(status, text, headers) {
|
|
183
|
+
let message = text.slice(0, 500) || `HTTP ${status}`;
|
|
184
|
+
let code = '';
|
|
185
|
+
let resetsIn = null;
|
|
186
|
+
try {
|
|
187
|
+
const j = JSON.parse(text);
|
|
188
|
+
message = j.error?.message || j.detail || j.message || message;
|
|
189
|
+
code = j.error?.type || j.error?.code || '';
|
|
190
|
+
resetsIn = j.error?.resets_in_seconds ?? null;
|
|
191
|
+
} catch { /* not JSON */ }
|
|
192
|
+
const extra = {};
|
|
193
|
+
if (resetsIn != null) extra['retry-after'] = String(resetsIn);
|
|
194
|
+
else if (headers.get('retry-after')) extra['retry-after'] = headers.get('retry-after');
|
|
195
|
+
if (status === 429 || /usage_limit|rate_limit/i.test(code)) return { status: 429, type: 'rate_limit_error', message: `Codex: ${message}`, headers: extra, upstreamMessage: message };
|
|
196
|
+
if (status === 401 || status === 403) return { status, type: 'authentication_error', message: `Codex login refused (${status}): ${message}. Run \`codex login\` again if this persists.`, headers: extra, upstreamMessage: message };
|
|
197
|
+
return { status, type: status >= 500 ? 'api_error' : 'invalid_request_error', message: `Codex: ${message}`, headers: extra, upstreamMessage: message };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The SDK's fetch for the codex provider. `options.env` is the configuration's environment (CODEX_HOME).
|
|
201
|
+
export function createCodexFetch({ env = process.env, url = CODEX_RESPONSES_URL, fetchImpl = globalThis.fetch, sessionId = crypto.randomUUID() } = {}) {
|
|
202
|
+
const sticky = { instructionsMode: 'custom', removedParams: new Set() };
|
|
203
|
+
return async function codexFetch(input, init = {}) {
|
|
204
|
+
const target = String(input?.url ?? input);
|
|
205
|
+
const { pathname } = new URL(target);
|
|
206
|
+
if (!/\/v1\/messages$/.test(pathname)) return errorResponse(404, 'not_found_error', `The codex provider does not support ${pathname}`);
|
|
207
|
+
let anth;
|
|
208
|
+
try { anth = JSON.parse(typeof init.body === 'string' ? init.body : await new Response(init.body).text()); } catch { return errorResponse(400, 'invalid_request_error', 'Request body is not valid JSON'); }
|
|
209
|
+
const file = codexAuthFile(env);
|
|
210
|
+
const known = codexModels(env);
|
|
211
|
+
const model = String(anth.model || '').replace(/\[1m\]$/i, '');
|
|
212
|
+
const info = known.models.find((m) => m.slug === model);
|
|
213
|
+
// No effort sent ("auto", and side queries such as titles): the model's own default, as with Claude.
|
|
214
|
+
const wanted = anth.output_config?.effort || info?.defaultEffort || null;
|
|
215
|
+
const o = {
|
|
216
|
+
model,
|
|
217
|
+
effort: clampEffort(wanted, info?.efforts),
|
|
218
|
+
summary: 'auto',
|
|
219
|
+
sessionId,
|
|
220
|
+
instructionsMode: sticky.instructionsMode,
|
|
221
|
+
codexInstructions: sticky.instructionsMode === 'codex' ? info?.baseInstructions : null,
|
|
222
|
+
removedParams: new Set(sticky.removedParams),
|
|
223
|
+
dropReasoning: false,
|
|
224
|
+
webSearch: false,
|
|
225
|
+
parallelToolCalls: true,
|
|
226
|
+
};
|
|
227
|
+
const triedEfforts = new Set([o.effort]);
|
|
228
|
+
let refreshed = false; // a 401 renews the token once
|
|
229
|
+
let force = false;
|
|
230
|
+
let res;
|
|
231
|
+
for (let attempt = 0; ; attempt++) {
|
|
232
|
+
let creds;
|
|
233
|
+
try { creds = await codexCredentials(file, { force, fetchImpl }); } catch (e) { return errorResponse(e.status || 401, 'authentication_error', e.message); }
|
|
234
|
+
force = false;
|
|
235
|
+
const headers = {
|
|
236
|
+
'content-type': 'application/json',
|
|
237
|
+
accept: 'text/event-stream',
|
|
238
|
+
'openai-beta': 'responses=experimental',
|
|
239
|
+
originator: 'codex_cli_rs',
|
|
240
|
+
version: known.clientVersion || '0.162.0',
|
|
241
|
+
'user-agent': `codex_cli_rs/${known.clientVersion || '0.162.0'} (${os.type()} ${os.release()}; ${os.arch()}) claude-pro`,
|
|
242
|
+
session_id: sessionId,
|
|
243
|
+
authorization: `Bearer ${creds.mode === 'chatgpt' ? creds.accessToken : creds.apiKey}`,
|
|
244
|
+
};
|
|
245
|
+
if (creds.accountId) headers['chatgpt-account-id'] = creds.accountId;
|
|
246
|
+
const payload = buildResponsesRequest(anth, o);
|
|
247
|
+
res = await fetchImpl(creds.mode === 'chatgpt' ? url : 'https://api.openai.com/v1/responses', { method: 'POST', headers, body: JSON.stringify(payload), signal: init.signal });
|
|
248
|
+
if (res.ok) break;
|
|
249
|
+
const text = await res.text();
|
|
250
|
+
const err = upstreamError(res.status, text, res.headers);
|
|
251
|
+
if (attempt >= 5) return errorResponse(err.status, err.type, err.message, err.headers);
|
|
252
|
+
if (res.status === 401 && !refreshed) { refreshed = force = true; continue; }
|
|
253
|
+
if (res.status === 400) {
|
|
254
|
+
const m = err.upstreamMessage;
|
|
255
|
+
if (/instruction/i.test(m) && o.instructionsMode !== 'codex' && info?.baseInstructions) {
|
|
256
|
+
o.instructionsMode = sticky.instructionsMode = 'codex';
|
|
257
|
+
o.codexInstructions = info.baseInstructions;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (/encrypted|reasoning item|could not be (verified|decrypted)/i.test(m) && !o.dropReasoning) { o.dropReasoning = true; continue; }
|
|
261
|
+
const param = m.match(/unsupported parameter:?\s*['"`]?([\w.]+)/i)?.[1];
|
|
262
|
+
if (param && !o.removedParams.has(param) && !['model', 'input', 'stream'].includes(param)) {
|
|
263
|
+
o.removedParams.add(param);
|
|
264
|
+
sticky.removedParams.add(param);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const lower = LOWER_EFFORT[o.effort];
|
|
268
|
+
if (/effort/i.test(m) && lower && !triedEfforts.has(lower)) { o.effort = lower; triedEfforts.add(lower); continue; }
|
|
269
|
+
}
|
|
270
|
+
return errorResponse(err.status, err.type, err.message, err.headers);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const toolNames = toolNameMap(anth);
|
|
274
|
+
const encoder = new TextEncoder();
|
|
275
|
+
const body = new ReadableStream({
|
|
276
|
+
async start(controller) {
|
|
277
|
+
const conv = new ResponsesToAnthropic({
|
|
278
|
+
model: anth.model,
|
|
279
|
+
toolNames,
|
|
280
|
+
emitThinking: true,
|
|
281
|
+
onEvent: (name, data) => controller.enqueue(encoder.encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)),
|
|
282
|
+
});
|
|
283
|
+
conv.start();
|
|
284
|
+
try {
|
|
285
|
+
for await (const ev of parseSSE(res.body)) {
|
|
286
|
+
conv.handle(ev);
|
|
287
|
+
if (conv.done) break;
|
|
288
|
+
}
|
|
289
|
+
conv.end();
|
|
290
|
+
} catch (e) {
|
|
291
|
+
if (init.signal?.aborted) { controller.error(e); return; }
|
|
292
|
+
conv.fail(`Codex stream error: ${e.message}`);
|
|
293
|
+
}
|
|
294
|
+
controller.close();
|
|
295
|
+
},
|
|
296
|
+
cancel() { res.body?.cancel?.().catch(() => {}); },
|
|
297
|
+
});
|
|
298
|
+
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream; charset=utf-8' } });
|
|
299
|
+
};
|
|
300
|
+
}
|