@bitkyc08/opencodex 2.7.23 → 2.7.24
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 +37 -7
- package/README.md +52 -11
- package/README.zh-CN.md +36 -7
- package/bin/ocx.mjs +5 -3
- package/gui/dist/assets/index-BzhyTAco.js +40 -0
- package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opencode.svg +1 -1
- package/package.json +5 -2
- package/src/adapters/anthropic-image-normalize.ts +70 -29
- package/src/adapters/cursor/transport-retry.ts +20 -1
- package/src/adapters/run-turn-queue.ts +40 -0
- package/src/codex/auth-api.ts +10 -1
- package/src/codex/auth-context.ts +33 -7
- package/src/codex/catalog.ts +357 -23
- package/src/codex/routing.ts +10 -4
- package/src/combos/failover.ts +102 -0
- package/src/combos/index.ts +37 -0
- package/src/combos/request.ts +31 -0
- package/src/combos/resolve.ts +171 -0
- package/src/combos/types.ts +203 -0
- package/src/config.ts +280 -11
- package/src/lib/errors.ts +86 -24
- package/src/lib/upstream-retry.ts +8 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/key-providers.ts +2 -32
- package/src/oauth/login-cli.ts +4 -3
- package/src/oauth/token-guardian.ts +38 -3
- package/src/providers/derive.ts +27 -2
- package/src/providers/kiro-models.ts +8 -3
- package/src/providers/label.ts +3 -1
- package/src/providers/openai-sidecar.ts +94 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +283 -0
- package/src/providers/openai-virtual-models.ts +82 -0
- package/src/providers/quota.ts +344 -24
- package/src/providers/registry.ts +112 -20
- package/src/reasoning-effort.ts +12 -11
- package/src/router.ts +80 -36
- package/src/server/auth-cors.ts +85 -9
- package/src/server/images.ts +31 -75
- package/src/server/index.ts +45 -86
- package/src/server/management-api.ts +273 -21
- package/src/server/request-log.ts +221 -20
- package/src/server/responses.ts +594 -75
- package/src/server/search.ts +22 -37
- package/src/types.ts +49 -1
- package/src/update/index.ts +50 -6
- package/src/update/job.ts +21 -4
- package/src/usage/log.ts +124 -1
- package/src/usage/summary.ts +147 -56
- package/src/vision/index.ts +20 -19
- package/src/web-search/index.ts +15 -17
- package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
- package/gui/dist/assets/index-DQjt6Hly.js +0 -40
package/src/config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, truncateSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
|
+
import { comboConfigIssues } from "./combos/types";
|
|
6
7
|
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
7
8
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
8
9
|
import type { OcxConfig } from "./types";
|
|
@@ -42,10 +43,213 @@ export function renameAtomicFile(
|
|
|
42
43
|
* Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
|
|
43
44
|
* proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
|
|
44
45
|
*/
|
|
45
|
-
export
|
|
46
|
+
export interface AtomicWriteIO {
|
|
47
|
+
write: (path: string, content: string) => void;
|
|
48
|
+
harden: (path: string) => void;
|
|
49
|
+
rename: (source: string, destination: string) => void;
|
|
50
|
+
truncate: (path: string) => void;
|
|
51
|
+
unlink: (path: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class AtomicWriteResidualTempError extends Error {
|
|
55
|
+
constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) {
|
|
56
|
+
super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options);
|
|
57
|
+
this.name = "AtomicWriteResidualTempError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class AtomicWriteSecretResidualError extends Error {
|
|
62
|
+
constructor(readonly tempPath: string, options?: ErrorOptions) {
|
|
63
|
+
super("Atomic config write could not scrub or remove a secret-bearing temporary file", options);
|
|
64
|
+
this.name = "AtomicWriteSecretResidualError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isMissingPathError(error: unknown): boolean {
|
|
69
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
|
|
73
|
+
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
74
|
+
harden: target => {
|
|
75
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
76
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: true });
|
|
77
|
+
},
|
|
78
|
+
rename: renameAtomicFile,
|
|
79
|
+
truncate: target => truncateSync(target, 0),
|
|
80
|
+
unlink: unlinkSync,
|
|
81
|
+
}): void {
|
|
46
82
|
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
47
|
-
|
|
48
|
-
|
|
83
|
+
let hardened = false;
|
|
84
|
+
try {
|
|
85
|
+
io.write(tmp, content);
|
|
86
|
+
io.harden(tmp);
|
|
87
|
+
hardened = true;
|
|
88
|
+
io.rename(tmp, path);
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
let scrubbed = false;
|
|
91
|
+
try {
|
|
92
|
+
io.truncate(tmp);
|
|
93
|
+
scrubbed = true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
96
|
+
else {
|
|
97
|
+
try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
let removed = false;
|
|
101
|
+
try {
|
|
102
|
+
io.unlink(tmp);
|
|
103
|
+
removed = true;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (isMissingPathError(error)) removed = true;
|
|
106
|
+
else {
|
|
107
|
+
try { io.unlink(tmp); removed = true; }
|
|
108
|
+
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
|
|
112
|
+
if (!removed && !hardened) {
|
|
113
|
+
try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
114
|
+
}
|
|
115
|
+
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
116
|
+
throw cause;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class OpenAiTierBackupCleanupError extends Error {
|
|
121
|
+
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class OpenAiTierBackupRollbackError extends Error {
|
|
125
|
+
constructor() { super("OpenAI tier backup rollback failed"); this.name = "OpenAiTierBackupRollbackError"; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class OpenAiTierBackupCollisionError extends Error {
|
|
129
|
+
constructor() { super("Existing OpenAI tier backup differs from the current config"); this.name = "OpenAiTierBackupCollisionError"; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class OpenAiTierBackupSecretResidualError extends Error {
|
|
133
|
+
constructor(readonly tempPath: string, options?: ErrorOptions) {
|
|
134
|
+
super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options);
|
|
135
|
+
this.name = "OpenAiTierBackupSecretResidualError";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface OpenAiTierBackupIO {
|
|
140
|
+
exists(path: string): boolean;
|
|
141
|
+
read(path: string): Uint8Array;
|
|
142
|
+
createExclusive(path: string): void;
|
|
143
|
+
write(path: string, bytes: Uint8Array): void;
|
|
144
|
+
harden(path: string): void;
|
|
145
|
+
publishNoReplace(temp: string, backup: string): void;
|
|
146
|
+
truncate(path: string): void;
|
|
147
|
+
unlink(path: string): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
151
|
+
return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
155
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function backupConfigBeforeOpenAiTierMigration(
|
|
159
|
+
configPath = getConfigPath(),
|
|
160
|
+
io: OpenAiTierBackupIO = {
|
|
161
|
+
exists: existsSync,
|
|
162
|
+
read: target => readFileSync(target),
|
|
163
|
+
createExclusive: target => { writeFileSync(target, new Uint8Array(), { flag: "wx", mode: 0o600 }); },
|
|
164
|
+
write: (target, bytes) => writeFileSync(target, bytes),
|
|
165
|
+
harden: target => {
|
|
166
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
167
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: true });
|
|
168
|
+
},
|
|
169
|
+
publishNoReplace: (temp, backup) => linkSync(temp, backup),
|
|
170
|
+
truncate: target => truncateSync(target, 0),
|
|
171
|
+
unlink: unlinkSync,
|
|
172
|
+
},
|
|
173
|
+
): "absent" | "created" | "reused" {
|
|
174
|
+
const source = configPath;
|
|
175
|
+
if (!io.exists(source)) return "absent";
|
|
176
|
+
const original = io.read(source);
|
|
177
|
+
// v2 snapshot path. The historical `.pre-openai-tiers-v1.bak` is read only by restore
|
|
178
|
+
// docs/fixtures and is never reused or overwritten as the v2 snapshot.
|
|
179
|
+
const backup = `${source}.pre-openai-tiers-v2.bak`;
|
|
180
|
+
if (io.exists(backup)) {
|
|
181
|
+
if (!sameBytes(original, io.read(backup))) throw new OpenAiTierBackupCollisionError();
|
|
182
|
+
return "reused";
|
|
183
|
+
}
|
|
184
|
+
const temp = `${backup}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
185
|
+
let published = false;
|
|
186
|
+
let cleanupAttempted = false;
|
|
187
|
+
|
|
188
|
+
const scrubUnpublishedTemp = (): void => {
|
|
189
|
+
cleanupAttempted = true;
|
|
190
|
+
if (!io.exists(temp)) return;
|
|
191
|
+
let scrubbed = false;
|
|
192
|
+
try {
|
|
193
|
+
io.truncate(temp);
|
|
194
|
+
scrubbed = true;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
197
|
+
else {
|
|
198
|
+
try { io.write(temp, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
let removed = false;
|
|
202
|
+
try {
|
|
203
|
+
io.unlink(temp);
|
|
204
|
+
removed = true;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (isMissingPathError(error) || !io.exists(temp)) removed = true;
|
|
207
|
+
else {
|
|
208
|
+
try { io.unlink(temp); removed = true; }
|
|
209
|
+
catch (retryError) {
|
|
210
|
+
if (isMissingPathError(retryError) || !io.exists(temp)) removed = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
|
|
215
|
+
if (!removed) throw new OpenAiTierBackupCleanupError();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
io.createExclusive(temp);
|
|
220
|
+
io.write(temp, original);
|
|
221
|
+
io.harden(temp);
|
|
222
|
+
try {
|
|
223
|
+
io.publishNoReplace(temp, backup);
|
|
224
|
+
} catch (cause) {
|
|
225
|
+
if (!isAlreadyExistsError(cause)) throw cause;
|
|
226
|
+
const winner = io.read(backup);
|
|
227
|
+
if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError();
|
|
228
|
+
scrubUnpublishedTemp();
|
|
229
|
+
return "reused";
|
|
230
|
+
}
|
|
231
|
+
published = true;
|
|
232
|
+
try {
|
|
233
|
+
io.unlink(temp);
|
|
234
|
+
} catch {
|
|
235
|
+
try {
|
|
236
|
+
io.unlink(temp);
|
|
237
|
+
} catch {
|
|
238
|
+
// temp and backup are hard links to the same inode. Roll back the backup
|
|
239
|
+
// link before any truncation so the downgrade snapshot is never zeroed.
|
|
240
|
+
try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); }
|
|
241
|
+
published = false;
|
|
242
|
+
scrubUnpublishedTemp();
|
|
243
|
+
throw new OpenAiTierBackupCleanupError();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return "created";
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
if (!published && !cleanupAttempted) {
|
|
249
|
+
scrubUnpublishedTemp();
|
|
250
|
+
}
|
|
251
|
+
throw cause;
|
|
252
|
+
}
|
|
49
253
|
}
|
|
50
254
|
|
|
51
255
|
/**
|
|
@@ -87,6 +291,7 @@ const providerConfigSchema = z.object({
|
|
|
87
291
|
adapter: z.string().min(1),
|
|
88
292
|
baseUrl: z.string().min(1),
|
|
89
293
|
allowPrivateNetwork: z.boolean().optional(),
|
|
294
|
+
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
90
295
|
}).passthrough();
|
|
91
296
|
|
|
92
297
|
const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
|
|
@@ -138,10 +343,25 @@ export function providerHeadersConfigError(headers: unknown): string | null {
|
|
|
138
343
|
return null;
|
|
139
344
|
}
|
|
140
345
|
|
|
346
|
+
export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
|
|
347
|
+
if (value === undefined) return null;
|
|
348
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
349
|
+
const prototype = Object.getPrototypeOf(value);
|
|
350
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
351
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
352
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
353
|
+
if (typeof entry !== "number" || !Number.isFinite(entry) || !Number.isInteger(entry) || entry <= 0) {
|
|
354
|
+
return `${field}.${key} must be a positive finite integer`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
141
360
|
const configSchema = z.object({
|
|
142
361
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
143
362
|
providers: z.record(z.string(), providerConfigSchema),
|
|
144
363
|
defaultProvider: z.string().min(1).default("openai"),
|
|
364
|
+
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
145
365
|
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
146
366
|
contextCapValue: z.number().int().positive().optional(),
|
|
147
367
|
}).passthrough().superRefine((config, ctx) => {
|
|
@@ -154,6 +374,13 @@ const configSchema = z.object({
|
|
|
154
374
|
});
|
|
155
375
|
}
|
|
156
376
|
const provider = config.providers[name];
|
|
377
|
+
if (Object.hasOwn(provider, "virtualModels")) {
|
|
378
|
+
ctx.addIssue({
|
|
379
|
+
code: "custom",
|
|
380
|
+
path: ["providers", name, "virtualModels"],
|
|
381
|
+
message: "virtualModels is registry-only and must not be persisted",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
157
384
|
const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
|
|
158
385
|
if (baseUrlError) {
|
|
159
386
|
ctx.addIssue({
|
|
@@ -179,6 +406,33 @@ const configSchema = z.object({
|
|
|
179
406
|
message: headersError,
|
|
180
407
|
});
|
|
181
408
|
}
|
|
409
|
+
const maxInputError = positiveIntegerRecordConfigError(
|
|
410
|
+
(provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens,
|
|
411
|
+
"modelMaxInputTokens",
|
|
412
|
+
);
|
|
413
|
+
if (maxInputError) {
|
|
414
|
+
ctx.addIssue({
|
|
415
|
+
code: "custom",
|
|
416
|
+
path: ["providers", name, "modelMaxInputTokens"],
|
|
417
|
+
message: maxInputError,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
|
|
421
|
+
// Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
|
|
422
|
+
// Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
|
|
423
|
+
const canonicalOpenAiShape = name === "openai"
|
|
424
|
+
&& provider.adapter === "openai-responses"
|
|
425
|
+
&& (provider as { authMode?: unknown }).authMode === "forward"
|
|
426
|
+
&& typeof provider.baseUrl === "string"
|
|
427
|
+
&& provider.baseUrl.replace(/\/+$/, "") === "https://chatgpt.com/backend-api/codex";
|
|
428
|
+
if (!canonicalOpenAiShape) {
|
|
429
|
+
ctx.addIssue({
|
|
430
|
+
code: "custom",
|
|
431
|
+
path: ["providers", name, "codexAccountMode"],
|
|
432
|
+
message: "codexAccountMode is valid only on the canonical built-in openai provider",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
182
436
|
}
|
|
183
437
|
if (!hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
184
438
|
ctx.addIssue({
|
|
@@ -187,6 +441,22 @@ const configSchema = z.object({
|
|
|
187
441
|
message: "defaultProvider must exist in providers",
|
|
188
442
|
});
|
|
189
443
|
}
|
|
444
|
+
const combos = (config as { combos?: unknown }).combos;
|
|
445
|
+
if (combos !== undefined) {
|
|
446
|
+
if (!combos || typeof combos !== "object" || Array.isArray(combos)) {
|
|
447
|
+
ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" });
|
|
448
|
+
} else {
|
|
449
|
+
for (const [id, raw] of Object.entries(combos as Record<string, unknown>)) {
|
|
450
|
+
for (const issue of comboConfigIssues(id, raw, config.providers)) {
|
|
451
|
+
ctx.addIssue({
|
|
452
|
+
code: "custom",
|
|
453
|
+
path: ["combos", id, ...issue.path],
|
|
454
|
+
message: issue.message,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
190
460
|
});
|
|
191
461
|
|
|
192
462
|
/**
|
|
@@ -311,14 +581,12 @@ function mergeConfigDefaults(parsed: unknown): unknown {
|
|
|
311
581
|
return merged;
|
|
312
582
|
}
|
|
313
583
|
|
|
314
|
-
function configIssuePaths(error: z.ZodError): string[] {
|
|
315
|
-
const paths = error.issues.map(issue => issue.path.join(".") || "config");
|
|
316
|
-
return [...new Set(paths)].sort();
|
|
317
|
-
}
|
|
318
|
-
|
|
319
584
|
function schemaDiagnosticsError(error: z.ZodError): string {
|
|
320
|
-
const
|
|
321
|
-
|
|
585
|
+
const details = error.issues.map(issue => {
|
|
586
|
+
const path = issue.path.join(".") || "config";
|
|
587
|
+
return `${path}: ${issue.message}`;
|
|
588
|
+
});
|
|
589
|
+
return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid";
|
|
322
590
|
}
|
|
323
591
|
|
|
324
592
|
export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
@@ -378,6 +646,7 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
378
646
|
adapter: "openai-responses",
|
|
379
647
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
380
648
|
authMode: "forward",
|
|
649
|
+
codexAccountMode: "pool",
|
|
381
650
|
},
|
|
382
651
|
},
|
|
383
652
|
defaultProvider: "openai",
|
package/src/lib/errors.ts
CHANGED
|
@@ -4,6 +4,59 @@ export interface OcxErrorPayload {
|
|
|
4
4
|
code: string | null;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
function isSubscriptionGateMessage(text: string): boolean {
|
|
8
|
+
return (
|
|
9
|
+
text.includes("requires a subscription") ||
|
|
10
|
+
text.includes("requires subscription") ||
|
|
11
|
+
text.includes("subscription required") ||
|
|
12
|
+
text.includes("upgrade for access") ||
|
|
13
|
+
text.includes("upgrade to pro") ||
|
|
14
|
+
text.includes("pro subscription") ||
|
|
15
|
+
text.includes("ollama.com/upgrade") ||
|
|
16
|
+
(text.includes("upgrade") && text.includes("subscription"))
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isAuthenticationMessage(text: string): boolean {
|
|
21
|
+
const accessDeniedWithCredentialCue = (
|
|
22
|
+
text.includes("access denied") ||
|
|
23
|
+
text.includes("accessdeniedexception")
|
|
24
|
+
) && (
|
|
25
|
+
text.includes("authentication") ||
|
|
26
|
+
text.includes("credential") ||
|
|
27
|
+
text.includes("api key") ||
|
|
28
|
+
text.includes("token") ||
|
|
29
|
+
text.includes("signature")
|
|
30
|
+
);
|
|
31
|
+
return (
|
|
32
|
+
text.includes("authentication failed") ||
|
|
33
|
+
text.includes("authentication") ||
|
|
34
|
+
text.includes("invalid_api_key") ||
|
|
35
|
+
text.includes("invalid api key") ||
|
|
36
|
+
text.includes("invalid token") ||
|
|
37
|
+
text.includes("unauthorizedexception") ||
|
|
38
|
+
text.includes("unrecognizedclientexception") ||
|
|
39
|
+
text.includes("unrecognizedclient") ||
|
|
40
|
+
text.includes("expired token") ||
|
|
41
|
+
text.includes("expiredtoken") ||
|
|
42
|
+
text.includes("unauthenticated") ||
|
|
43
|
+
text.includes("unauthorized") ||
|
|
44
|
+
accessDeniedWithCredentialCue
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPermissionMessage(text: string): boolean {
|
|
49
|
+
return (
|
|
50
|
+
text.includes("permission_denied") ||
|
|
51
|
+
text.includes("permission denied") ||
|
|
52
|
+
text.includes("forbidden") ||
|
|
53
|
+
text.includes("access denied") ||
|
|
54
|
+
text.includes("accessdeniedexception") ||
|
|
55
|
+
text.includes("not allowed to use") ||
|
|
56
|
+
text.includes("model access")
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
7
60
|
export function classifyError(status: number, type: string, message: string): OcxErrorPayload {
|
|
8
61
|
const text = message.toLowerCase();
|
|
9
62
|
if (
|
|
@@ -40,20 +93,29 @@ export function classifyError(status: number, type: string, message: string): Oc
|
|
|
40
93
|
if (type === "origin_rejected") {
|
|
41
94
|
return { message, type: "invalid_request_error", code: "origin_rejected" };
|
|
42
95
|
}
|
|
96
|
+
// HTTP 401 and explicit auth failures are authoritative even when provider text
|
|
97
|
+
// also advertises an upgrade or subscription.
|
|
43
98
|
if (
|
|
44
99
|
status === 401 ||
|
|
45
|
-
status === 403 ||
|
|
46
100
|
type === "authentication_error" ||
|
|
47
|
-
text
|
|
48
|
-
text.includes("access denied") ||
|
|
49
|
-
text.includes("unauthorizedexception") ||
|
|
50
|
-
text.includes("unrecognizedclientexception") ||
|
|
51
|
-
text.includes("unrecognizedclient") ||
|
|
52
|
-
text.includes("expired token") ||
|
|
53
|
-
text.includes("expiredtoken")
|
|
101
|
+
isAuthenticationMessage(text)
|
|
54
102
|
) {
|
|
55
103
|
return { message, type: "authentication_error", code: "invalid_api_key" };
|
|
56
104
|
}
|
|
105
|
+
// Subscription labels are valid only in a known permission context.
|
|
106
|
+
if (
|
|
107
|
+
(status === 403 || type === "permission_error") &&
|
|
108
|
+
isSubscriptionGateMessage(text)
|
|
109
|
+
) {
|
|
110
|
+
return { message, type: "permission_error", code: "subscription_required" };
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
status === 403 ||
|
|
114
|
+
type === "permission_error" ||
|
|
115
|
+
isPermissionMessage(text)
|
|
116
|
+
) {
|
|
117
|
+
return { message, type: "permission_error", code: "permission_denied" };
|
|
118
|
+
}
|
|
57
119
|
if (
|
|
58
120
|
status === 503 ||
|
|
59
121
|
text.includes("overloaded") ||
|
|
@@ -111,17 +173,10 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
|
|
|
111
173
|
lower.includes("too many requests") ||
|
|
112
174
|
lower.includes("throttling")
|
|
113
175
|
) return 429;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
lower.includes("permission denied") ||
|
|
119
|
-
lower.includes("forbidden") ||
|
|
120
|
-
lower.includes("invalid token") ||
|
|
121
|
-
lower.includes("expired token") ||
|
|
122
|
-
lower.includes("authentication") ||
|
|
123
|
-
lower.includes("access denied")
|
|
124
|
-
) return 401;
|
|
176
|
+
// Strong authentication signals win when a message contains mixed auth and
|
|
177
|
+
// subscription/permission wording.
|
|
178
|
+
if (isAuthenticationMessage(lower)) return 401;
|
|
179
|
+
if (isSubscriptionGateMessage(lower) || isPermissionMessage(lower)) return 403;
|
|
125
180
|
if (
|
|
126
181
|
lower.includes("unavailable") ||
|
|
127
182
|
lower.includes("overloaded") ||
|
|
@@ -156,11 +211,13 @@ export function adapterFailureFromMessage(message: string): { httpStatus: number
|
|
|
156
211
|
? "rate_limit_error"
|
|
157
212
|
: httpStatus === 401
|
|
158
213
|
? "authentication_error"
|
|
159
|
-
: httpStatus ===
|
|
160
|
-
? "
|
|
161
|
-
: httpStatus ===
|
|
162
|
-
? "
|
|
163
|
-
:
|
|
214
|
+
: httpStatus === 403
|
|
215
|
+
? "permission_error"
|
|
216
|
+
: httpStatus === 503 || httpStatus === 504
|
|
217
|
+
? "server_error"
|
|
218
|
+
: httpStatus === 400
|
|
219
|
+
? "invalid_request_error"
|
|
220
|
+
: "upstream_error";
|
|
164
221
|
return {
|
|
165
222
|
httpStatus,
|
|
166
223
|
error: classifyError(httpStatus, errorType, finalMessage),
|
|
@@ -176,6 +233,11 @@ export function httpStatusFromTerminalError(error: {
|
|
|
176
233
|
if (!error) return 502;
|
|
177
234
|
if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429;
|
|
178
235
|
if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401;
|
|
236
|
+
if (
|
|
237
|
+
error.type === "permission_error" ||
|
|
238
|
+
error.code === "permission_denied" ||
|
|
239
|
+
error.code === "subscription_required"
|
|
240
|
+
) return 403;
|
|
179
241
|
if (error.type === "insufficient_quota" || error.code === "insufficient_quota") return 429;
|
|
180
242
|
if (error.type === "server_error" && error.code === "server_is_overloaded") return 503;
|
|
181
243
|
if (error.type === "invalid_request_error") return 400;
|
|
@@ -145,21 +145,25 @@ export interface TransientRetryOptions extends ResetRetryOptions {
|
|
|
145
145
|
slowAttemptMs?: number;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
|
|
149
|
+
type ReplayableFetch = (recovery?: UpstreamSendRecovery) => Promise<Response>;
|
|
150
|
+
|
|
148
151
|
/**
|
|
149
152
|
* Run `doFetch`, retrying only connection-reset-shaped rejections (see
|
|
150
153
|
* isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
|
|
151
154
|
* (string body); every retry is logged so persistent resets stay visible.
|
|
152
155
|
*/
|
|
153
156
|
export async function fetchWithResetRetry(
|
|
154
|
-
doFetch:
|
|
157
|
+
doFetch: ReplayableFetch,
|
|
155
158
|
opts: ResetRetryOptions = {},
|
|
159
|
+
firstRecovery?: UpstreamSendRecovery,
|
|
156
160
|
): Promise<Response> {
|
|
157
161
|
const attempts = Math.max(1, opts.attempts ?? RESET_RETRY_MAX_ATTEMPTS);
|
|
158
162
|
let lastError: unknown;
|
|
159
163
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
160
164
|
if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
|
|
161
165
|
try {
|
|
162
|
-
return await doFetch();
|
|
166
|
+
return await doFetch(attempt === 0 ? firstRecovery : "connection-reset");
|
|
163
167
|
} catch (err) {
|
|
164
168
|
if (opts.abortSignal?.aborted || !isConnectionResetError(err) || attempt === attempts - 1) throw err;
|
|
165
169
|
lastError = err;
|
|
@@ -186,7 +190,7 @@ export async function fetchWithResetRetry(
|
|
|
186
190
|
* note `opts.attempts` is shared with the inner reset layer (no caller passes it today).
|
|
187
191
|
*/
|
|
188
192
|
export async function fetchWithTransientRetry(
|
|
189
|
-
doFetch:
|
|
193
|
+
doFetch: ReplayableFetch,
|
|
190
194
|
opts: TransientRetryOptions = {},
|
|
191
195
|
): Promise<Response> {
|
|
192
196
|
const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
|
|
@@ -208,7 +212,7 @@ export async function fetchWithTransientRetry(
|
|
|
208
212
|
cancelResponseBodyBestEffort(res);
|
|
209
213
|
await sleepWithAbort(delay, opts.abortSignal);
|
|
210
214
|
attemptStart = Date.now();
|
|
211
|
-
res = await fetchWithResetRetry(doFetch, opts);
|
|
215
|
+
res = await fetchWithResetRetry(doFetch, opts, "transient-5xx");
|
|
212
216
|
}
|
|
213
217
|
return res;
|
|
214
218
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -112,6 +112,10 @@ export function isOAuthProvider(name: string): boolean {
|
|
|
112
112
|
return name in OAUTH_PROVIDERS;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
export function isPublicOAuthProvider(name: string): boolean {
|
|
116
|
+
return name !== "chatgpt" && isOAuthProvider(name);
|
|
117
|
+
}
|
|
118
|
+
|
|
115
119
|
function isRefreshPolicy(value: unknown): value is RefreshPolicy {
|
|
116
120
|
return value === "proactive" || value === "lazy-only" || value === "disabled";
|
|
117
121
|
}
|
|
@@ -135,7 +139,7 @@ export function getOAuthCredentialProjectId(provider: string): string | undefine
|
|
|
135
139
|
|
|
136
140
|
/** Provider ids that support real OAuth login (drives the GUI's "Log in with …" buttons). */
|
|
137
141
|
export function listOAuthProviders(): string[] {
|
|
138
|
-
return Object.keys(OAUTH_PROVIDERS);
|
|
142
|
+
return Object.keys(OAUTH_PROVIDERS).filter(isPublicOAuthProvider);
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
export class UnsupportedOAuthProviderError extends Error {
|
|
@@ -399,6 +403,7 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
|
|
|
399
403
|
|
|
400
404
|
/** Add/refresh an OAuth provider's config entry on a config object (does not persist). */
|
|
401
405
|
export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
406
|
+
if (provider === "chatgpt") return;
|
|
402
407
|
const def = OAUTH_PROVIDERS[provider];
|
|
403
408
|
if (!def) return;
|
|
404
409
|
config.providers[provider] = { ...def.providerConfig };
|
|
@@ -411,6 +416,7 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L
|
|
|
411
416
|
const rawCred = await def.login(ctrl, opts);
|
|
412
417
|
const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
|
|
413
418
|
await saveCredential(provider, cred);
|
|
419
|
+
if (provider === "chatgpt") return cred;
|
|
414
420
|
const config = loadConfig();
|
|
415
421
|
upsertOAuthProvider(config, provider);
|
|
416
422
|
saveConfig(config);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OcxProviderConfig } from "../types";
|
|
2
|
-
import { deriveKeyLoginMap, enrichProviderFromRegistry } from "../providers/derive";
|
|
2
|
+
import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
|
|
@@ -7,37 +7,7 @@ import { deriveKeyLoginMap, enrichProviderFromRegistry } from "../providers/deri
|
|
|
7
7
|
* Most use the OpenAI-compatible chat API (`openai-chat` adapter, `Authorization: Bearer <key>`); a
|
|
8
8
|
* few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (`x-api-key`).
|
|
9
9
|
*/
|
|
10
|
-
export interface KeyLoginProvider {
|
|
11
|
-
label: string;
|
|
12
|
-
baseUrl: string;
|
|
13
|
-
adapter: string;
|
|
14
|
-
/** Where the user creates/copies the API key. */
|
|
15
|
-
dashboardUrl: string;
|
|
16
|
-
models?: string[];
|
|
17
|
-
liveModels?: boolean;
|
|
18
|
-
defaultModel?: string;
|
|
19
|
-
contextWindow?: number;
|
|
20
|
-
modelContextWindows?: Record<string, number>;
|
|
21
|
-
modelInputModalities?: Record<string, string[]>;
|
|
22
|
-
/**
|
|
23
|
-
* Model ids that do NOT accept image input (the vision sidecar describes images for them) / do NOT
|
|
24
|
-
* accept a reasoning param. Copied into the created provider config by `enrichProviderFromCatalog`,
|
|
25
|
-
* so the classification actually gates the sidecars (matching is tolerant of an Ollama ":size" tag).
|
|
26
|
-
*/
|
|
27
|
-
reasoningEfforts?: string[];
|
|
28
|
-
modelReasoningEfforts?: Record<string, string[]>;
|
|
29
|
-
reasoningEffortMap?: Record<string, string>;
|
|
30
|
-
modelReasoningEffortMap?: Record<string, Record<string, string>>;
|
|
31
|
-
noVisionModels?: string[];
|
|
32
|
-
noReasoningModels?: string[];
|
|
33
|
-
noTemperatureModels?: string[];
|
|
34
|
-
noTopPModels?: string[];
|
|
35
|
-
noPenaltyModels?: string[];
|
|
36
|
-
autoToolChoiceOnlyModels?: string[];
|
|
37
|
-
preserveReasoningContentModels?: string[];
|
|
38
|
-
escapeBuiltinToolNames?: boolean;
|
|
39
|
-
googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
|
|
40
|
-
}
|
|
10
|
+
export interface KeyLoginProvider extends DerivedKeyLoginProvider {}
|
|
41
11
|
|
|
42
12
|
export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLoginMap();
|
|
43
13
|
|
package/src/oauth/login-cli.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as readline from "node:readline";
|
|
|
2
2
|
import { openUrl } from "../lib/open-url";
|
|
3
3
|
import { loadConfig, saveConfig } from "../config";
|
|
4
4
|
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
5
|
-
import { OAUTH_PROVIDERS, runLogin } from "./index";
|
|
5
|
+
import { isPublicOAuthProvider, listOAuthProviders, OAUTH_PROVIDERS, runLogin } from "./index";
|
|
6
6
|
import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
|
|
7
7
|
import type { OcxProviderConfig } from "../types";
|
|
8
8
|
|
|
@@ -32,11 +32,11 @@ async function notifyRunningProxy(name: string, provider: unknown): Promise<void
|
|
|
32
32
|
|
|
33
33
|
export async function handleLogin(provider?: string): Promise<void> {
|
|
34
34
|
const name = (provider ?? "").trim().toLowerCase();
|
|
35
|
-
if (
|
|
35
|
+
if (isPublicOAuthProvider(name)) return handleOAuthLogin(name);
|
|
36
36
|
if (isKeyLoginProvider(name)) return handleKeyLogin(name);
|
|
37
37
|
console.error(
|
|
38
38
|
`Usage: ocx login <provider>\n` +
|
|
39
|
-
` OAuth login: ${
|
|
39
|
+
` OAuth login: ${listOAuthProviders().join(", ")}\n` +
|
|
40
40
|
` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`,
|
|
41
41
|
);
|
|
42
42
|
process.exit(1);
|
|
@@ -71,6 +71,7 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s
|
|
|
71
71
|
...(def.models ? { models: [...def.models] } : {}),
|
|
72
72
|
...(def.contextWindow !== undefined ? { contextWindow: def.contextWindow } : {}),
|
|
73
73
|
...(def.modelContextWindows ? { modelContextWindows: { ...def.modelContextWindows } } : {}),
|
|
74
|
+
...(def.modelMaxInputTokens ? { modelMaxInputTokens: { ...def.modelMaxInputTokens } } : {}),
|
|
74
75
|
...(def.modelInputModalities ? { modelInputModalities: cloneRecordOfArrays(def.modelInputModalities) } : {}),
|
|
75
76
|
...(def.reasoningEfforts ? { reasoningEfforts: [...def.reasoningEfforts] } : {}),
|
|
76
77
|
...(def.modelReasoningEfforts ? { modelReasoningEfforts: cloneRecordOfArrays(def.modelReasoningEfforts) } : {}),
|