@kevin0181/rcodex 0.0.1
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/LICENSE +21 -0
- package/README.md +160 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +114 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/gateway-daemon.d.ts +2 -0
- package/dist/commands/gateway-daemon.d.ts.map +1 -0
- package/dist/commands/gateway-daemon.js +22 -0
- package/dist/commands/gateway-daemon.js.map +1 -0
- package/dist/commands/launch.d.ts +2 -0
- package/dist/commands/launch.d.ts.map +1 -0
- package/dist/commands/launch.js +129 -0
- package/dist/commands/launch.js.map +1 -0
- package/dist/commands/migrate.d.ts +4 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +137 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +78 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/stop.d.ts +2 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +20 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/switch.d.ts +4 -0
- package/dist/commands/switch.d.ts.map +1 -0
- package/dist/commands/switch.js +78 -0
- package/dist/commands/switch.js.map +1 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +107 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/core/codex.d.ts +6 -0
- package/dist/core/codex.d.ts.map +1 -0
- package/dist/core/codex.js +123 -0
- package/dist/core/codex.js.map +1 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +68 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/constants.d.ts +4 -0
- package/dist/core/constants.d.ts.map +1 -0
- package/dist/core/constants.js +7 -0
- package/dist/core/constants.js.map +1 -0
- package/dist/core/ollama.d.ts +3 -0
- package/dist/core/ollama.d.ts.map +1 -0
- package/dist/core/ollama.js +20 -0
- package/dist/core/ollama.js.map +1 -0
- package/dist/gateway/auth.d.ts +58 -0
- package/dist/gateway/auth.d.ts.map +1 -0
- package/dist/gateway/auth.js +248 -0
- package/dist/gateway/auth.js.map +1 -0
- package/dist/gateway/providers/anthropic.d.ts +15 -0
- package/dist/gateway/providers/anthropic.d.ts.map +1 -0
- package/dist/gateway/providers/anthropic.js +122 -0
- package/dist/gateway/providers/anthropic.js.map +1 -0
- package/dist/gateway/providers/antigravity-oauth-flow.d.ts +21 -0
- package/dist/gateway/providers/antigravity-oauth-flow.d.ts.map +1 -0
- package/dist/gateway/providers/antigravity-oauth-flow.js +231 -0
- package/dist/gateway/providers/antigravity-oauth-flow.js.map +1 -0
- package/dist/gateway/providers/antigravity.d.ts +5 -0
- package/dist/gateway/providers/antigravity.d.ts.map +1 -0
- package/dist/gateway/providers/antigravity.js +111 -0
- package/dist/gateway/providers/antigravity.js.map +1 -0
- package/dist/gateway/providers/claude-oauth-flow.d.ts +16 -0
- package/dist/gateway/providers/claude-oauth-flow.d.ts.map +1 -0
- package/dist/gateway/providers/claude-oauth-flow.js +178 -0
- package/dist/gateway/providers/claude-oauth-flow.js.map +1 -0
- package/dist/gateway/providers/copilot.d.ts +19 -0
- package/dist/gateway/providers/copilot.d.ts.map +1 -0
- package/dist/gateway/providers/copilot.js +141 -0
- package/dist/gateway/providers/copilot.js.map +1 -0
- package/dist/gateway/providers/google.d.ts +12 -0
- package/dist/gateway/providers/google.d.ts.map +1 -0
- package/dist/gateway/providers/google.js +58 -0
- package/dist/gateway/providers/google.js.map +1 -0
- package/dist/gateway/providers/ollama.d.ts +6 -0
- package/dist/gateway/providers/ollama.d.ts.map +1 -0
- package/dist/gateway/providers/ollama.js +54 -0
- package/dist/gateway/providers/ollama.js.map +1 -0
- package/dist/gateway/providers/openai-oauth-flow.d.ts +15 -0
- package/dist/gateway/providers/openai-oauth-flow.d.ts.map +1 -0
- package/dist/gateway/providers/openai-oauth-flow.js +149 -0
- package/dist/gateway/providers/openai-oauth-flow.js.map +1 -0
- package/dist/gateway/providers/openai.d.ts +8 -0
- package/dist/gateway/providers/openai.d.ts.map +1 -0
- package/dist/gateway/providers/openai.js +193 -0
- package/dist/gateway/providers/openai.js.map +1 -0
- package/dist/gateway/proxy.d.ts +119 -0
- package/dist/gateway/proxy.d.ts.map +1 -0
- package/dist/gateway/proxy.js +1949 -0
- package/dist/gateway/proxy.js.map +1 -0
- package/dist/gateway/server.d.ts +6 -0
- package/dist/gateway/server.d.ts.map +1 -0
- package/dist/gateway/server.js +890 -0
- package/dist/gateway/server.js.map +1 -0
- package/dist/gateway/ui.d.ts +2 -0
- package/dist/gateway/ui.d.ts.map +1 -0
- package/dist/gateway/ui.js +1748 -0
- package/dist/gateway/ui.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +78 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +26 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +25 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/paths.d.ts +4 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +22 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/shell.d.ts +8 -0
- package/dist/utils/shell.d.ts.map +1 -0
- package/dist/utils/shell.js +27 -0
- package/dist/utils/shell.js.map +1 -0
- package/dist/utils/updates.d.ts +2 -0
- package/dist/utils/updates.d.ts.map +1 -0
- package/dist/utils/updates.js +83 -0
- package/dist/utils/updates.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import cors from "@fastify/cors";
|
|
3
|
+
import { createServer } from "net";
|
|
4
|
+
import { exec } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import { readFileSync, writeFileSync, accessSync, constants } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { getCodexConfigPath } from "../utils/paths.js";
|
|
10
|
+
import { readCodexConfig, writeCodexConfig } from "../core/config.js";
|
|
11
|
+
import { MANAGED_PROVIDER_KEY, OPENAI_PROVIDER_KEY } from "../core/constants.js";
|
|
12
|
+
import { isCodexRunning, killCodex, openCodexApp } from "../core/codex.js";
|
|
13
|
+
import { migrateThreads } from "../commands/migrate.js";
|
|
14
|
+
import { loadConfig, saveConfig, addAccount, removeAccount, setAccountNodeState, reorderConnectedAccounts, addModelSlot, removeModelSlot, reorderAllSlots, updateAccountProjectId, saveGatewayPid, clearGatewayPid, } from "./auth.js";
|
|
15
|
+
import { buildAuthUrl, waitForCallback, exchangeCode } from "./providers/openai-oauth-flow.js";
|
|
16
|
+
import { buildClaudeAuthUrl, waitForClaudeCallback, exchangeClaudeCode } from "./providers/claude-oauth-flow.js";
|
|
17
|
+
import { buildAntigravityAuthUrl, waitForAntigravityCallback, exchangeAntigravityCode, loadAntigravityProject } from "./providers/antigravity-oauth-flow.js";
|
|
18
|
+
import { ANTIGRAVITY_DEFAULT_MODELS, getAntigravityModels } from "./providers/antigravity.js";
|
|
19
|
+
import { getCopilotModels, startCopilotDeviceAuth, pollCopilotDeviceToken, getCopilotToken } from "./providers/copilot.js";
|
|
20
|
+
import { proxyRequest, streamProxyRequest, tryCodexPassthrough, requestLog, pushLog, flushLog, ensureFreshToken, glog, gerr } from "./proxy.js";
|
|
21
|
+
import { getAnthropicModels } from "./providers/anthropic.js";
|
|
22
|
+
import { getOpenAIModels } from "./providers/openai.js";
|
|
23
|
+
import { getGoogleModels } from "./providers/google.js";
|
|
24
|
+
import { getOllamaModels } from "./providers/ollama.js";
|
|
25
|
+
import { accountToProviderAuth } from "./auth.js";
|
|
26
|
+
import { getHTML } from "./ui.js";
|
|
27
|
+
const execAsync = promisify(exec);
|
|
28
|
+
const startTime = Date.now();
|
|
29
|
+
function findFreePort(startPort, maxAttempts = 10) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const tryPort = (port, attempt) => {
|
|
32
|
+
if (attempt >= maxAttempts) {
|
|
33
|
+
reject(new Error(`No free port found starting from ${startPort}`));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const server = createServer();
|
|
37
|
+
server.listen(port, "127.0.0.1", () => { server.close(() => resolve(port)); });
|
|
38
|
+
server.on("error", () => tryPort(port + 1, attempt + 1));
|
|
39
|
+
};
|
|
40
|
+
tryPort(startPort, 0);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async function loadModelsForAccount(account, ollamaBaseUrl) {
|
|
44
|
+
const auth = accountToProviderAuth(account);
|
|
45
|
+
try {
|
|
46
|
+
switch (account.provider) {
|
|
47
|
+
case "anthropic": return await getAnthropicModels(auth);
|
|
48
|
+
case "openai": return await getOpenAIModels(auth);
|
|
49
|
+
case "google": return await getGoogleModels(auth);
|
|
50
|
+
case "ollama": return await getOllamaModels(ollamaBaseUrl);
|
|
51
|
+
case "antigravity": return await getAntigravityModels(auth.oauthToken ?? "", account.projectId);
|
|
52
|
+
case "copilot": return await getCopilotModels(auth.oauthToken ?? "");
|
|
53
|
+
default: return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function createGatewayServer() {
|
|
61
|
+
const fastify = Fastify({ logger: false });
|
|
62
|
+
fastify.register(cors, { origin: "*" });
|
|
63
|
+
let pendingOAuth = null;
|
|
64
|
+
let oauthError = null;
|
|
65
|
+
// Quota cache ??keyed by account.id, TTL 90s to avoid rate-limiting upstream APIs
|
|
66
|
+
const QUOTA_TTL = 90_000;
|
|
67
|
+
const quotaCache = new Map();
|
|
68
|
+
// ?�?� Web UI ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
69
|
+
fastify.get("/", async (_req, reply) => {
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
reply.header("content-type", "text/html; charset=utf-8");
|
|
72
|
+
return reply.send(getHTML(config.port));
|
|
73
|
+
});
|
|
74
|
+
// ?�?� Health check + status ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
75
|
+
fastify.get("/api/status", async () => {
|
|
76
|
+
const config = loadConfig();
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
port: config.port,
|
|
80
|
+
pid: process.pid,
|
|
81
|
+
uptimeMs: Date.now() - startTime,
|
|
82
|
+
home: homedir(),
|
|
83
|
+
accountCount: config.accounts.length,
|
|
84
|
+
connectedCount: config.accounts.filter(a => a.connectedToOut).length,
|
|
85
|
+
connectedProviders: config.accounts.filter(a => a.connectedToOut)
|
|
86
|
+
.sort((a, b) => (a.connectedOrder ?? 999) - (b.connectedOrder ?? 999))
|
|
87
|
+
.map(a => ({ label: a.label, provider: a.provider, model: a.selectedModel || "(auto)" })),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
// ?�?� Gateway log tail ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
91
|
+
fastify.get("/api/logs", async (req) => {
|
|
92
|
+
const n = Math.min(parseInt(req.query.n ?? "120"), 500);
|
|
93
|
+
const logPath = join(homedir(), ".rcodex", "gateway.log");
|
|
94
|
+
try {
|
|
95
|
+
const content = readFileSync(logPath, "utf-8");
|
|
96
|
+
const lines = content.split("\n").filter(Boolean);
|
|
97
|
+
return { lines: lines.slice(-n) };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { lines: [] };
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
// ?�?� Request history ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
104
|
+
fastify.get("/api/requests", async () => ({ requests: [...requestLog].reverse().slice(0, 100) }));
|
|
105
|
+
fastify.delete("/api/requests", async () => { requestLog.splice(0); return { ok: true }; });
|
|
106
|
+
fastify.delete("/api/logs", async () => {
|
|
107
|
+
const logPath = join(homedir(), ".rcodex", "gateway.log");
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(logPath, "", "utf-8");
|
|
110
|
+
}
|
|
111
|
+
catch { /* ignore */ }
|
|
112
|
+
return { ok: true };
|
|
113
|
+
});
|
|
114
|
+
// ?�?� Provider quota (Claude Code & Codex OAuth) ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
115
|
+
fastify.get("/api/quota", async (req) => {
|
|
116
|
+
const cfg = loadConfig();
|
|
117
|
+
const bustId = req.query.bust;
|
|
118
|
+
if (bustId)
|
|
119
|
+
quotaCache.delete(bustId);
|
|
120
|
+
const results = [];
|
|
121
|
+
for (const rawAccount of cfg.accounts) {
|
|
122
|
+
if (rawAccount.method !== "oauth-official" || !rawAccount.oauthToken)
|
|
123
|
+
continue;
|
|
124
|
+
// Return cached entry if still fresh
|
|
125
|
+
const cached = quotaCache.get(rawAccount.id);
|
|
126
|
+
if (cached && Date.now() - cached.ts < QUOTA_TTL) {
|
|
127
|
+
results.push(cached.entry);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const account = await ensureFreshToken(rawAccount);
|
|
131
|
+
if (account.provider === "anthropic") {
|
|
132
|
+
let entry;
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
135
|
+
headers: {
|
|
136
|
+
"authorization": `Bearer ${account.oauthToken}`,
|
|
137
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
138
|
+
"anthropic-version": "2023-06-01",
|
|
139
|
+
},
|
|
140
|
+
signal: AbortSignal.timeout(10_000),
|
|
141
|
+
});
|
|
142
|
+
if (res.ok) {
|
|
143
|
+
const d = await res.json();
|
|
144
|
+
const fh = d.five_hour;
|
|
145
|
+
const sd = d.seven_day;
|
|
146
|
+
entry = {
|
|
147
|
+
provider: "anthropic", label: account.label, id: account.id,
|
|
148
|
+
five_hour: fh ? { utilization: fh.utilization ?? 0, resets_at: fh.resets_at ?? null } : undefined,
|
|
149
|
+
seven_day: sd ? { utilization: sd.utilization ?? 0, resets_at: sd.resets_at ?? null } : undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
entry = { provider: "anthropic", label: account.label, id: account.id, error: `${res.status}` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
entry = { provider: "anthropic", label: account.label, id: account.id, error: String(e) };
|
|
158
|
+
}
|
|
159
|
+
// Only cache successful responses (not errors)
|
|
160
|
+
if (!entry.error)
|
|
161
|
+
quotaCache.set(account.id, { entry, ts: Date.now() });
|
|
162
|
+
results.push(entry);
|
|
163
|
+
}
|
|
164
|
+
if (account.provider === "openai") {
|
|
165
|
+
let entry;
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
168
|
+
headers: { "authorization": `Bearer ${account.oauthToken}`, "accept": "application/json" },
|
|
169
|
+
signal: AbortSignal.timeout(10_000),
|
|
170
|
+
});
|
|
171
|
+
if (res.ok) {
|
|
172
|
+
const d = await res.json();
|
|
173
|
+
const rl = (d.rate_limit ?? d.rate_limits ?? d.rate_limits_by_limit_id?.codex ?? {});
|
|
174
|
+
const body = (rl.rate_limit && typeof rl.rate_limit === "object" ? rl.rate_limit : rl);
|
|
175
|
+
const primary = (body.primary_window ?? body.primary);
|
|
176
|
+
const secondary = (body.secondary_window ?? body.secondary);
|
|
177
|
+
entry = {
|
|
178
|
+
provider: "openai", label: account.label, id: account.id,
|
|
179
|
+
primary: primary ? { used: Math.min(100, primary.used_percent ?? primary.percent_used ?? 0), resets_at: primary.reset_at ?? primary.resets_at ?? null } : undefined,
|
|
180
|
+
secondary: secondary ? { used: Math.min(100, secondary.used_percent ?? secondary.percent_used ?? 0), resets_at: secondary.reset_at ?? secondary.resets_at ?? null } : undefined,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
entry = { provider: "openai", label: account.label, id: account.id, error: `${res.status}` };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
entry = { provider: "openai", label: account.label, id: account.id, error: String(e) };
|
|
189
|
+
}
|
|
190
|
+
if (!entry.error)
|
|
191
|
+
quotaCache.set(account.id, { entry, ts: Date.now() });
|
|
192
|
+
results.push(entry);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { quota: results };
|
|
196
|
+
});
|
|
197
|
+
// ?�?� Codex provider mode ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
198
|
+
fastify.get("/api/codex-provider", async () => {
|
|
199
|
+
const configPath = getCodexConfigPath();
|
|
200
|
+
const config = readCodexConfig(configPath);
|
|
201
|
+
const active = config.model_provider ?? MANAGED_PROVIDER_KEY;
|
|
202
|
+
return { mode: active === MANAGED_PROVIDER_KEY ? "rcodex" : "openai", activeProvider: active };
|
|
203
|
+
});
|
|
204
|
+
fastify.post("/api/codex-provider", async (req, reply) => {
|
|
205
|
+
const { mode } = req.body;
|
|
206
|
+
if (mode !== "rcodex" && mode !== "openai")
|
|
207
|
+
return reply.status(400).send({ error: "invalid mode" });
|
|
208
|
+
const targetProvider = mode === "rcodex" ? MANAGED_PROVIDER_KEY : OPENAI_PROVIDER_KEY;
|
|
209
|
+
// 1. Stop Codex (required before migration)
|
|
210
|
+
if (await isCodexRunning()) {
|
|
211
|
+
await killCodex();
|
|
212
|
+
await new Promise(r => setTimeout(r, 800));
|
|
213
|
+
}
|
|
214
|
+
// 2. Update Codex config
|
|
215
|
+
const configPath = getCodexConfigPath();
|
|
216
|
+
const config = readCodexConfig(configPath);
|
|
217
|
+
const gatewayPort = loadConfig().port;
|
|
218
|
+
if (mode === "rcodex") {
|
|
219
|
+
// Restore rcodex provider entry and activate it
|
|
220
|
+
if (!config.model_providers)
|
|
221
|
+
config.model_providers = {};
|
|
222
|
+
config.model_providers[MANAGED_PROVIDER_KEY] = {
|
|
223
|
+
name: "rcodex Gateway",
|
|
224
|
+
base_url: `http://localhost:${gatewayPort}/v1`,
|
|
225
|
+
wire_api: "responses",
|
|
226
|
+
};
|
|
227
|
+
config.model_provider = MANAGED_PROVIDER_KEY;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// Revert to Codex built-in OpenAI: remove model_provider key and rcodex entry
|
|
231
|
+
// so Codex falls back to its default (no label shown)
|
|
232
|
+
delete config.model_provider;
|
|
233
|
+
if (config.model_providers) {
|
|
234
|
+
delete config.model_providers[MANAGED_PROVIDER_KEY];
|
|
235
|
+
if (Object.keys(config.model_providers).length === 0) {
|
|
236
|
+
delete config.model_providers;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
writeCodexConfig(configPath, config);
|
|
241
|
+
// 3. Migrate threads to target provider
|
|
242
|
+
await migrateThreads(targetProvider, false);
|
|
243
|
+
// 4. Restart Codex
|
|
244
|
+
openCodexApp().catch(() => { });
|
|
245
|
+
return { ok: true, mode };
|
|
246
|
+
});
|
|
247
|
+
// ?�?� Terminal exec ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
248
|
+
fastify.post("/api/terminal/exec", async (req, reply) => {
|
|
249
|
+
const { cmd, cwd } = req.body;
|
|
250
|
+
if (!cmd?.trim())
|
|
251
|
+
return reply.code(400).send("cmd required");
|
|
252
|
+
const workDir = cwd ?? homedir();
|
|
253
|
+
const trimmed = cmd.trim();
|
|
254
|
+
if (/^cd(\s|$)/.test(trimmed)) {
|
|
255
|
+
const raw = trimmed.replace(/^cd\s*/, "").trim() || "~";
|
|
256
|
+
const dir = raw === "~" ? homedir()
|
|
257
|
+
: raw.startsWith("~/") ? join(homedir(), raw.slice(2))
|
|
258
|
+
: raw.startsWith("/") ? raw
|
|
259
|
+
: join(workDir, raw);
|
|
260
|
+
try {
|
|
261
|
+
accessSync(dir, constants.R_OK);
|
|
262
|
+
return { stdout: "", stderr: "", cwd: dir };
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return { stdout: "", stderr: `cd: no such file or directory: ${raw}`, cwd: workDir };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const { stdout, stderr } = await execAsync(trimmed, {
|
|
270
|
+
cwd: workDir, timeout: 15_000, maxBuffer: 512 * 1024,
|
|
271
|
+
env: { ...process.env, TERM: "dumb" },
|
|
272
|
+
windowsHide: true,
|
|
273
|
+
});
|
|
274
|
+
return { stdout: stdout.slice(0, 8192), stderr: stderr.slice(0, 8192), cwd: workDir };
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
const e = err;
|
|
278
|
+
return { stdout: (e.stdout ?? "").slice(0, 8192), stderr: (e.stderr ?? e.message ?? "error").slice(0, 8192), cwd: workDir };
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
// ?�?� Accounts list + status ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
282
|
+
fastify.get("/api/accounts", async () => {
|
|
283
|
+
const config = loadConfig();
|
|
284
|
+
const ollamaModels = await getOllamaModels(config.ollamaBaseUrl);
|
|
285
|
+
const accountsWithModels = await Promise.all(config.accounts.map(async (account) => ({
|
|
286
|
+
...account,
|
|
287
|
+
models: await loadModelsForAccount(account, config.ollamaBaseUrl),
|
|
288
|
+
})));
|
|
289
|
+
return {
|
|
290
|
+
port: config.port,
|
|
291
|
+
accounts: accountsWithModels,
|
|
292
|
+
ollamaRunning: ollamaModels.length > 0,
|
|
293
|
+
ollamaModels,
|
|
294
|
+
ollamaBaseUrl: config.ollamaBaseUrl,
|
|
295
|
+
oauthPending: pendingOAuth,
|
|
296
|
+
oauthError,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
// ?�?� Add account ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
300
|
+
fastify.post("/api/accounts", async (req, reply) => {
|
|
301
|
+
const { provider, label, method, apiKey, sessionToken } = req.body;
|
|
302
|
+
if (!provider || !label || !method)
|
|
303
|
+
return reply.code(400).send("provider, label, method required");
|
|
304
|
+
const account = addAccount(provider, label, method, { apiKey, sessionToken });
|
|
305
|
+
glog(`[account] ??added: ${label} (${provider}, ${method})`);
|
|
306
|
+
return account;
|
|
307
|
+
});
|
|
308
|
+
// ?�?� Remove account ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
309
|
+
fastify.delete("/api/accounts/:id", async (req, reply) => {
|
|
310
|
+
const { id } = req.params;
|
|
311
|
+
const config = loadConfig();
|
|
312
|
+
const account = config.accounts.find(a => a.id === id);
|
|
313
|
+
if (!account)
|
|
314
|
+
return reply.code(404).send("Account not found");
|
|
315
|
+
removeAccount(id);
|
|
316
|
+
glog(`[account] ??removed: ${account.label} (${account.provider})`);
|
|
317
|
+
return { ok: true };
|
|
318
|
+
});
|
|
319
|
+
// ?�?� Account node state (canvas connection) ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
320
|
+
fastify.post("/api/accounts/:id/node-state", async (req, reply) => {
|
|
321
|
+
const { id } = req.params;
|
|
322
|
+
const { connectedToOut, selectedModel, connectedOrder } = req.body;
|
|
323
|
+
const config = loadConfig();
|
|
324
|
+
const account = config.accounts.find(a => a.id === id);
|
|
325
|
+
if (!account)
|
|
326
|
+
return reply.code(404).send("Account not found");
|
|
327
|
+
setAccountNodeState(id, selectedModel ?? null, connectedToOut, connectedOrder);
|
|
328
|
+
glog(`[canvas] ${account.label}: connectedToOut=${connectedToOut}${selectedModel ? ` model=${selectedModel}` : ""}`);
|
|
329
|
+
return { ok: true };
|
|
330
|
+
});
|
|
331
|
+
// ?�?� Add model slot ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
332
|
+
fastify.post("/api/accounts/:id/slots", async (req, reply) => {
|
|
333
|
+
const { id } = req.params;
|
|
334
|
+
const { model, slotId } = req.body;
|
|
335
|
+
if (!model)
|
|
336
|
+
return reply.code(400).send("model required");
|
|
337
|
+
const config = loadConfig();
|
|
338
|
+
const account = config.accounts.find(a => a.id === id);
|
|
339
|
+
if (!account)
|
|
340
|
+
return reply.code(404).send("Account not found");
|
|
341
|
+
const slot = addModelSlot(id, model, slotId);
|
|
342
|
+
glog(`[slot] ??added: ${account.label} ??${model}`);
|
|
343
|
+
return slot ?? reply.code(500).send("Failed to create slot");
|
|
344
|
+
});
|
|
345
|
+
// ?�?� Remove model slot ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
346
|
+
fastify.delete("/api/accounts/:id/slots/:slotId", async (req, reply) => {
|
|
347
|
+
const { id, slotId } = req.params;
|
|
348
|
+
const config = loadConfig();
|
|
349
|
+
const account = config.accounts.find(a => a.id === id);
|
|
350
|
+
if (!account)
|
|
351
|
+
return reply.code(404).send("Account not found");
|
|
352
|
+
const slot = account.activeModels?.find(s => s.slotId === slotId);
|
|
353
|
+
removeModelSlot(id, slotId);
|
|
354
|
+
glog(`[slot] ??removed: ${account.label} ??${slot?.model ?? slotId}`);
|
|
355
|
+
return { ok: true };
|
|
356
|
+
});
|
|
357
|
+
// ?�?� Reorder all slots (cross-account) ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
358
|
+
fastify.post("/api/slots/reorder", async (req, reply) => {
|
|
359
|
+
const { items } = req.body;
|
|
360
|
+
if (!Array.isArray(items))
|
|
361
|
+
return reply.code(400).send("items array required");
|
|
362
|
+
reorderAllSlots(items);
|
|
363
|
+
return { ok: true };
|
|
364
|
+
});
|
|
365
|
+
// ?�?� Reorder connected accounts (legacy) ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
366
|
+
fastify.post("/api/accounts/reorder", async (req, reply) => {
|
|
367
|
+
const { ids } = req.body;
|
|
368
|
+
if (!Array.isArray(ids))
|
|
369
|
+
return reply.code(400).send("ids array required");
|
|
370
|
+
reorderConnectedAccounts(ids);
|
|
371
|
+
return { ok: true };
|
|
372
|
+
});
|
|
373
|
+
// ?�?� Ollama base URL update ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
374
|
+
fastify.post("/api/ollama/config", async (req, reply) => {
|
|
375
|
+
const { baseUrl } = req.body;
|
|
376
|
+
if (!baseUrl?.trim())
|
|
377
|
+
return reply.code(400).send("baseUrl required");
|
|
378
|
+
const config = loadConfig();
|
|
379
|
+
config.ollamaBaseUrl = baseUrl.trim();
|
|
380
|
+
saveConfig(config);
|
|
381
|
+
return { ok: true };
|
|
382
|
+
});
|
|
383
|
+
// ?�?� Claude Code OAuth (PKCE) ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
384
|
+
fastify.post("/api/oauth/anthropic/start", async (req, reply) => {
|
|
385
|
+
const { url, state, verifier } = buildClaudeAuthUrl();
|
|
386
|
+
const label = req.body?.label || "Claude Code";
|
|
387
|
+
oauthError = null;
|
|
388
|
+
pendingOAuth = "anthropic";
|
|
389
|
+
glog(`[oauth] anthropic: login started (label="${label}")`);
|
|
390
|
+
waitForClaudeCallback(state)
|
|
391
|
+
.then(code => { glog("[oauth] anthropic: callback received, exchanging token..."); return exchangeClaudeCode(code, verifier, state); })
|
|
392
|
+
.then(tokens => {
|
|
393
|
+
pendingOAuth = null;
|
|
394
|
+
const expiry = Date.now() + tokens.expiresIn * 1000;
|
|
395
|
+
addAccount("anthropic", label, "oauth-official", {
|
|
396
|
+
apiKey: tokens.apiKey,
|
|
397
|
+
oauthToken: tokens.accessToken,
|
|
398
|
+
oauthRefresh: tokens.refreshToken,
|
|
399
|
+
oauthExpiry: expiry,
|
|
400
|
+
});
|
|
401
|
+
glog(`[oauth] anthropic: ??account added (label="${label}", apiKey=${tokens.apiKey ? "yes" : "no"})`);
|
|
402
|
+
})
|
|
403
|
+
.catch((err) => {
|
|
404
|
+
pendingOAuth = null;
|
|
405
|
+
oauthError = err.message;
|
|
406
|
+
gerr(`[oauth] anthropic: ??failed ??${err.message}`);
|
|
407
|
+
});
|
|
408
|
+
return reply.send({ authUrl: url });
|
|
409
|
+
});
|
|
410
|
+
// ?�?� ChatGPT OAuth (PKCE) ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
411
|
+
fastify.post("/api/oauth/openai/start", async (req, reply) => {
|
|
412
|
+
const { url, state, verifier } = buildAuthUrl();
|
|
413
|
+
const label = req.body?.label || "ChatGPT / Codex";
|
|
414
|
+
oauthError = null;
|
|
415
|
+
pendingOAuth = "openai";
|
|
416
|
+
glog(`[oauth] openai: login started (label="${label}")`);
|
|
417
|
+
waitForCallback(state)
|
|
418
|
+
.then(code => { glog("[oauth] openai: callback received, exchanging token..."); return exchangeCode(code, verifier); })
|
|
419
|
+
.then(tokens => {
|
|
420
|
+
pendingOAuth = null;
|
|
421
|
+
const expiry = Date.now() + tokens.expiresIn * 1000;
|
|
422
|
+
addAccount("openai", label, "oauth-official", {
|
|
423
|
+
oauthToken: tokens.accessToken,
|
|
424
|
+
oauthRefresh: tokens.refreshToken,
|
|
425
|
+
oauthExpiry: expiry,
|
|
426
|
+
});
|
|
427
|
+
glog(`[oauth] openai: ??account added (label="${label}")`);
|
|
428
|
+
})
|
|
429
|
+
.catch((err) => {
|
|
430
|
+
pendingOAuth = null;
|
|
431
|
+
oauthError = err.message;
|
|
432
|
+
gerr(`[oauth] openai: ??failed ??${err.message}`);
|
|
433
|
+
});
|
|
434
|
+
return reply.send({ authUrl: url });
|
|
435
|
+
});
|
|
436
|
+
// ?�?� Antigravity OAuth ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
437
|
+
fastify.post("/api/oauth/antigravity/start", async (req, reply) => {
|
|
438
|
+
const { url, state } = buildAntigravityAuthUrl();
|
|
439
|
+
const label = req.body?.label || "Antigravity";
|
|
440
|
+
oauthError = null;
|
|
441
|
+
pendingOAuth = "antigravity";
|
|
442
|
+
glog(`[oauth] antigravity: login started (label="${label}")`);
|
|
443
|
+
waitForAntigravityCallback(state)
|
|
444
|
+
.then(code => { glog("[oauth] antigravity: callback received, exchanging token..."); return exchangeAntigravityCode(code); })
|
|
445
|
+
.then(async (tokens) => {
|
|
446
|
+
const expiry = Date.now() + tokens.expiresIn * 1000;
|
|
447
|
+
const account = addAccount("antigravity", label, "oauth-official", {
|
|
448
|
+
oauthToken: tokens.accessToken,
|
|
449
|
+
oauthRefresh: tokens.refreshToken,
|
|
450
|
+
oauthExpiry: expiry,
|
|
451
|
+
});
|
|
452
|
+
pendingOAuth = null;
|
|
453
|
+
glog(`[oauth] antigravity: ??account added (label="${label}")`);
|
|
454
|
+
loadAntigravityProject(tokens.accessToken)
|
|
455
|
+
.then(projectId => {
|
|
456
|
+
updateAccountProjectId(account.id, projectId);
|
|
457
|
+
glog(`[oauth] antigravity: fetched projectId=${projectId}`);
|
|
458
|
+
})
|
|
459
|
+
.catch((e) => {
|
|
460
|
+
gerr(`[oauth] antigravity: could not fetch projectId ??${e.message}`);
|
|
461
|
+
});
|
|
462
|
+
})
|
|
463
|
+
.catch((err) => {
|
|
464
|
+
pendingOAuth = null;
|
|
465
|
+
oauthError = err.message;
|
|
466
|
+
gerr(`[oauth] antigravity: ??failed ??${err.message}`);
|
|
467
|
+
});
|
|
468
|
+
return reply.send({ authUrl: url });
|
|
469
|
+
});
|
|
470
|
+
// ?�?� GitHub Copilot OAuth (Device Code) ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
471
|
+
fastify.post("/api/oauth/copilot/start", async (req, reply) => {
|
|
472
|
+
const label = req.body?.label || "GitHub Copilot";
|
|
473
|
+
oauthError = null;
|
|
474
|
+
pendingOAuth = "copilot";
|
|
475
|
+
try {
|
|
476
|
+
const device = await startCopilotDeviceAuth();
|
|
477
|
+
glog(`[oauth] copilot: device login started (label="${label}", code="${device.userCode}")`);
|
|
478
|
+
pollCopilotDeviceToken(device)
|
|
479
|
+
.then(async (tokens) => {
|
|
480
|
+
// Validate the account can mint Copilot API tokens before saving it.
|
|
481
|
+
await getCopilotToken(tokens.accessToken);
|
|
482
|
+
addAccount("copilot", label, "oauth-official", {
|
|
483
|
+
oauthToken: tokens.accessToken,
|
|
484
|
+
});
|
|
485
|
+
pendingOAuth = null;
|
|
486
|
+
glog(`[oauth] copilot: ??account added (label="${label}")`);
|
|
487
|
+
})
|
|
488
|
+
.catch((err) => {
|
|
489
|
+
pendingOAuth = null;
|
|
490
|
+
oauthError = err.message;
|
|
491
|
+
gerr(`[oauth] copilot: ??failed ??${err.message}`);
|
|
492
|
+
});
|
|
493
|
+
return reply.send({
|
|
494
|
+
authUrl: device.verificationUri,
|
|
495
|
+
userCode: device.userCode,
|
|
496
|
+
expiresIn: device.expiresIn,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
pendingOAuth = null;
|
|
501
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
502
|
+
oauthError = msg;
|
|
503
|
+
gerr(`[oauth] copilot: ??failed ??${msg}`);
|
|
504
|
+
return reply.code(502).send({ error: msg });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
// ?�?� Antigravity models ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
508
|
+
fastify.get("/api/accounts/:id/antigravity-models", async (req, reply) => {
|
|
509
|
+
const config = loadConfig();
|
|
510
|
+
const account = config.accounts.find(a => a.id === req.params.id && a.provider === "antigravity");
|
|
511
|
+
if (!account)
|
|
512
|
+
return reply.status(404).send({ error: "Account not found" });
|
|
513
|
+
return reply.send({ models: ANTIGRAVITY_DEFAULT_MODELS });
|
|
514
|
+
});
|
|
515
|
+
// ?�?� Models (only from connected accounts, filtered by selectedModel) ?�?�?�?�?�?�?�?�
|
|
516
|
+
fastify.get("/v1/models", async () => {
|
|
517
|
+
const config = loadConfig();
|
|
518
|
+
const models = [];
|
|
519
|
+
for (const account of config.accounts.filter(a => a.connectedToOut)) {
|
|
520
|
+
if (account.selectedModel) {
|
|
521
|
+
models.push({ id: account.selectedModel, object: "model", owned_by: account.provider });
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
const accountModels = await loadModelsForAccount(account, config.ollamaBaseUrl);
|
|
525
|
+
accountModels.forEach(id => models.push({ id, object: "model", owned_by: account.provider }));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Ollama connected directly (special case for nodes without account entry)
|
|
529
|
+
return { object: "list", data: models };
|
|
530
|
+
});
|
|
531
|
+
// ?�?� Responses API proxy ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
532
|
+
fastify.post("/v1/responses", async (req, reply) => {
|
|
533
|
+
const config = loadConfig();
|
|
534
|
+
const rawBody = req.body;
|
|
535
|
+
const responsesReq = rawBody;
|
|
536
|
+
const streaming = rawBody.stream === true;
|
|
537
|
+
// Non-streaming: single call, return JSON
|
|
538
|
+
if (!streaming) {
|
|
539
|
+
try {
|
|
540
|
+
const result = await proxyRequest(responsesReq, config);
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
545
|
+
return reply.code(502).send({ error: { message: msg, type: "gateway_error" } });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Streaming: single streaming call ??no prior non-streaming call to avoid double billing
|
|
549
|
+
const raw = reply.raw;
|
|
550
|
+
raw.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
|
|
551
|
+
let seqNum = 0;
|
|
552
|
+
const sse = (event, data) => {
|
|
553
|
+
const payload = typeof data === "object" && data !== null
|
|
554
|
+
? { ...data, sequence_number: seqNum++ }
|
|
555
|
+
: data;
|
|
556
|
+
raw.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
557
|
+
};
|
|
558
|
+
// ?�?� Codex OAuth transparent passthrough ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
559
|
+
// Forwards the full request body (including tools, previous_response_id) so
|
|
560
|
+
// Codex-native models (gpt-5.5 etc.) can execute shell/file tools normally.
|
|
561
|
+
const passthrough = await tryCodexPassthrough(rawBody, config);
|
|
562
|
+
if (passthrough) {
|
|
563
|
+
const t0 = Date.now();
|
|
564
|
+
const usage = {};
|
|
565
|
+
const decoder = new TextDecoder();
|
|
566
|
+
let textBuf = "";
|
|
567
|
+
let logged = false;
|
|
568
|
+
try {
|
|
569
|
+
if (passthrough.res.body) {
|
|
570
|
+
const reader = passthrough.res.body.getReader();
|
|
571
|
+
try {
|
|
572
|
+
while (true) {
|
|
573
|
+
const { done, value } = await reader.read();
|
|
574
|
+
if (done)
|
|
575
|
+
break;
|
|
576
|
+
if (!logged) {
|
|
577
|
+
pushLog({ ts: Date.now(), requestedModel: responsesReq.model, provider: "openai", usedModel: passthrough.model, fallback: false, ms: Date.now() - t0, status: "ok" });
|
|
578
|
+
logged = true;
|
|
579
|
+
}
|
|
580
|
+
// Extract usage from response.completed while piping bytes
|
|
581
|
+
textBuf += decoder.decode(value, { stream: true });
|
|
582
|
+
const lines = textBuf.split("\n");
|
|
583
|
+
textBuf = lines.pop() ?? "";
|
|
584
|
+
for (const line of lines) {
|
|
585
|
+
if (!line.startsWith("data: "))
|
|
586
|
+
continue;
|
|
587
|
+
try {
|
|
588
|
+
const evt = JSON.parse(line.slice(6).trim());
|
|
589
|
+
if (evt.type === "response.completed") {
|
|
590
|
+
const resp = evt.response;
|
|
591
|
+
if (resp?.usage?.input_tokens)
|
|
592
|
+
usage.inputTokens = resp.usage.input_tokens;
|
|
593
|
+
if (resp?.usage?.output_tokens)
|
|
594
|
+
usage.outputTokens = resp.usage.output_tokens;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch { /* skip malformed */ }
|
|
598
|
+
}
|
|
599
|
+
raw.write(value);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
reader.releaseLock();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Update log entry with final usage/latency
|
|
607
|
+
const lastEntry = requestLog[requestLog.length - 1];
|
|
608
|
+
if (lastEntry && lastEntry.provider === "openai" && lastEntry.usedModel === passthrough.model) {
|
|
609
|
+
lastEntry.ms = Date.now() - t0;
|
|
610
|
+
if (usage.inputTokens)
|
|
611
|
+
lastEntry.inputTokens = usage.inputTokens;
|
|
612
|
+
if (usage.outputTokens)
|
|
613
|
+
lastEntry.outputTokens = usage.outputTokens;
|
|
614
|
+
}
|
|
615
|
+
flushLog();
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
gerr(`[gateway] Codex passthrough pipe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
619
|
+
}
|
|
620
|
+
raw.end();
|
|
621
|
+
return reply;
|
|
622
|
+
}
|
|
623
|
+
// ?�?� End transparent passthrough ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
624
|
+
const responseId = `resp_${Date.now()}`;
|
|
625
|
+
const reasoningItemId = `rs_${Date.now()}`;
|
|
626
|
+
let msgItemId = `msg_${Date.now() + 1}`;
|
|
627
|
+
// Only send response.created upfront ??Codex shows "?�각�? while waiting.
|
|
628
|
+
// output_item.added is deferred until the first real delta arrives so that
|
|
629
|
+
// "?�업�? only appears when content actually starts streaming.
|
|
630
|
+
sse("response.created", { type: "response.created", response: { id: responseId, object: "response", model: req.body.model, status: "in_progress", output: [] } });
|
|
631
|
+
let fullText = "";
|
|
632
|
+
let reasoningStarted = false;
|
|
633
|
+
let reasoningClosed = false;
|
|
634
|
+
let textItemOpened = false;
|
|
635
|
+
let textItemClosed = false;
|
|
636
|
+
let nextOutputIdx = 0;
|
|
637
|
+
let msgOutIdx = 0;
|
|
638
|
+
const toolCallMap = new Map();
|
|
639
|
+
const completedToolCalls = [];
|
|
640
|
+
const webFetchUrls = [];
|
|
641
|
+
const streamErrorDetails = (message) => {
|
|
642
|
+
if (message.includes("Anthropic error 429") || message.includes("rate_limit_error")) {
|
|
643
|
+
return {
|
|
644
|
+
code: "rate_limit_exceeded",
|
|
645
|
+
message: "Claude Code ?�용???�한??걸려???�번 ?�청????진행?????�습?�다. Anthropic??rate limit(429)??반환?�습?�다. ?�시 ???�시 ?�도?�거??Claude ?�용?�이 ?�복?????�어???�청?�주?�요.",
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (message.includes("Google error 429") || message.includes("RESOURCE_EXHAUSTED") || message.includes("Quota exceeded")) {
|
|
649
|
+
const retry = message.match(/retryDelay":\s*"([^"]+)"/)?.[1] ??
|
|
650
|
+
message.match(/Please retry in ([^.\n]+(?:\.\d+)?s)/)?.[1];
|
|
651
|
+
const retryText = retry ? ` ${retry} ???�시 ?�도?�주?�요.` : " ?�시 ???�시 ?�도?�주?�요.";
|
|
652
|
+
return {
|
|
653
|
+
code: "rate_limit_exceeded",
|
|
654
|
+
message: `Gemini ?�용???�한??걸려???�번 ?�청????진행?????�습?�다. Google??quota/rate limit(429)??반환?�습?�다.${retryText}`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return { code: "server_error", message };
|
|
658
|
+
};
|
|
659
|
+
const failStream = (message) => {
|
|
660
|
+
if (reasoningStarted && !reasoningClosed) {
|
|
661
|
+
sse("response.reasoning.done", { type: "response.reasoning.done", item_id: reasoningItemId, output_index: 0, content_index: 0, text: "" });
|
|
662
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: reasoningItemId, status: "completed", summary: [] } });
|
|
663
|
+
reasoningClosed = true;
|
|
664
|
+
}
|
|
665
|
+
if (textItemOpened && !textItemClosed) {
|
|
666
|
+
sse("response.output_text.done", { type: "response.output_text.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, text: fullText });
|
|
667
|
+
sse("response.content_part.done", { type: "response.content_part.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, part: { type: "output_text", text: fullText } });
|
|
668
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: msgOutIdx, item: { type: "message", id: msgItemId, status: "completed", role: "assistant", content: [{ type: "output_text", text: fullText }] } });
|
|
669
|
+
textItemClosed = true;
|
|
670
|
+
}
|
|
671
|
+
const completedOutput = [];
|
|
672
|
+
if (reasoningStarted)
|
|
673
|
+
completedOutput.push({ type: "reasoning", id: reasoningItemId, status: "completed", summary: [] });
|
|
674
|
+
if (textItemOpened)
|
|
675
|
+
completedOutput.push({ type: "message", id: msgItemId, status: "completed", role: "assistant", content: [{ type: "output_text", text: fullText }] });
|
|
676
|
+
for (const tc of completedToolCalls)
|
|
677
|
+
completedOutput.push({ type: "function_call", id: tc.id, call_id: tc.id, name: tc.name, arguments: tc.arguments, status: "completed" });
|
|
678
|
+
const error = streamErrorDetails(message);
|
|
679
|
+
sse("response.failed", { type: "response.failed", response: { id: responseId, object: "response", model: req.body.model, status: "failed", output: completedOutput, error } });
|
|
680
|
+
};
|
|
681
|
+
// Abort Ollama when Codex disconnects (prevents stacking multiple Ollama calls on reconnect)
|
|
682
|
+
const abortController = new AbortController();
|
|
683
|
+
raw.on("close", () => abortController.abort());
|
|
684
|
+
// Use response.reasoning.delta as keepalive when reasoning item is open ??this is a real
|
|
685
|
+
// Responses API event that resets Codex's idle timer. Heartbeat/comment events do not.
|
|
686
|
+
const keepaliveTimer = setInterval(() => {
|
|
687
|
+
if (reasoningStarted && !reasoningClosed) {
|
|
688
|
+
raw.write(`event: response.reasoning.delta\ndata: ${JSON.stringify({ type: "response.reasoning.delta", item_id: reasoningItemId, output_index: 0, content_index: 0, delta: { type: "reasoning_text_delta", text: "" } })}\n\n`);
|
|
689
|
+
}
|
|
690
|
+
}, 3_000);
|
|
691
|
+
try {
|
|
692
|
+
for await (const chunk of streamProxyRequest(responsesReq, config, abortController.signal, responseId)) {
|
|
693
|
+
if (chunk.type === 'reasoning') {
|
|
694
|
+
if (!reasoningStarted) {
|
|
695
|
+
sse("response.output_item.added", { type: "response.output_item.added", output_index: nextOutputIdx++, item: { type: "reasoning", id: reasoningItemId, status: "in_progress", summary: [] } });
|
|
696
|
+
reasoningStarted = true;
|
|
697
|
+
}
|
|
698
|
+
// Capture web fetch URLs emitted by proxy as "?�� <url>" in reasoning
|
|
699
|
+
if (chunk.content.includes("?�� ")) {
|
|
700
|
+
const urlMatch = chunk.content.match(/https?:\/\/\S+/);
|
|
701
|
+
if (urlMatch)
|
|
702
|
+
webFetchUrls.push(urlMatch[0]);
|
|
703
|
+
}
|
|
704
|
+
sse("response.reasoning.delta", { type: "response.reasoning.delta", item_id: reasoningItemId, output_index: 0, content_index: 0, delta: { type: "reasoning_text_delta", text: chunk.content } });
|
|
705
|
+
}
|
|
706
|
+
else if (chunk.type === 'text') {
|
|
707
|
+
if (reasoningStarted && !reasoningClosed) {
|
|
708
|
+
sse("response.reasoning.done", { type: "response.reasoning.done", item_id: reasoningItemId, output_index: 0, content_index: 0, text: "" });
|
|
709
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: reasoningItemId, status: "completed", summary: [] } });
|
|
710
|
+
reasoningClosed = true;
|
|
711
|
+
}
|
|
712
|
+
// If a function_call closed the text item, open a new message item for the continuation text.
|
|
713
|
+
if (textItemClosed) {
|
|
714
|
+
msgItemId = `msg_${Date.now() + nextOutputIdx}`;
|
|
715
|
+
textItemOpened = false;
|
|
716
|
+
textItemClosed = false;
|
|
717
|
+
}
|
|
718
|
+
if (!textItemOpened) {
|
|
719
|
+
msgOutIdx = nextOutputIdx++;
|
|
720
|
+
sse("response.output_item.added", { type: "response.output_item.added", output_index: msgOutIdx, item: { type: "message", id: msgItemId, status: "in_progress", role: "assistant", content: [] } });
|
|
721
|
+
sse("response.content_part.added", { type: "response.content_part.added", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, part: { type: "output_text", text: "" } });
|
|
722
|
+
textItemOpened = true;
|
|
723
|
+
}
|
|
724
|
+
fullText += chunk.content;
|
|
725
|
+
sse("response.output_text.delta", { type: "response.output_text.delta", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, delta: chunk.content });
|
|
726
|
+
}
|
|
727
|
+
else if (chunk.type === 'tool_call_start') {
|
|
728
|
+
// Keep reasoning open ??the full command will be appended to reasoning at tool_call_end
|
|
729
|
+
// so the user can see it in the gray "Thought" section in Codex UI.
|
|
730
|
+
if (textItemOpened && !textItemClosed) {
|
|
731
|
+
sse("response.output_text.done", { type: "response.output_text.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, text: fullText });
|
|
732
|
+
sse("response.content_part.done", { type: "response.content_part.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, part: { type: "output_text", text: fullText } });
|
|
733
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: msgOutIdx, item: { type: "message", id: msgItemId, status: "completed", role: "assistant", content: [{ type: "output_text", text: fullText }] } });
|
|
734
|
+
textItemClosed = true;
|
|
735
|
+
}
|
|
736
|
+
const toolOutIdx = nextOutputIdx++;
|
|
737
|
+
toolCallMap.set(chunk.id, { name: chunk.name, arguments: "", outputIdx: toolOutIdx });
|
|
738
|
+
sse("response.output_item.added", { type: "response.output_item.added", output_index: toolOutIdx, item: { type: "function_call", id: chunk.id, call_id: chunk.id, name: chunk.name, arguments: "", status: "in_progress" } });
|
|
739
|
+
}
|
|
740
|
+
else if (chunk.type === 'tool_call_delta') {
|
|
741
|
+
const tc = toolCallMap.get(chunk.id);
|
|
742
|
+
if (tc) {
|
|
743
|
+
tc.arguments += chunk.delta;
|
|
744
|
+
sse("response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: chunk.id, output_index: tc.outputIdx, delta: chunk.delta });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
else if (chunk.type === 'tool_call_end') {
|
|
748
|
+
const tc = toolCallMap.get(chunk.id);
|
|
749
|
+
if (tc) {
|
|
750
|
+
tc.arguments = chunk.arguments;
|
|
751
|
+
completedToolCalls.push({ id: chunk.id, name: tc.name, arguments: chunk.arguments, outputIdx: tc.outputIdx });
|
|
752
|
+
// Append executed command to reasoning (gray "Thought" section) so user can see it
|
|
753
|
+
if (reasoningStarted && !reasoningClosed) {
|
|
754
|
+
let cmdLine = `\n??${chunk.name}`;
|
|
755
|
+
try {
|
|
756
|
+
const parsed = JSON.parse(chunk.arguments);
|
|
757
|
+
const cmd = (parsed.cmd ?? parsed.command ?? parsed.script);
|
|
758
|
+
if (cmd)
|
|
759
|
+
cmdLine += `\n$ ${cmd}`;
|
|
760
|
+
else
|
|
761
|
+
cmdLine += `(${chunk.arguments})`;
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
cmdLine += `(${chunk.arguments})`;
|
|
765
|
+
}
|
|
766
|
+
sse("response.reasoning.delta", { type: "response.reasoning.delta", item_id: reasoningItemId, output_index: 0, content_index: 0, delta: { type: "reasoning_text_delta", text: cmdLine } });
|
|
767
|
+
}
|
|
768
|
+
sse("response.function_call_arguments.done", { type: "response.function_call_arguments.done", item_id: chunk.id, output_index: tc.outputIdx, arguments: chunk.arguments });
|
|
769
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: tc.outputIdx, item: { type: "function_call", id: chunk.id, call_id: chunk.id, name: tc.name, arguments: chunk.arguments, status: "completed" } });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch (streamErr) {
|
|
775
|
+
clearInterval(keepaliveTimer);
|
|
776
|
+
const msg = streamErr instanceof Error ? streamErr.message : String(streamErr);
|
|
777
|
+
gerr(`[gateway] stream failed with protocol error event: ${msg}`);
|
|
778
|
+
failStream(msg);
|
|
779
|
+
raw.end();
|
|
780
|
+
return reply;
|
|
781
|
+
}
|
|
782
|
+
clearInterval(keepaliveTimer);
|
|
783
|
+
// Close text item if still open after stream ends
|
|
784
|
+
if (textItemOpened && !textItemClosed) {
|
|
785
|
+
sse("response.output_text.done", { type: "response.output_text.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, text: fullText });
|
|
786
|
+
sse("response.content_part.done", { type: "response.content_part.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, part: { type: "output_text", text: fullText } });
|
|
787
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: msgOutIdx, item: { type: "message", id: msgItemId, status: "completed", role: "assistant", content: [{ type: "output_text", text: fullText }] } });
|
|
788
|
+
textItemClosed = true;
|
|
789
|
+
}
|
|
790
|
+
// Close reasoning if it was opened but never closed (model output was reasoning-only).
|
|
791
|
+
// An unclosed reasoning item before response.completed confuses Codex and causes blank UI.
|
|
792
|
+
if (reasoningStarted && !reasoningClosed) {
|
|
793
|
+
sse("response.reasoning.done", { type: "response.reasoning.done", item_id: reasoningItemId, output_index: 0, content_index: 0, text: "" });
|
|
794
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: reasoningItemId, status: "completed", summary: [] } });
|
|
795
|
+
reasoningClosed = true;
|
|
796
|
+
}
|
|
797
|
+
// If nothing was emitted at all, open+close empty message so Codex doesn't hang
|
|
798
|
+
if (!textItemOpened && completedToolCalls.length === 0) {
|
|
799
|
+
msgOutIdx = nextOutputIdx++;
|
|
800
|
+
sse("response.output_item.added", { type: "response.output_item.added", output_index: msgOutIdx, item: { type: "message", id: msgItemId, status: "in_progress", role: "assistant", content: [] } });
|
|
801
|
+
sse("response.content_part.added", { type: "response.content_part.added", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, part: { type: "output_text", text: "" } });
|
|
802
|
+
sse("response.output_text.done", { type: "response.output_text.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, text: "" });
|
|
803
|
+
sse("response.content_part.done", { type: "response.content_part.done", item_id: msgItemId, output_index: msgOutIdx, content_index: 0, part: { type: "output_text", text: "" } });
|
|
804
|
+
sse("response.output_item.done", { type: "response.output_item.done", output_index: msgOutIdx, item: { type: "message", id: msgItemId, status: "completed", role: "assistant", content: [{ type: "output_text", text: "" }] } });
|
|
805
|
+
}
|
|
806
|
+
// Build response.completed output array (reasoning + message + function_calls)
|
|
807
|
+
const completedOutput = [];
|
|
808
|
+
if (reasoningStarted)
|
|
809
|
+
completedOutput.push({ type: "reasoning", id: reasoningItemId, status: "completed", summary: [] });
|
|
810
|
+
if (textItemOpened || completedToolCalls.length === 0) {
|
|
811
|
+
completedOutput.push({ type: "message", id: msgItemId, status: "completed", role: "assistant", content: [{ type: "output_text", text: fullText }] });
|
|
812
|
+
}
|
|
813
|
+
for (const tc of completedToolCalls)
|
|
814
|
+
completedOutput.push({ type: "function_call", id: tc.id, call_id: tc.id, name: tc.name, arguments: tc.arguments, status: "completed" });
|
|
815
|
+
// Populate detail fields on the most-recent log entry
|
|
816
|
+
const lastLogEntry = requestLog[requestLog.length - 1];
|
|
817
|
+
if (lastLogEntry) {
|
|
818
|
+
if (fullText)
|
|
819
|
+
lastLogEntry.outputPreview = fullText.slice(0, 300);
|
|
820
|
+
if (completedToolCalls.length) {
|
|
821
|
+
lastLogEntry.toolCalls = completedToolCalls.map(tc => tc.name);
|
|
822
|
+
lastLogEntry.toolCallDetails = completedToolCalls.map(tc => ({ name: tc.name, args: tc.arguments }));
|
|
823
|
+
}
|
|
824
|
+
if (webFetchUrls.length)
|
|
825
|
+
lastLogEntry.webFetches = webFetchUrls;
|
|
826
|
+
// inputPreview: extract user text from req.body.input
|
|
827
|
+
try {
|
|
828
|
+
const inputArr = Array.isArray(responsesReq.input) ? responsesReq.input : [];
|
|
829
|
+
const userMsg = [...inputArr].reverse().find((i) => i.type === "message" && i.role === "user");
|
|
830
|
+
if (userMsg) {
|
|
831
|
+
const c = userMsg.content;
|
|
832
|
+
const txt = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x.text ?? '').join('') : '';
|
|
833
|
+
if (txt)
|
|
834
|
+
lastLogEntry.inputPreview = txt.slice(0, 200);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
catch { /* ignore */ }
|
|
838
|
+
flushLog();
|
|
839
|
+
}
|
|
840
|
+
sse("response.completed", { type: "response.completed", response: { id: responseId, object: "response", model: req.body.model, status: "completed", output: completedOutput } });
|
|
841
|
+
raw.end();
|
|
842
|
+
return reply;
|
|
843
|
+
});
|
|
844
|
+
// ?�?� Chat completions fallback ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�
|
|
845
|
+
fastify.post("/v1/chat/completions", async (req, reply) => {
|
|
846
|
+
const config = loadConfig();
|
|
847
|
+
const { model, messages, stream = false } = req.body;
|
|
848
|
+
const systemMsg = messages.find(m => m.role === "system");
|
|
849
|
+
const userMessages = messages.filter(m => m.role !== "system");
|
|
850
|
+
const responsesReq = {
|
|
851
|
+
model,
|
|
852
|
+
input: userMessages.map(m => ({ type: "message", role: m.role, content: m.content })),
|
|
853
|
+
instructions: systemMsg?.content,
|
|
854
|
+
stream,
|
|
855
|
+
};
|
|
856
|
+
try {
|
|
857
|
+
const result = await proxyRequest(responsesReq, config);
|
|
858
|
+
const text = result.output[0]?.content[0]?.text ?? "";
|
|
859
|
+
return {
|
|
860
|
+
id: result.id, object: "chat.completion", model,
|
|
861
|
+
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
|
|
862
|
+
usage: result.usage ? { prompt_tokens: result.usage.input_tokens, completion_tokens: result.usage.output_tokens, total_tokens: result.usage.total_tokens } : undefined,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
867
|
+
return reply.code(502).send({ error: { message: msg } });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
return {
|
|
871
|
+
async start() {
|
|
872
|
+
const config = loadConfig();
|
|
873
|
+
const port = await findFreePort(config.port);
|
|
874
|
+
await fastify.listen({ port, host: "127.0.0.1" });
|
|
875
|
+
if (port !== config.port) {
|
|
876
|
+
config.port = port;
|
|
877
|
+
saveConfig(config);
|
|
878
|
+
}
|
|
879
|
+
saveGatewayPid(process.pid);
|
|
880
|
+
glog(`[gateway] ??started on port ${port} (pid=${process.pid}, accounts=${config.accounts.length})`);
|
|
881
|
+
return port;
|
|
882
|
+
},
|
|
883
|
+
async stop() {
|
|
884
|
+
glog("[gateway] stopping...");
|
|
885
|
+
clearGatewayPid();
|
|
886
|
+
await fastify.close();
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
//# sourceMappingURL=server.js.map
|