@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.
Files changed (55) hide show
  1. package/README.ko.md +37 -7
  2. package/README.md +52 -11
  3. package/README.zh-CN.md +36 -7
  4. package/bin/ocx.mjs +5 -3
  5. package/gui/dist/assets/index-BzhyTAco.js +40 -0
  6. package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/opencode.svg +1 -1
  9. package/package.json +5 -2
  10. package/src/adapters/anthropic-image-normalize.ts +70 -29
  11. package/src/adapters/cursor/transport-retry.ts +20 -1
  12. package/src/adapters/run-turn-queue.ts +40 -0
  13. package/src/codex/auth-api.ts +10 -1
  14. package/src/codex/auth-context.ts +33 -7
  15. package/src/codex/catalog.ts +357 -23
  16. package/src/codex/routing.ts +10 -4
  17. package/src/combos/failover.ts +102 -0
  18. package/src/combos/index.ts +37 -0
  19. package/src/combos/request.ts +31 -0
  20. package/src/combos/resolve.ts +171 -0
  21. package/src/combos/types.ts +203 -0
  22. package/src/config.ts +280 -11
  23. package/src/lib/errors.ts +86 -24
  24. package/src/lib/upstream-retry.ts +8 -4
  25. package/src/oauth/index.ts +7 -1
  26. package/src/oauth/key-providers.ts +2 -32
  27. package/src/oauth/login-cli.ts +4 -3
  28. package/src/oauth/token-guardian.ts +38 -3
  29. package/src/providers/derive.ts +27 -2
  30. package/src/providers/kiro-models.ts +8 -3
  31. package/src/providers/label.ts +3 -1
  32. package/src/providers/openai-sidecar.ts +94 -0
  33. package/src/providers/openai-tier-startup.ts +27 -0
  34. package/src/providers/openai-tiers.ts +283 -0
  35. package/src/providers/openai-virtual-models.ts +82 -0
  36. package/src/providers/quota.ts +344 -24
  37. package/src/providers/registry.ts +112 -20
  38. package/src/reasoning-effort.ts +12 -11
  39. package/src/router.ts +80 -36
  40. package/src/server/auth-cors.ts +85 -9
  41. package/src/server/images.ts +31 -75
  42. package/src/server/index.ts +45 -86
  43. package/src/server/management-api.ts +273 -21
  44. package/src/server/request-log.ts +221 -20
  45. package/src/server/responses.ts +594 -75
  46. package/src/server/search.ts +22 -37
  47. package/src/types.ts +49 -1
  48. package/src/update/index.ts +50 -6
  49. package/src/update/job.ts +21 -4
  50. package/src/usage/log.ts +124 -1
  51. package/src/usage/summary.ts +147 -56
  52. package/src/vision/index.ts +20 -19
  53. package/src/web-search/index.ts +15 -17
  54. package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
  55. 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 function atomicWriteFile(path: string, content: string): void {
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
- writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o600 });
48
- renameAtomicFile(tmp, path);
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 paths = configIssuePaths(error);
321
- return paths.length > 0 ? `schema_invalid: ${paths.join(", ")}` : "schema_invalid";
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.includes("authentication failed") ||
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
- if (
115
- lower.includes("unauthenticated") ||
116
- lower.includes("unauthorized") ||
117
- lower.includes("permission_denied") ||
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 === 503 || httpStatus === 504
160
- ? "server_error"
161
- : httpStatus === 400
162
- ? "invalid_request_error"
163
- : "upstream_error";
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: () => Promise<Response>,
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: () => Promise<Response>,
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
  }
@@ -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
 
@@ -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 (OAUTH_PROVIDERS[name]) return handleOAuthLogin(name);
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: ${Object.keys(OAUTH_PROVIDERS).join(", ")}\n` +
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) } : {}),