@bitkyc08/opencodex 2.5.6 → 2.6.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/README.ko.md +17 -6
- package/README.md +19 -7
- package/README.zh-CN.md +12 -3
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +88 -2
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-BS4X1QDi.js +9 -0
- package/gui/dist/assets/{index-CKqUwc02.css → index-BwvDb198.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +20 -6
- package/src/adapters/anthropic.ts +16 -5
- package/src/adapters/google.ts +9 -2
- package/src/adapters/openai-chat.ts +13 -5
- package/src/bun-runtime.ts +22 -1
- package/src/cli-help.ts +111 -0
- package/src/cli-status.ts +164 -0
- package/src/cli.ts +77 -186
- package/src/codex-account-store.ts +47 -8
- package/src/codex-auth-api.ts +111 -54
- package/src/codex-auth-collision.ts +5 -0
- package/src/codex-catalog.ts +24 -12
- package/src/codex-history-provider.ts +29 -13
- package/src/codex-inject.ts +46 -29
- package/src/codex-journal.ts +77 -13
- package/src/codex-quota.ts +11 -3
- package/src/codex-routing.ts +14 -4
- package/src/codex-shim.ts +71 -24
- package/src/codex-websocket-registry.ts +20 -4
- package/src/config.ts +138 -4
- package/src/init.ts +7 -2
- package/src/oauth/callback-server.ts +22 -15
- package/src/oauth/index.ts +18 -4
- package/src/oauth/login-cli.ts +8 -1
- package/src/oauth/store.ts +2 -1
- package/src/process-control.ts +36 -0
- package/src/provider-label.ts +8 -0
- package/src/responses/parser.ts +18 -1
- package/src/router.ts +61 -5
- package/src/server.ts +878 -94
- package/src/service-secrets.ts +6 -0
- package/src/service.ts +293 -28
- package/src/types.ts +26 -1
- package/src/update.ts +16 -9
- package/src/usage-debug.ts +65 -0
- package/src/usage-log.ts +62 -0
- package/src/usage-summary.ts +0 -0
- package/src/ws-bridge.ts +2 -2
- package/gui/README.md +0 -73
- package/gui/dist/assets/index-CSUvRNAX.js +0 -9
package/src/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
6
|
import type { OcxConfig } from "./types";
|
|
7
7
|
|
|
@@ -16,8 +16,14 @@ export function atomicWriteFile(path: string, content: string): void {
|
|
|
16
16
|
renameSync(tmp, path);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
|
|
20
|
+
|
|
19
21
|
function resolveConfigDir(): string {
|
|
20
|
-
|
|
22
|
+
const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
|
|
23
|
+
if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
|
|
24
|
+
const path = raw ? resolve(raw) : join(homedir(), ".opencodex");
|
|
25
|
+
resolvedConfigDirCache = { raw, path };
|
|
26
|
+
return path;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
function resolveConfigPath(): string {
|
|
@@ -28,6 +34,10 @@ function resolvePidPath(): string {
|
|
|
28
34
|
return join(resolveConfigDir(), "ocx.pid");
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
function resolveRuntimePortPath(): string {
|
|
38
|
+
return join(resolveConfigDir(), "runtime-port.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
const warnedConfigFallbacks = new Set<string>();
|
|
32
42
|
|
|
33
43
|
const providerConfigSchema = z.object({
|
|
@@ -35,12 +45,35 @@ const providerConfigSchema = z.object({
|
|
|
35
45
|
baseUrl: z.string().min(1),
|
|
36
46
|
}).passthrough();
|
|
37
47
|
|
|
48
|
+
const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
|
|
49
|
+
const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
|
|
50
|
+
|
|
51
|
+
export function isValidProviderName(name: string): boolean {
|
|
52
|
+
const trimmed = name.trim();
|
|
53
|
+
return trimmed === name
|
|
54
|
+
&& PROVIDER_NAME_PATTERN.test(name)
|
|
55
|
+
&& !RESERVED_PROVIDER_NAMES.has(name.toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function hasOwnProvider(providers: Record<string, unknown>, name: string): boolean {
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(providers, name);
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
const configSchema = z.object({
|
|
39
63
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
40
64
|
providers: z.record(z.string(), providerConfigSchema),
|
|
41
65
|
defaultProvider: z.string().min(1).default("openai"),
|
|
42
66
|
}).passthrough().superRefine((config, ctx) => {
|
|
43
|
-
|
|
67
|
+
for (const name of Object.keys(config.providers)) {
|
|
68
|
+
if (!isValidProviderName(name)) {
|
|
69
|
+
ctx.addIssue({
|
|
70
|
+
code: "custom",
|
|
71
|
+
path: ["providers", name],
|
|
72
|
+
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
44
77
|
ctx.addIssue({
|
|
45
78
|
code: "custom",
|
|
46
79
|
path: ["defaultProvider"],
|
|
@@ -70,6 +103,10 @@ export function getPidPath(): string {
|
|
|
70
103
|
return resolvePidPath();
|
|
71
104
|
}
|
|
72
105
|
|
|
106
|
+
export function getRuntimePortPath(): string {
|
|
107
|
+
return resolveRuntimePortPath();
|
|
108
|
+
}
|
|
109
|
+
|
|
73
110
|
export function hardenConfigDir(): void {
|
|
74
111
|
const dir = getConfigDir();
|
|
75
112
|
if (existsSync(dir)) {
|
|
@@ -120,6 +157,57 @@ export function loadConfig(): OcxConfig {
|
|
|
120
157
|
}
|
|
121
158
|
}
|
|
122
159
|
|
|
160
|
+
export type ConfigDiagnostics = {
|
|
161
|
+
config: OcxConfig;
|
|
162
|
+
source: "default" | "file" | "fallback";
|
|
163
|
+
error: string | null;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function mergeConfigDefaults(parsed: unknown): unknown {
|
|
167
|
+
if (!parsed || typeof parsed !== "object") return parsed;
|
|
168
|
+
const defaults = getDefaultConfig();
|
|
169
|
+
const raw = parsed as Record<string, unknown>;
|
|
170
|
+
const merged: Record<string, unknown> = { ...defaults, ...raw };
|
|
171
|
+
if (raw.providers && typeof raw.providers === "object" && defaults.providers) {
|
|
172
|
+
merged.providers = { ...defaults.providers, ...(raw.providers as Record<string, unknown>) };
|
|
173
|
+
}
|
|
174
|
+
return merged;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function configIssuePaths(error: z.ZodError): string[] {
|
|
178
|
+
const paths = error.issues.map(issue => issue.path.join(".") || "config");
|
|
179
|
+
return [...new Set(paths)].sort();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function schemaDiagnosticsError(error: z.ZodError): string {
|
|
183
|
+
const paths = configIssuePaths(error);
|
|
184
|
+
return paths.length > 0 ? `schema_invalid: ${paths.join(", ")}` : "schema_invalid";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
188
|
+
const configPath = getConfigPath();
|
|
189
|
+
if (!existsSync(configPath)) {
|
|
190
|
+
return { config: getDefaultConfig(), source: "default", error: null };
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
194
|
+
const parsed = JSON.parse(raw);
|
|
195
|
+
const result = configSchema.safeParse(parsed);
|
|
196
|
+
if (result.success) {
|
|
197
|
+
return { config: result.data as OcxConfig, source: "file", error: null };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
|
|
201
|
+
if (retryResult.success) {
|
|
202
|
+
return { config: retryResult.data as OcxConfig, source: "file", error: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
|
|
206
|
+
} catch {
|
|
207
|
+
return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
123
211
|
export function saveConfig(config: OcxConfig): void {
|
|
124
212
|
const dir = getConfigDir();
|
|
125
213
|
if (!existsSync(dir)) {
|
|
@@ -176,6 +264,34 @@ export function writePid(pid: number): void {
|
|
|
176
264
|
atomicWriteFile(getPidPath(), String(pid));
|
|
177
265
|
}
|
|
178
266
|
|
|
267
|
+
export type RuntimePortState = {
|
|
268
|
+
pid: number;
|
|
269
|
+
port: number;
|
|
270
|
+
hostname?: string;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
function isValidRuntimePortState(value: unknown): value is RuntimePortState {
|
|
274
|
+
if (!value || typeof value !== "object") return false;
|
|
275
|
+
const state = value as Record<string, unknown>;
|
|
276
|
+
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
|
|
277
|
+
return Number.isSafeInteger(state.pid)
|
|
278
|
+
&& Number(state.pid) > 0
|
|
279
|
+
&& Number.isInteger(state.port)
|
|
280
|
+
&& Number(state.port) > 0
|
|
281
|
+
&& Number(state.port) <= 65535
|
|
282
|
+
&& hostnameOk;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function writeRuntimePort(state: RuntimePortState): void {
|
|
286
|
+
const dir = getConfigDir();
|
|
287
|
+
if (!existsSync(dir)) {
|
|
288
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
289
|
+
} else {
|
|
290
|
+
hardenConfigDir();
|
|
291
|
+
}
|
|
292
|
+
atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
179
295
|
export function readPid(): number | null {
|
|
180
296
|
const pidPath = getPidPath();
|
|
181
297
|
if (!existsSync(pidPath)) return null;
|
|
@@ -197,6 +313,17 @@ export function readPid(): number | null {
|
|
|
197
313
|
}
|
|
198
314
|
}
|
|
199
315
|
|
|
316
|
+
export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8"));
|
|
319
|
+
if (!isValidRuntimePortState(parsed)) return null;
|
|
320
|
+
if (expectedPid !== undefined && parsed.pid !== expectedPid) return null;
|
|
321
|
+
return parsed;
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
200
327
|
export function removePid(expectedPid?: number): void {
|
|
201
328
|
if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
|
|
202
329
|
try {
|
|
@@ -219,6 +346,13 @@ function readPidFileValue(): number | null {
|
|
|
219
346
|
}
|
|
220
347
|
}
|
|
221
348
|
|
|
349
|
+
export function removeRuntimePort(expectedPid?: number): void {
|
|
350
|
+
if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
|
|
351
|
+
try {
|
|
352
|
+
unlinkSync(getRuntimePortPath());
|
|
353
|
+
} catch { /* ignore */ }
|
|
354
|
+
}
|
|
355
|
+
|
|
222
356
|
export function parsePidFile(raw: string): number | null {
|
|
223
357
|
const trimmed = raw.trim();
|
|
224
358
|
if (!/^\d+$/.test(trimmed)) return null;
|
|
@@ -273,7 +407,7 @@ function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
|
|
|
273
407
|
console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`);
|
|
274
408
|
}
|
|
275
409
|
|
|
276
|
-
function backupInvalidConfig(configPath: string): string | null {
|
|
410
|
+
export function backupInvalidConfig(configPath: string): string | null {
|
|
277
411
|
if (!existsSync(configPath)) return null;
|
|
278
412
|
const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
279
413
|
try {
|
package/src/init.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as readline from "node:readline";
|
|
2
2
|
import { injectCodexConfig } from "./codex-inject";
|
|
3
|
-
import { getDefaultConfig, saveConfig } from "./config";
|
|
3
|
+
import { getDefaultConfig, isValidProviderName, saveConfig } from "./config";
|
|
4
4
|
import { enrichProviderFromCatalog } from "./oauth/key-providers";
|
|
5
5
|
import { deriveInitProviders } from "./providers/derive";
|
|
6
6
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
@@ -97,7 +97,12 @@ export async function runInit(): Promise<void> {
|
|
|
97
97
|
enrichProviderFromCatalog(p.id, providerConfig);
|
|
98
98
|
}
|
|
99
99
|
} else {
|
|
100
|
-
providerName = await prompt.ask("Provider name: ");
|
|
100
|
+
providerName = (await prompt.ask("Provider name: ")).trim();
|
|
101
|
+
if (!isValidProviderName(providerName)) {
|
|
102
|
+
console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
|
|
103
|
+
prompt.close();
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
101
106
|
const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
|
|
102
107
|
const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
|
|
103
108
|
const apiKey = await prompt.ask("API key (optional): ");
|
|
@@ -12,6 +12,7 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
|
|
12
12
|
|
|
13
13
|
const DEFAULT_TIMEOUT = 300_000;
|
|
14
14
|
const DEFAULT_HOSTNAME = "localhost";
|
|
15
|
+
const DEFAULT_BIND_HOSTNAME = "127.0.0.1";
|
|
15
16
|
const CALLBACK_PATH = "/callback";
|
|
16
17
|
|
|
17
18
|
const SUCCESS_HTML =
|
|
@@ -67,13 +68,13 @@ export abstract class OAuthCallbackFlow {
|
|
|
67
68
|
this.preferredPort = preferredPortOrOptions;
|
|
68
69
|
this.callbackPath = callbackPath;
|
|
69
70
|
this.callbackHostname = DEFAULT_HOSTNAME;
|
|
70
|
-
this.callbackBindHostname =
|
|
71
|
+
this.callbackBindHostname = DEFAULT_BIND_HOSTNAME;
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
73
74
|
this.preferredPort = preferredPortOrOptions.preferredPort;
|
|
74
75
|
this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
|
|
75
76
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
76
|
-
this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ??
|
|
77
|
+
this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? DEFAULT_BIND_HOSTNAME;
|
|
77
78
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -151,30 +152,36 @@ export abstract class OAuthCallbackFlow {
|
|
|
151
152
|
const errorDescription = url.searchParams.get("error_description") || error;
|
|
152
153
|
|
|
153
154
|
let ok = false;
|
|
155
|
+
let consumeFlow = false;
|
|
154
156
|
let errMessage = "";
|
|
157
|
+
const stateMatches = !expectedState || state === expectedState;
|
|
155
158
|
if (error) {
|
|
156
159
|
errMessage = `Authorization failed: ${errorDescription}`;
|
|
160
|
+
consumeFlow = stateMatches;
|
|
157
161
|
} else if (!code) {
|
|
158
162
|
errMessage = "Missing authorization code";
|
|
159
|
-
} else if (
|
|
163
|
+
} else if (!stateMatches) {
|
|
160
164
|
errMessage = "State mismatch - possible CSRF attack";
|
|
161
165
|
} else {
|
|
162
166
|
ok = true;
|
|
167
|
+
consumeFlow = true;
|
|
163
168
|
}
|
|
164
169
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
170
|
+
if (consumeFlow) {
|
|
171
|
+
// Capture refs before they could be cleared, then resolve on the next microtask.
|
|
172
|
+
const resolve = this.#callbackResolve;
|
|
173
|
+
const reject = this.#callbackReject;
|
|
174
|
+
queueMicrotask(() => {
|
|
175
|
+
if (ok && code) {
|
|
176
|
+
resolve?.({ code, state });
|
|
177
|
+
} else {
|
|
178
|
+
reject?.(errMessage || "Unknown error");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
175
182
|
|
|
176
183
|
return new Response(ok ? SUCCESS_HTML : errorHtml(errMessage), {
|
|
177
|
-
status: ok ? 200 : 500,
|
|
184
|
+
status: ok ? 200 : consumeFlow ? 500 : 400,
|
|
178
185
|
headers: { "Content-Type": "text/html" },
|
|
179
186
|
});
|
|
180
187
|
}
|
|
@@ -204,7 +211,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
204
211
|
.then((input): CallbackResult | null => {
|
|
205
212
|
const parsed = parseCallbackInput(input);
|
|
206
213
|
if (!parsed.code) return null;
|
|
207
|
-
if (expectedState && parsed.state
|
|
214
|
+
if (expectedState && parsed.state !== expectedState) return null;
|
|
208
215
|
return { code: parsed.code, state: parsed.state ?? "" };
|
|
209
216
|
})
|
|
210
217
|
.catch((): CallbackResult | null => null),
|
package/src/oauth/index.ts
CHANGED
|
@@ -69,12 +69,26 @@ export function listOAuthProviders(): string[] {
|
|
|
69
69
|
return Object.keys(OAUTH_PROVIDERS);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
export class UnsupportedOAuthProviderError extends Error {
|
|
73
|
+
constructor(provider: string) {
|
|
74
|
+
super(`Unsupported OAuth provider in config: ${provider}`);
|
|
75
|
+
this.name = "UnsupportedOAuthProviderError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class OAuthLoginRequiredError extends Error {
|
|
80
|
+
constructor(provider: string) {
|
|
81
|
+
super(`Not logged in to ${provider}. Run: ocx login ${provider}`);
|
|
82
|
+
this.name = "OAuthLoginRequiredError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
/** Return a valid access token, refreshing + persisting if expired. Throws if not logged in. */
|
|
73
87
|
export async function getValidAccessToken(provider: string): Promise<string> {
|
|
74
88
|
const def = OAUTH_PROVIDERS[provider];
|
|
75
|
-
if (!def) throw new
|
|
89
|
+
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
76
90
|
const cred = getCredential(provider);
|
|
77
|
-
if (!cred) throw new
|
|
91
|
+
if (!cred) throw new OAuthLoginRequiredError(provider);
|
|
78
92
|
if (cred.expires > Date.now() + REFRESH_SKEW_MS) return cred.access;
|
|
79
93
|
const fresh = await def.refresh(cred.refresh);
|
|
80
94
|
saveCredential(provider, fresh);
|
|
@@ -190,7 +204,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
190
204
|
/** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */
|
|
191
205
|
export async function runLogin(provider: string, ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials> {
|
|
192
206
|
const def = OAUTH_PROVIDERS[provider];
|
|
193
|
-
if (!def) throw new
|
|
207
|
+
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
194
208
|
const cred = await def.login(ctrl, opts);
|
|
195
209
|
saveCredential(provider, cred);
|
|
196
210
|
const config = loadConfig();
|
|
@@ -231,7 +245,7 @@ export function cancelLoginFlow(provider: string): boolean {
|
|
|
231
245
|
|
|
232
246
|
export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string }> {
|
|
233
247
|
const def = OAUTH_PROVIDERS[provider];
|
|
234
|
-
if (!def) throw new
|
|
248
|
+
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
235
249
|
const existing = loginState.get(provider);
|
|
236
250
|
if (existing && !existing.done) {
|
|
237
251
|
throw new Error(`A login for ${provider} is already in progress`);
|
package/src/oauth/login-cli.ts
CHANGED
|
@@ -5,6 +5,13 @@ import { OAUTH_PROVIDERS, runLogin } from "./index";
|
|
|
5
5
|
import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
|
|
6
6
|
import type { OcxProviderConfig } from "../types";
|
|
7
7
|
|
|
8
|
+
export function runningProxyUpdateHeaders(): Headers {
|
|
9
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
10
|
+
const apiToken = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
11
|
+
if (apiToken) headers.set("X-OpenCodex-API-Key", apiToken);
|
|
12
|
+
return headers;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
/** Push the new provider into a running proxy's live config so it routes without a restart. */
|
|
9
16
|
async function notifyRunningProxy(name: string, provider: unknown): Promise<void> {
|
|
10
17
|
if (!readPid()) return;
|
|
@@ -12,7 +19,7 @@ async function notifyRunningProxy(name: string, provider: unknown): Promise<void
|
|
|
12
19
|
try {
|
|
13
20
|
await fetch(`http://localhost:${cfg.port}/api/providers`, {
|
|
14
21
|
method: "POST",
|
|
15
|
-
headers:
|
|
22
|
+
headers: runningProxyUpdateHeaders(),
|
|
16
23
|
body: JSON.stringify({ name, provider }),
|
|
17
24
|
});
|
|
18
25
|
} catch {
|
package/src/oauth/store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** OAuth token store at ~/.opencodex/auth.json, keyed by provider name. */
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
4
|
+
import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
5
5
|
import type { OAuthCredentials } from "./types";
|
|
6
6
|
|
|
7
7
|
type AuthStore = Record<string, OAuthCredentials>;
|
|
@@ -18,6 +18,7 @@ export function loadAuthStore(): AuthStore {
|
|
|
18
18
|
try {
|
|
19
19
|
return JSON.parse(readFileSync(path, "utf-8")) as AuthStore;
|
|
20
20
|
} catch {
|
|
21
|
+
backupInvalidConfig(path);
|
|
21
22
|
return {};
|
|
22
23
|
}
|
|
23
24
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function isProcessAlive(pid: number): boolean {
|
|
4
|
+
try {
|
|
5
|
+
process.kill(pid, 0);
|
|
6
|
+
return true;
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function waitForExit(pid: number, timeoutMs: number): boolean {
|
|
13
|
+
const deadline = Date.now() + timeoutMs;
|
|
14
|
+
const marker = new Int32Array(new SharedArrayBuffer(4));
|
|
15
|
+
while (Date.now() < deadline) {
|
|
16
|
+
if (!isProcessAlive(pid)) return true;
|
|
17
|
+
Atomics.wait(marker, 0, 0, 50);
|
|
18
|
+
}
|
|
19
|
+
return !isProcessAlive(pid);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function killProxy(pid: number): void {
|
|
23
|
+
if (!isProcessAlive(pid)) return;
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
|
|
26
|
+
try {
|
|
27
|
+
execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (isProcessAlive(pid)) throw err;
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
process.kill(pid, "SIGTERM");
|
|
33
|
+
if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL");
|
|
34
|
+
}
|
|
35
|
+
if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./codex-account-label";
|
|
2
|
+
|
|
3
|
+
export function baseProviderLabel(provider: string): string {
|
|
4
|
+
const cut = provider.lastIndexOf("-");
|
|
5
|
+
if (cut <= 0) return provider;
|
|
6
|
+
const suffix = provider.slice(cut + 1);
|
|
7
|
+
return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? provider.slice(0, cut) : provider;
|
|
8
|
+
}
|
package/src/responses/parser.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
} from "../types";
|
|
13
13
|
import { namespacedToolName } from "../types";
|
|
14
14
|
import { responsesRequestSchema } from "./schema";
|
|
15
|
-
import { extractHostedWebSearch } from "../web-search/synthetic-tool";
|
|
15
|
+
import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
|
|
16
16
|
|
|
17
17
|
function isObj(v: unknown): v is Record<string, unknown> {
|
|
18
18
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -73,11 +73,27 @@ function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] {
|
|
|
73
73
|
if ((t === "function" || t === "custom") && "name" in value) {
|
|
74
74
|
return { name: (value as { name: string }).name };
|
|
75
75
|
}
|
|
76
|
+
if (t === "allowed_tools" && Array.isArray(value.tools)) {
|
|
77
|
+
const names = value.tools
|
|
78
|
+
.map(allowedToolName)
|
|
79
|
+
.filter((name): name is string => Boolean(name));
|
|
80
|
+
return names.length > 0
|
|
81
|
+
? { allowedTools: [...new Set(names)], mode: value.mode === "required" ? "required" : "auto" }
|
|
82
|
+
: "none";
|
|
83
|
+
}
|
|
76
84
|
return "auto";
|
|
77
85
|
}
|
|
78
86
|
return undefined;
|
|
79
87
|
}
|
|
80
88
|
|
|
89
|
+
function allowedToolName(tool: unknown): string | undefined {
|
|
90
|
+
if (!isObj(tool)) return undefined;
|
|
91
|
+
if (typeof tool.name === "string" && tool.name.length > 0) return tool.name;
|
|
92
|
+
if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME;
|
|
93
|
+
if (tool.type === "tool_search") return "tool_search";
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
81
97
|
function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
|
|
82
98
|
if (!tools) return undefined;
|
|
83
99
|
const out: OcxTool[] = [];
|
|
@@ -379,6 +395,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
379
395
|
if (!summaryMode || summaryMode === "none") options.hideThinkingSummary = true;
|
|
380
396
|
if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
|
|
381
397
|
if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
|
|
398
|
+
if (data.service_tier !== undefined) options.serviceTier = data.service_tier;
|
|
382
399
|
|
|
383
400
|
// Stash the hosted web_search config (if Codex enabled it) so the proxy can run searches via the
|
|
384
401
|
// gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
|
package/src/router.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
2
|
-
import { resolveEnvValue } from "./config";
|
|
2
|
+
import { hasOwnProvider, resolveEnvValue } from "./config";
|
|
3
3
|
import { PROVIDER_REGISTRY } from "./providers/registry";
|
|
4
4
|
|
|
5
5
|
interface RouteResult {
|
|
@@ -42,17 +42,73 @@ function mergeNestedRecord(
|
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function mergeStringArray(
|
|
46
|
+
seed: string[] | undefined,
|
|
47
|
+
user: string[] | undefined,
|
|
48
|
+
): string[] | undefined {
|
|
49
|
+
if (!seed && !user) return undefined;
|
|
50
|
+
return [...new Set([...(seed ?? []), ...(user ?? [])])];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mergeRecordFill<T>(
|
|
54
|
+
seed: Record<string, T> | undefined,
|
|
55
|
+
user: Record<string, T> | undefined,
|
|
56
|
+
): Record<string, T> | undefined {
|
|
57
|
+
if (!seed && !user) return undefined;
|
|
58
|
+
return { ...(seed ?? {}), ...(user ?? {}) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mergeStringArrayRecord(
|
|
62
|
+
seed: Record<string, string[]> | undefined,
|
|
63
|
+
user: Record<string, string[]> | undefined,
|
|
64
|
+
): Record<string, string[]> | undefined {
|
|
65
|
+
if (!seed && !user) return undefined;
|
|
66
|
+
const out: Record<string, string[]> = {};
|
|
67
|
+
for (const [key, value] of Object.entries(seed ?? {})) out[key] = [...value];
|
|
68
|
+
for (const [key, value] of Object.entries(user ?? {})) out[key] = [...value];
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
46
73
|
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
|
|
47
74
|
if (!registryEntry) return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
|
|
75
|
+
const canonicalAuthMode = registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
|
|
76
|
+
? registryEntry.authKind
|
|
77
|
+
: provider.authMode === "forward" ? undefined : provider.authMode;
|
|
48
78
|
const reasoningEffortMap = mergeRecord(registryEntry.reasoningEffortMap, provider.reasoningEffortMap);
|
|
49
79
|
const modelReasoningEffortMap = mergeNestedRecord(registryEntry.modelReasoningEffortMap, provider.modelReasoningEffortMap);
|
|
80
|
+
const modelReasoningEfforts = mergeStringArrayRecord(registryEntry.modelReasoningEfforts, provider.modelReasoningEfforts);
|
|
81
|
+
const modelContextWindows = mergeRecordFill(registryEntry.modelContextWindows, provider.modelContextWindows);
|
|
82
|
+
const modelInputModalities = mergeRecordFill(registryEntry.modelInputModalities, provider.modelInputModalities);
|
|
83
|
+
const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels);
|
|
84
|
+
const noReasoningModels = mergeStringArray(registryEntry.noReasoningModels, provider.noReasoningModels);
|
|
85
|
+
const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels);
|
|
86
|
+
const noTopPModels = mergeStringArray(registryEntry.noTopPModels, provider.noTopPModels);
|
|
87
|
+
const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
|
|
88
|
+
const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
|
|
89
|
+
const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
|
|
50
90
|
|
|
51
91
|
return {
|
|
52
92
|
...provider,
|
|
93
|
+
adapter: registryEntry.adapter,
|
|
94
|
+
baseUrl: registryEntry.baseUrl,
|
|
95
|
+
authMode: canonicalAuthMode,
|
|
53
96
|
apiKey: resolveEnvValue(provider.apiKey),
|
|
97
|
+
...(provider.contextWindow === undefined && registryEntry.contextWindow !== undefined ? { contextWindow: registryEntry.contextWindow } : {}),
|
|
98
|
+
...(provider.reasoningEfforts === undefined && registryEntry.reasoningEfforts !== undefined ? { reasoningEfforts: registryEntry.reasoningEfforts } : {}),
|
|
99
|
+
...(provider.escapeBuiltinToolNames === undefined && registryEntry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: registryEntry.escapeBuiltinToolNames } : {}),
|
|
100
|
+
...(modelContextWindows ? { modelContextWindows } : {}),
|
|
101
|
+
...(modelInputModalities ? { modelInputModalities } : {}),
|
|
102
|
+
...(modelReasoningEfforts ? { modelReasoningEfforts } : {}),
|
|
54
103
|
...(reasoningEffortMap ? { reasoningEffortMap } : {}),
|
|
55
104
|
...(modelReasoningEffortMap ? { modelReasoningEffortMap } : {}),
|
|
105
|
+
...(noVisionModels ? { noVisionModels } : {}),
|
|
106
|
+
...(noReasoningModels ? { noReasoningModels } : {}),
|
|
107
|
+
...(noTemperatureModels ? { noTemperatureModels } : {}),
|
|
108
|
+
...(noTopPModels ? { noTopPModels } : {}),
|
|
109
|
+
...(noPenaltyModels ? { noPenaltyModels } : {}),
|
|
110
|
+
...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
|
|
111
|
+
...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
|
|
56
112
|
};
|
|
57
113
|
}
|
|
58
114
|
|
|
@@ -64,8 +120,8 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
|
64
120
|
const slash = modelId.indexOf("/");
|
|
65
121
|
if (slash > 0) {
|
|
66
122
|
const provName = modelId.slice(0, slash);
|
|
67
|
-
|
|
68
|
-
|
|
123
|
+
if (hasOwnProvider(config.providers, provName)) {
|
|
124
|
+
const prov = config.providers[provName];
|
|
69
125
|
return {
|
|
70
126
|
providerName: provName,
|
|
71
127
|
provider: routedProviderConfig(provName, prov),
|
|
@@ -110,8 +166,8 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
|
110
166
|
}
|
|
111
167
|
}
|
|
112
168
|
|
|
113
|
-
|
|
114
|
-
|
|
169
|
+
if (hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
170
|
+
const defaultProv = config.providers[config.defaultProvider];
|
|
115
171
|
return {
|
|
116
172
|
providerName: config.defaultProvider,
|
|
117
173
|
provider: routedProviderConfig(config.defaultProvider, defaultProv),
|