@bitkyc08/opencodex 2.7.31 → 2.7.33

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 (46) hide show
  1. package/README.ja.md +438 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +2 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +18 -1
  7. package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
  8. package/gui/dist/assets/index-d63HMU0x.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/anthropic.ts +17 -2
  12. package/src/adapters/google-tool-schema.ts +4 -0
  13. package/src/adapters/google.ts +10 -2
  14. package/src/adapters/openai-chat.ts +36 -1
  15. package/src/adapters/openai-responses.ts +2 -1
  16. package/src/bridge.ts +12 -4
  17. package/src/cli/account-api.ts +4 -2
  18. package/src/cli/account-extended.ts +34 -0
  19. package/src/cli/account.ts +3 -1
  20. package/src/cli/claude.ts +6 -1
  21. package/src/cli/help.ts +25 -4
  22. package/src/cli/init.ts +38 -3
  23. package/src/cli/models.ts +206 -7
  24. package/src/codex/auth-api.ts +27 -3
  25. package/src/codex/catalog.ts +72 -2
  26. package/src/config.ts +60 -3
  27. package/src/oauth/github-copilot.ts +1 -0
  28. package/src/oauth/index.ts +5 -4
  29. package/src/oauth/kiro.ts +12 -1
  30. package/src/oauth/store.ts +11 -0
  31. package/src/oauth/types.ts +3 -1
  32. package/src/providers/antigravity-models.ts +103 -5
  33. package/src/providers/api-keys.ts +12 -0
  34. package/src/providers/openrouter-routing.ts +102 -0
  35. package/src/providers/registry.ts +7 -5
  36. package/src/router.ts +2 -1
  37. package/src/server/auth-cors.ts +5 -0
  38. package/src/server/management-api.ts +143 -6
  39. package/src/server/responses.ts +16 -4
  40. package/src/types.ts +42 -1
  41. package/src/update/index.ts +19 -2
  42. package/src/update/job.ts +13 -3
  43. package/src/usage/expected-prices.ts +10 -0
  44. package/src/usage/summary.ts +43 -0
  45. package/gui/dist/assets/index-BPa0R6EN.js +0 -46
  46. package/gui/dist/assets/index-BY7KvJRB.css +0 -1
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BPa0R6EN.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BY7KvJRB.css">
19
+ <script type="module" crossorigin src="/assets/index-d63HMU0x.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-D6Fcl4yM.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.31",
3
+ "version": "2.7.33",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -629,9 +629,16 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
629
629
  if (typeof parsed.options.reasoning === "string" && parsed.options.reasoning !== "none") {
630
630
  if (usesAdaptiveThinking(parsed.modelId)) {
631
631
  // Adaptive-thinking models replace the token budget with an effort knob and reject
632
- // `thinking.type: "enabled"` outright no budget/max_tokens re-sizing needed.
632
+ // `thinking.type: "enabled"` outright. `max_tokens` still caps thinking plus visible
633
+ // output, so high effort needs the same total-token headroom as budget thinking or a
634
+ // default 8192-token request can spend everything on thought and return empty text.
633
635
  body.thinking = { type: "adaptive" };
634
636
  body.output_config = { effort: adaptiveEffort(parsed.options.reasoning) };
637
+ const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
638
+ body.max_tokens = Math.min(
639
+ REASONING_MAX_TOKENS_CEILING,
640
+ Math.max(maxOut, reasoningBudget(adaptiveEffort(parsed.options.reasoning)) + OUTPUT_HEADROOM),
641
+ );
635
642
  } else {
636
643
  // Anthropic requires max_tokens > thinking.budget_tokens (max_tokens caps thinking +
637
644
  // visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which
@@ -713,12 +720,17 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
713
720
  let currentToolCallId = "";
714
721
  let currentToolCallName = "";
715
722
  let pendingUsage: Record<string, number> | undefined;
723
+ let pendingStopReason: string | undefined;
716
724
  let emittedDone = false;
717
725
 
718
726
  const emitDone = function* (): Generator<AdapterEvent> {
719
727
  if (emittedDone) return;
720
728
  emittedDone = true;
721
- yield { type: "done", usage: usageFromAnthropic(pendingUsage) };
729
+ yield {
730
+ type: "done",
731
+ usage: usageFromAnthropic(pendingUsage),
732
+ ...(pendingStopReason ? { stopReason: pendingStopReason } : {}),
733
+ };
722
734
  };
723
735
 
724
736
  try {
@@ -796,6 +808,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
796
808
  case "message_delta": {
797
809
  const usage = data.usage as Record<string, number> | undefined;
798
810
  pendingUsage = mergeAnthropicUsage(pendingUsage, usage);
811
+ const delta = data.delta as { stop_reason?: unknown } | undefined;
812
+ if (typeof delta?.stop_reason === "string") pendingStopReason = delta.stop_reason;
799
813
  break;
800
814
  }
801
815
  case "message_stop": {
@@ -843,6 +857,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
843
857
  events.push({
844
858
  type: "done",
845
859
  usage: usageFromAnthropic(usage),
860
+ ...(typeof json.stop_reason === "string" ? { stopReason: json.stop_reason } : {}),
846
861
  });
847
862
  return events;
848
863
  },
@@ -73,6 +73,10 @@ function sanitize(node: unknown, defs: Map<string, unknown>, depth: number): unk
73
73
  if (key === "const") { out.enum = [value]; continue; }
74
74
  if (key === "exclusiveMinimum" && typeof value === "number") { out.minimum = value; continue; }
75
75
  if (key === "exclusiveMaximum" && typeof value === "number") { out.maximum = value; continue; }
76
+ if (key === "required" && Array.isArray(value)) {
77
+ out.required = [...new Set(value.filter((item): item is string => typeof item === "string"))];
78
+ continue;
79
+ }
76
80
  if (key === "additionalProperties") {
77
81
  // A boolean additionalProperties is accepted, but a nested schema is only meaningful with
78
82
  // its own sanitize pass.
@@ -21,7 +21,7 @@ import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignat
21
21
  import { sanitizeGeminiToolParameters } from "./google-tool-schema";
22
22
  import { neutralizeIdentity } from "./identity";
23
23
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
24
- import { resolveAntigravityWireModelId } from "../providers/antigravity-models";
24
+ import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
25
25
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
26
26
  import { mapReasoningEffort } from "../reasoning-effort";
27
27
 
@@ -249,9 +249,17 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
249
249
  const project = provider.project;
250
250
  if (!project) throw new Error("Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`).");
251
251
  const sessionId = antigravitySessionId(parsed);
252
- const wireModelId = resolveAntigravityWireModelId(parsed.modelId);
252
+ const mappedEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
253
+ const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(parsed.modelId, mappedEffort);
253
254
  antigravityModel = wireModelId;
254
255
  antigravitySession = sessionId;
256
+ // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig).
257
+ // Suffix/compat IDs return thinkingLevel=undefined — the suffix IS the effort, no contradiction.
258
+ if (thinkingLevel) {
259
+ const gc = (body.generationConfig ?? {}) as Record<string, unknown>;
260
+ gc.thinkingConfig = { thinkingLevel };
261
+ body.generationConfig = gc;
262
+ }
255
263
  // Reasoning continuity: Gemini models re-inject cached thoughtSignatures; Claude-on-Antigravity
256
264
  // sanitizes signatures inline (no cache). Both guard against the upstream 400 on bad signatures.
257
265
  if (Array.isArray((body as { contents?: unknown[] }).contents)) {
@@ -6,6 +6,7 @@ import { redactSecretString } from "../lib/redact";
6
6
  import { contentPartsToText } from "./image";
7
7
  import { neutralizeIdentity } from "./identity";
8
8
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
9
+ import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
9
10
 
10
11
  // Providers may opt into stripping one trailing "[...]" group from the wire model id.
11
12
  // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
@@ -337,6 +338,28 @@ function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
337
338
  }
338
339
  }
339
340
 
341
+ function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
342
+ try {
343
+ return new URL(provider.baseUrl).hostname === "api.kimi.com";
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Kimi requires function.parameters.type to be exactly "object" at the root.
351
+ * Codex tools with oneOf/anyOf schemas omit the root type, causing 400 errors.
352
+ * Add type: "object" at the root while preserving oneOf, $defs, and other schema keys.
353
+ */
354
+ function ensureKimiRootObjectType(parameters: unknown): Record<string, unknown> {
355
+ if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
356
+ return { type: "object", properties: {} };
357
+ }
358
+ const obj = parameters as Record<string, unknown>;
359
+ if (obj.type === "object") return obj;
360
+ return { ...obj, type: "object" };
361
+ }
362
+
340
363
  function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
341
364
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
342
365
  const obj = schema as Record<string, unknown>;
@@ -379,8 +402,13 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
379
402
  : parsed.context.tools;
380
403
  if (tools.length === 0) return undefined;
381
404
  const xaiTarget = isXaiSchemaTarget(provider);
405
+ const kimiTarget = isKimiSchemaTarget(provider);
382
406
  const formatted = tools.flatMap(t => {
383
- const parameters = xaiTarget ? normalizeXaiToolParameters(t.parameters) : t.parameters;
407
+ const parameters = xaiTarget
408
+ ? normalizeXaiToolParameters(t.parameters)
409
+ : kimiTarget
410
+ ? ensureKimiRootObjectType(t.parameters)
411
+ : t.parameters;
384
412
  if (parameters === undefined) return [];
385
413
  return [{
386
414
  type: "function",
@@ -467,6 +495,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
467
495
  messages,
468
496
  stream: parsed.stream,
469
497
  };
498
+ const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
499
+ if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
470
500
  if (tools) body.tools = tools;
471
501
  if (tools && toolChoice !== undefined) {
472
502
  body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)
@@ -502,6 +532,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
502
532
  if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
503
533
  body.frequency_penalty = parsed.options.frequencyPenalty;
504
534
  }
535
+ // prompt_cache_key is an OpenAI-specific chat extension; strict backends (Groq,
536
+ // Cerebras, etc.) reject unknown fields. Only forward when the provider opts in.
537
+ if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
538
+ body.prompt_cache_key = parsed.options.promptCacheKey;
539
+ }
505
540
 
506
541
  if (tools) {
507
542
  // Default-ON for chat-completions providers (user decision 260709): the buffered
@@ -25,7 +25,7 @@ export const FORWARD_HEADERS = [
25
25
  "x-responsesapi-include-timing-metrics",
26
26
  ];
27
27
 
28
- function sanitizeReasoningInputContent(body: unknown): unknown {
28
+ export function sanitizeReasoningInputContent(body: unknown): unknown {
29
29
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
30
30
  const raw = body as Record<string, unknown>;
31
31
  if (!Array.isArray(raw.input)) return body;
@@ -57,6 +57,7 @@ function stripInvalidItemIds(body: unknown): unknown {
57
57
 
58
58
  const validPrefixes: Record<string, string> = {
59
59
  message: "msg_",
60
+ agent_message: "amsg_",
60
61
  reasoning: "rs_",
61
62
  function_call: "fc_",
62
63
  custom_tool_call: "ctc_",
package/src/bridge.ts CHANGED
@@ -634,12 +634,17 @@ export function bridgeToResponsesSSE(
634
634
  finishedItems.push(item as OutputItem);
635
635
  outputIndex++;
636
636
  }
637
- const response = { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(event.usage) };
637
+ const truncated = event.stopReason === "max_tokens";
638
+ const response = {
639
+ ...responseSnapshot(truncated ? "incomplete" : "completed", finishedItems),
640
+ usage: responsesUsage(event.usage),
641
+ ...(truncated ? { incomplete_details: { reason: "max_output_tokens" } } : {}),
642
+ };
638
643
  options?.onCompletedResponse?.(response);
639
- emit("response.completed", {
644
+ emit(truncated ? "response.incomplete" : "response.completed", {
640
645
  response,
641
646
  });
642
- reportTerminal("completed");
647
+ reportTerminal(truncated ? "incomplete" : "completed");
643
648
  terminated = true;
644
649
  break;
645
650
  }
@@ -737,6 +742,7 @@ export function buildResponseJSON(
737
742
  const output: OutputItem[] = [];
738
743
  let usage: OcxUsage | undefined;
739
744
  let errorMessage: string | undefined;
745
+ let stopReason: string | undefined;
740
746
  let compactionText = "";
741
747
 
742
748
  let currentText = "";
@@ -911,6 +917,7 @@ export function buildResponseJSON(
911
917
  break;
912
918
  case "done":
913
919
  usage = e.usage;
920
+ stopReason = e.stopReason;
914
921
  break;
915
922
  }
916
923
  }
@@ -925,9 +932,10 @@ export function buildResponseJSON(
925
932
  return {
926
933
  id: responseId, object: "response",
927
934
  created_at: Math.floor(Date.now() / 1000),
928
- status: errorMessage ? "failed" : "completed",
935
+ status: errorMessage ? "failed" : stopReason === "max_tokens" ? "incomplete" : "completed",
929
936
  model: modelId, output,
930
937
  ...(errorMessage ? { error: { message: errorMessage } } : {}),
938
+ ...(!errorMessage && stopReason === "max_tokens" ? { incomplete_details: { reason: "max_output_tokens" } } : {}),
931
939
  usage: responsesUsage(usage),
932
940
  };
933
941
  }
@@ -143,6 +143,7 @@ export interface ProviderQuotaReportDto {
143
143
 
144
144
  interface CodexAccountDto {
145
145
  id: string;
146
+ alias?: string;
146
147
  email?: string;
147
148
  plan?: string;
148
149
  isMain?: boolean;
@@ -189,7 +190,7 @@ export async function fetchCodexRows(
189
190
  provider: "openai",
190
191
  type: "codex" as const,
191
192
  id: a.id,
192
- label: a.plan ?? a.email,
193
+ label: a.alias ?? a.plan ?? a.email,
193
194
  email: a.email,
194
195
  plan: a.plan,
195
196
  active: a.id === activeId,
@@ -201,6 +202,7 @@ export async function fetchCodexRows(
201
202
 
202
203
  interface OAuthAccountDto {
203
204
  id: string;
205
+ alias?: string;
204
206
  email?: string;
205
207
  active?: boolean;
206
208
  needsReauth?: boolean;
@@ -216,7 +218,7 @@ async function fetchOAuthRows(deps: AccountDeps, baseUrl: string, name: string):
216
218
  provider: name,
217
219
  type: "oauth" as const,
218
220
  id: a.id,
219
- label: a.email ?? `Account ${i + 1}`,
221
+ label: a.alias ?? a.email ?? `Account ${i + 1}`,
220
222
  email: a.email,
221
223
  active: a.active ?? a.id === activeId,
222
224
  needsReauth: a.needsReauth,
@@ -17,6 +17,7 @@ const AUTO_NOTE = "auto (no pin — lowest-usage account is selected per request
17
17
  const EXTENDED_USAGE = `Usage:
18
18
  ocx account refresh <provider> [--json]
19
19
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
20
+ ocx account alias <provider> <id|main> <display-name|-> [--json]
20
21
  ocx account remove <provider> <id|main> --yes [--json]
21
22
  ocx account add-key <provider> [--label <label>] [--json]`;
22
23
  const PIPE_GUIDANCE = `Pipe the API key on stdin, for example:
@@ -280,3 +281,36 @@ export async function cmdAddKey(args: string[], deps: AccountDeps): Promise<numb
280
281
  console.log(output.replaceAll(key, "[redacted]"));
281
282
  return 0;
282
283
  }
284
+
285
+ export async function cmdAlias(args: string[], deps: AccountDeps): Promise<number> {
286
+ const wantsJson = flag(args, "--json");
287
+ const name = args.shift();
288
+ const requestedId = args.shift();
289
+ const requestedAlias = args.shift();
290
+ if (!name || !requestedId || requestedAlias === undefined || args.length) return usage();
291
+ const classified = configAndType(deps, name);
292
+ if ("error" in classified) return usage(`Error: ${classified.error}`);
293
+ const id = classified.type === "codex" && requestedId === "main" ? MAIN_ID : requestedId;
294
+ if (id === MAIN_ID) return usage("Error: the main Codex App login cannot be renamed");
295
+ const alias = requestedAlias === "-" ? "" : requestedAlias.trim();
296
+ if (alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) return usage("Error: alias must be at most 80 printable characters");
297
+ const baseUrl = await resolveBaseUrl(deps);
298
+ if (!baseUrl) return proxyUnreachable();
299
+ const path = classified.type === "codex"
300
+ ? "/api/codex-auth/accounts/alias"
301
+ : classified.type === "oauth"
302
+ ? "/api/oauth/accounts/alias"
303
+ : "/api/providers/keys/alias";
304
+ const body = classified.type === "codex"
305
+ ? { id, alias }
306
+ : classified.type === "oauth"
307
+ ? { provider: name, accountId: id, alias }
308
+ : { name, id, alias };
309
+ const response = await apiJson(deps, baseUrl, "PUT", path, body);
310
+ if (response.status === 0) return proxyUnreachable();
311
+ if (response.status !== 200) return apiError(response.json, `failed to rename ${requestedId}`);
312
+ const result = { ok: true, provider: name, id, alias: alias || null };
313
+ if (wantsJson) console.log(JSON.stringify(result, null, 2));
314
+ else console.log(alias ? `${name}: ${requestedId} is now “${alias}”` : `${name}: cleared alias for ${requestedId}`);
315
+ return 0;
316
+ }
@@ -2,7 +2,7 @@
2
2
  import { loadConfig } from "../config";
3
3
  import { providerCodexAccountMode } from "../providers/registry";
4
4
  import type { OcxConfig } from "../types";
5
- import { cmdAddKey, cmdAutoSwitch, cmdRefresh, cmdRemove } from "./account-extended";
5
+ import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdRefresh, cmdRemove } from "./account-extended";
6
6
  import { apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
7
7
  from "./account-api";
8
8
 
@@ -21,6 +21,7 @@ const ACCOUNT_USAGE = `Usage:
21
21
  ocx account use <provider> <account-or-key-id|main> [--json]
22
22
  ocx account refresh <provider> [--json]
23
23
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
24
+ ocx account alias <provider> <account-or-key-id> <display-name|-> [--json]
24
25
  ocx account remove <provider> <account-or-key-id|main> --yes [--json]
25
26
  ocx account add-key <provider> [--label <label>] [--json]
26
27
 
@@ -254,6 +255,7 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
254
255
  if (sub === "use") return await cmdUse(rest, deps);
255
256
  if (sub === "refresh") return await cmdRefresh(rest, deps);
256
257
  if (sub === "auto-switch") return await cmdAutoSwitch(rest, deps);
258
+ if (sub === "alias" || sub === "rename") return await cmdAlias(rest, deps);
257
259
  if (sub === "remove") return await cmdRemove(rest, deps);
258
260
  if (sub === "add-key") return await cmdAddKey(rest, deps);
259
261
  console.error(ACCOUNT_USAGE);
package/src/cli/claude.ts CHANGED
@@ -73,7 +73,12 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
73
73
  // setDefault: an explicit user export (e.g. =0, isEnvTruthy-false) still wins.
74
74
  // Intentional contract change: settings.env model slots are also stripped in
75
75
  // ocx claude runs — use the top-level settings "model" field or opt out.
76
- setDefault("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1");
76
+ // Claude Code 2.1.206+ also treats this as a host-auth assertion. Injecting it
77
+ // without a host token makes a valid claude.ai subscription look logged out,
78
+ // so the guard is only safe when opencodex actually owns authentication.
79
+ if (env.ANTHROPIC_AUTH_TOKEN) {
80
+ setDefault("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1");
81
+ }
77
82
  // Opt-in effort forcing (devlog 136 B6): opus-shaped aliases already carry
78
83
  // output_config.effort, so this is OFF unless the user enables it in config.
79
84
  if (config.claudeCode?.alwaysEnableEffort === true) {
package/src/cli/help.ts CHANGED
@@ -94,9 +94,18 @@ const helpEntries: Record<string, HelpEntry> = {
94
94
  ],
95
95
  },
96
96
  models: {
97
- usage: "ocx models [--provider <name>] [--json]",
98
- summary: "List available models from configured providers.",
99
- details: ["Shows statically configured models. Providers with liveModels may have additional models at runtime."],
97
+ usage: "ocx models [list] [--provider <name>] [--json] | add <provider> <modelId> [opts] | remove <id|provider/modelId> [--yes] | list-custom [--json]",
98
+ summary: "List models and manage custom (manually registered) models.",
99
+ details: [
100
+ "List available models from static config with no subcommand (liveModels may add more at runtime).",
101
+ "add: register a model the provider catalog does not advertise yet.",
102
+ " --display-name <name> Human label (no slashes).",
103
+ " --context-window <tokens> e.g. 200000.",
104
+ " --modalities text,image Comma-separated (text|image|audio).",
105
+ "remove: delete a custom model by UUID or <provider>/<modelId>.",
106
+ "list-custom: show all custom models.",
107
+ "Changes apply immediately to a running proxy (catalog sync).",
108
+ ],
100
109
  },
101
110
  claude: {
102
111
  usage: "ocx claude [claude args...]",
@@ -113,6 +122,17 @@ const helpEntries: Record<string, HelpEntry> = {
113
122
  usage: "ocx restart",
114
123
  summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.",
115
124
  },
125
+ v2: {
126
+ usage: "ocx v2 <status|on|off|mode <v1|default|v2>|threads <n>>",
127
+ summary: "Toggle the Codex multi_agent_v2 feature (multi-agent surface).",
128
+ details: [
129
+ "status Show flag, multi-agent mode, and thread limit.",
130
+ "on | off Enable/disable multi_agent_v2 (catalog resyncs).",
131
+ "mode <v1|default|v2> Force all models to one surface, or respect upstream pins.",
132
+ "threads <n> Set max_concurrent_threads_per_session (integer >= 1).",
133
+ "Flips preserve the active thread limit while moving between v1/v2 modes.",
134
+ ],
135
+ },
116
136
  health: {
117
137
  usage: "ocx health [--json]",
118
138
  summary: "Check proxy health. Exits 0 if healthy, 1 otherwise.",
@@ -156,10 +176,11 @@ Usage:
156
176
  ocx gui Open the opencodex dashboard
157
177
  ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
158
178
  ocx restart Stop and restart the proxy
179
+ ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|threads)
159
180
  ocx health [--json] Check proxy health (exit 0=healthy, 1=not)
160
181
  ocx provider <sub> Manage providers (list|add|remove|show|set-default)
161
182
  ocx account <sub> Accounts/keys (list|current|use|refresh|auto-switch|remove|add-key)
162
- ocx models [--json] List available models from configured providers
183
+ ocx models <sub> List models; manage custom models (add|remove|list-custom)
163
184
  ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
164
185
  ocx help [command] Show help
165
186
  ocx --version | -v Print version
package/src/cli/init.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as readline from "node:readline";
2
+ import { constants as fsConstants, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
2
3
  import { injectCodexConfig } from "../codex/inject";
3
- import { getDefaultConfig, isValidProviderName, saveConfig } from "../config";
4
+ import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, saveConfig } from "../config";
4
5
  import { enrichProviderFromCatalog } from "../oauth/key-providers";
5
6
  import { deriveInitProviders } from "../providers/derive";
6
7
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -42,7 +43,7 @@ const KIND_HEADING: Record<InitKind, string> = {
42
43
  };
43
44
 
44
45
  function printMenu(providers: InitProvider[]): void {
45
- console.log("Available providers:");
46
+ console.log("Choose your default provider (you can add more later):");
46
47
  let lastKind: InitKind | null = null;
47
48
  providers.forEach((p, i) => {
48
49
  if (p.kind !== lastKind) { console.log(`\n ${KIND_HEADING[p.kind]}:`); lastKind = p.kind; }
@@ -53,6 +54,33 @@ function printMenu(providers: InitProvider[]): void {
53
54
 
54
55
  const envKeyFor = (id: string) => `${id.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
55
56
 
57
+ /** Post-init cleanup of `.pre-openai-tiers-v2.bak` with rollback preservation (issue #257). */
58
+ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()): void {
59
+ const backup = `${configPath}.pre-openai-tiers-v2.bak`;
60
+ try {
61
+ if (!existsSync(backup)) return;
62
+ if (classifyOpenAiTierBackup(readFileSync(backup)) === "stale") {
63
+ unlinkSync(backup);
64
+ return;
65
+ }
66
+ // Publish the preserved snapshot with a no-replace copy (COPYFILE_EXCL) so a
67
+ // destination collision (frozen/rolled-back clock, pre-created file) can never
68
+ // silently overwrite another rollback snapshot; retry with a sequence suffix.
69
+ for (let attempt = 0; attempt < 16; attempt++) {
70
+ const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
71
+ try {
72
+ copyFileSync(backup, preserved, fsConstants.COPYFILE_EXCL);
73
+ } catch (error) {
74
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
75
+ throw error;
76
+ }
77
+ unlinkSync(backup);
78
+ console.warn(`⚠️ Kept your pre-migration config rollback snapshot at ${preserved}`);
79
+ return;
80
+ }
81
+ } catch { /* cleanup is best-effort; never block init on backup housekeeping */ }
82
+ }
83
+
56
84
  export async function runInit(): Promise<void> {
57
85
  const prompt = createPrompt();
58
86
  console.log("\n🔧 opencodex (ocx) setup\n");
@@ -60,7 +88,7 @@ export async function runInit(): Promise<void> {
60
88
  const providers = buildInitProviders();
61
89
  printMenu(providers);
62
90
 
63
- const choice = await prompt.ask("\nSelect provider (number): ");
91
+ const choice = await prompt.ask("\nSelect default provider (number): ");
64
92
  const idx = parseInt(choice, 10) - 1;
65
93
 
66
94
  let providerName: string;
@@ -136,6 +164,13 @@ export async function runInit(): Promise<void> {
136
164
  };
137
165
 
138
166
  saveConfig(config);
167
+ // Init writes a fresh config, so a stale pre-migration backup from a previous
168
+ // installation would make the next `ocx start` crash on a stale-backup
169
+ // collision (issue #257). But only a STALE backup (unparseable, or already a
170
+ // post-migration v2 snapshot) may be deleted; a backup that still parses as a
171
+ // valid pre-migration (v1) config is a user-intentional rollback point and is
172
+ // preserved by renaming it out of the collision path (sol review 260722).
173
+ cleanupOpenAiTierBackupAfterInit();
139
174
  console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
140
175
  if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
141
176