@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
package/src/cli/models.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  /**
2
- * `ocx models` subcommand — list available models from configured providers.
3
- *
4
- * Usage:
5
- * ocx models [--provider <name>] [--json]
2
+ * `ocx models` subcommand — list configured models and manage custom models.
6
3
  */
7
- import { hasOwnProvider, loadConfig } from "../config";
8
- import type { OcxConfig } from "../types";
4
+ import { randomUUID } from "node:crypto";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { syncModelsToCodex } from "../codex/sync";
7
+ import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
8
+ import { routedSlug } from "../providers/slug-codec";
9
+ import { findLiveProxy } from "../server/proxy-liveness";
10
+ import type { OcxConfig, OcxCustomModel } from "../types";
11
+
12
+ const ADD_USAGE = "Usage: ocx models add <provider> <modelId> [--display-name <name>] [--context-window <tokens>] [--modalities text,image,audio]";
13
+ const REMOVE_USAGE = "Usage: ocx models remove <customId|provider/modelId> [--yes]";
14
+ const LIST_CUSTOM_USAGE = "Usage: ocx models list-custom [--json]";
15
+ const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]);
9
16
 
10
17
  interface ModelEntry {
11
18
  provider: string;
@@ -75,7 +82,175 @@ function consumeFlagValue(args: string[], flag: string): string | undefined {
75
82
  return value;
76
83
  }
77
84
 
78
- export function handleModels(args: string[]): void {
85
+ function fail(message: string, usage?: string): never {
86
+ console.error(`Error: ${message}`);
87
+ if (usage) console.error(usage);
88
+ process.exit(1);
89
+ }
90
+
91
+ function rejectUnexpectedArgs(args: string[], usage: string): void {
92
+ if (args.length === 0) return;
93
+ const unknown = args.filter(arg => arg.startsWith("-"));
94
+ fail(
95
+ unknown.length > 0
96
+ ? `Unknown flag(s): ${unknown.join(", ")}`
97
+ : `Unexpected argument(s): ${args.join(", ")}`,
98
+ usage,
99
+ );
100
+ }
101
+
102
+ async function syncCustomModelsIfLive(): Promise<void> {
103
+ const live = await findLiveProxy();
104
+ if (!live) return;
105
+ await syncModelsToCodex(live.port).catch(error => {
106
+ console.error(`Warning: custom model saved, but catalog sync failed: ${error instanceof Error ? error.message : String(error)}`);
107
+ });
108
+ }
109
+
110
+ async function handleCustomAdd(args: string[]): Promise<void> {
111
+ const rest = [...args];
112
+ const provider = rest.shift()?.trim() ?? "";
113
+ const modelId = rest.shift()?.trim() ?? "";
114
+ const displayNameValue = consumeFlagValue(rest, "--display-name");
115
+ const contextWindowValue = consumeFlagValue(rest, "--context-window");
116
+ const modalitiesValue = consumeFlagValue(rest, "--modalities");
117
+ rejectUnexpectedArgs(rest, ADD_USAGE);
118
+
119
+ if (!provider || !modelId) fail("provider and modelId are required", ADD_USAGE);
120
+ if (!isValidProviderName(provider)) fail(`invalid provider name "${provider}"`);
121
+ if (modelId.includes("/")) fail("modelId must not contain /");
122
+
123
+ const config = loadConfig();
124
+ if (!hasOwnProvider(config.providers, provider)) {
125
+ fail(`provider "${provider}" is not configured. See: ocx provider list`);
126
+ }
127
+
128
+ const displayName = displayNameValue?.trim() || undefined;
129
+ if (displayName?.includes("/")) fail("displayName must not contain /");
130
+
131
+ let contextWindow: number | undefined;
132
+ if (contextWindowValue !== undefined) {
133
+ contextWindow = Number(contextWindowValue);
134
+ if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
135
+ fail("context window must be a positive integer");
136
+ }
137
+ }
138
+
139
+ let inputModalities: string[] | undefined;
140
+ if (modalitiesValue !== undefined) {
141
+ inputModalities = modalitiesValue.split(",").map(value => value.trim());
142
+ const invalid = inputModalities.filter(value => !ALLOWED_MODALITIES.has(value));
143
+ if (inputModalities.length === 0 || invalid.length > 0) {
144
+ fail("modalities must be comma-separated values from text|image|audio");
145
+ }
146
+ inputModalities = [...new Set(inputModalities)];
147
+ }
148
+
149
+ const existing = config.customModels ?? [];
150
+ const slug = routedSlug(provider, modelId);
151
+ if (existing.some(model => routedSlug(model.provider, model.modelId) === slug)) {
152
+ fail(`custom model "${slug}" already exists`);
153
+ }
154
+
155
+ const entry: OcxCustomModel = {
156
+ id: randomUUID(),
157
+ provider,
158
+ modelId,
159
+ ...(displayName ? { displayName } : {}),
160
+ ...(contextWindow ? { contextWindow } : {}),
161
+ ...(inputModalities ? { inputModalities } : {}),
162
+ addedAt: new Date().toISOString(),
163
+ };
164
+ config.customModels = [...existing, entry];
165
+ saveConfig(config);
166
+ await syncCustomModelsIfLive();
167
+ console.log(`Added custom model ${slug} (${entry.id}).`);
168
+ }
169
+
170
+ async function confirmCustomRemoval(model: OcxCustomModel): Promise<boolean> {
171
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
172
+ fail("remove requires --yes in non-interactive mode");
173
+ }
174
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
175
+ try {
176
+ const answer = (await rl.question(`Remove custom model ${routedSlug(model.provider, model.modelId)}? [y/N] `)).trim().toLowerCase();
177
+ return answer === "y" || answer === "yes";
178
+ } finally {
179
+ rl.close();
180
+ }
181
+ }
182
+
183
+ async function handleCustomRemove(args: string[]): Promise<void> {
184
+ const rest = [...args];
185
+ const confirmed = consumeFlag(rest, "--yes");
186
+ const target = rest.shift()?.trim() ?? "";
187
+ rejectUnexpectedArgs(rest, REMOVE_USAGE);
188
+ if (!target) fail("custom model id or provider/modelId is required", REMOVE_USAGE);
189
+
190
+ const config = loadConfig();
191
+ const existing = config.customModels ?? [];
192
+ const index = target.includes("/")
193
+ ? existing.findIndex(model => routedSlug(model.provider, model.modelId) === target)
194
+ : existing.findIndex(model => model.id === target);
195
+ if (index === -1) fail(`custom model "${target}" not found`);
196
+
197
+ const model = existing[index];
198
+ if (!confirmed && !(await confirmCustomRemoval(model))) {
199
+ console.log("Cancelled.");
200
+ return;
201
+ }
202
+
203
+ const next = existing.filter((_, modelIndex) => modelIndex !== index);
204
+ config.customModels = next.length > 0 ? next : undefined;
205
+ saveConfig(config);
206
+ await syncCustomModelsIfLive();
207
+ console.log(`Removed custom model ${routedSlug(model.provider, model.modelId)}.`);
208
+ }
209
+
210
+ function customModelCells(model: OcxCustomModel): string[] {
211
+ return [
212
+ model.id.slice(0, 8),
213
+ model.modelId,
214
+ model.displayName ?? "-",
215
+ model.contextWindow ? `${Math.round(model.contextWindow / 1000)}k` : "-",
216
+ model.inputModalities?.join(",") ?? "-",
217
+ ];
218
+ }
219
+
220
+ function printCustomModelGroup(provider: string, models: OcxCustomModel[]): void {
221
+ const rows = models.map(customModelCells);
222
+ const headers = ["ID", "MODEL", "DISPLAY NAME", "CONTEXT", "MODALITIES"];
223
+ const widths = headers.map((header, column) => Math.max(header.length, ...rows.map(row => row[column].length)));
224
+ const line = (cells: string[]) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ");
225
+ console.log(`${provider}:`);
226
+ console.log(` ${line(headers)}`);
227
+ for (const row of rows) console.log(` ${line(row)}`);
228
+ console.log();
229
+ }
230
+
231
+ function handleCustomList(args: string[]): void {
232
+ const rest = [...args];
233
+ const wantsJson = consumeFlag(rest, "--json");
234
+ rejectUnexpectedArgs(rest, LIST_CUSTOM_USAGE);
235
+ const models = loadConfig().customModels ?? [];
236
+ if (wantsJson) {
237
+ console.log(JSON.stringify(models, null, 2));
238
+ return;
239
+ }
240
+ if (models.length === 0) {
241
+ console.log("No custom models registered.");
242
+ return;
243
+ }
244
+ const byProvider = new Map<string, OcxCustomModel[]>();
245
+ for (const model of models) {
246
+ const group = byProvider.get(model.provider) ?? [];
247
+ group.push(model);
248
+ byProvider.set(model.provider, group);
249
+ }
250
+ for (const [provider, providerModels] of byProvider) printCustomModelGroup(provider, providerModels);
251
+ }
252
+
253
+ function handleConfiguredModels(args: string[]): void {
79
254
  const restArgs = [...args];
80
255
  const wantsJson = consumeFlag(restArgs, "--json");
81
256
  const providerFilter = consumeFlagValue(restArgs, "--provider");
@@ -136,3 +311,27 @@ export function handleModels(args: string[]): void {
136
311
  console.log("* = default model for provider");
137
312
  console.log("Note: providers with liveModels may have additional models at runtime.");
138
313
  }
314
+
315
+ function runCustomCommand(command: Promise<void>): void {
316
+ command.catch(error => {
317
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
318
+ process.exitCode = 1;
319
+ });
320
+ }
321
+
322
+ export function handleModels(args: string[]): void {
323
+ const [subcommand, ...rest] = args;
324
+ if (subcommand === "add") {
325
+ runCustomCommand(handleCustomAdd(rest));
326
+ return;
327
+ }
328
+ if (subcommand === "remove") {
329
+ runCustomCommand(handleCustomRemove(rest));
330
+ return;
331
+ }
332
+ if (subcommand === "list-custom") {
333
+ handleCustomList(rest);
334
+ return;
335
+ }
336
+ handleConfiguredModels(subcommand === "list" ? rest : args);
337
+ }
@@ -77,6 +77,7 @@ function poolAccountDto(
77
77
  return {
78
78
  id: account.id,
79
79
  email: maskEmail(account.email) ?? account.email,
80
+ ...(account.alias !== undefined ? { alias: account.alias } : {}),
80
81
  ...(account.plan !== undefined ? { plan: account.plan } : {}),
81
82
  ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
82
83
  isMain: false,
@@ -168,9 +169,13 @@ async function verifyCodexAccountWarmup(
168
169
  }
169
170
 
170
171
  function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
171
- if (!flowId) return;
172
- codexAuthLoginState.set(flowId, { status: "error", error, doneAt: Date.now() });
173
- setTimeout(() => codexAuthLoginState.delete(flowId), 30_000);
172
+ const ids = flowId
173
+ ? [flowId]
174
+ : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
175
+ for (const id of ids) {
176
+ codexAuthLoginState.set(id, { status: "error", error, doneAt: Date.now() });
177
+ setTimeout(() => codexAuthLoginState.delete(id), 30_000);
178
+ }
174
179
  }
175
180
 
176
181
  let mainAccountCache: { email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null; ts: number } | null = null;
@@ -258,6 +263,7 @@ interface PoolQuotaResult {
258
263
 
259
264
  export interface CodexAuthAccountDto {
260
265
  id: string;
266
+ alias?: string;
261
267
  email: string;
262
268
  plan?: string | null;
263
269
  logLabel?: string;
@@ -442,6 +448,24 @@ export async function handleCodexAuthAPI(
442
448
  return jsonResponse({ ok: true });
443
449
  }
444
450
 
451
+ if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
452
+ const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
453
+ const id = typeof body.id === "string" ? body.id.trim() : "";
454
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
455
+ if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
456
+ if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
457
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
458
+ return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
459
+ }
460
+ const runtimeConfig = getRuntimeConfig(config);
461
+ const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain);
462
+ if (!account) return jsonResponse({ error: "Account not found" }, 404);
463
+ if (alias) account.alias = alias;
464
+ else delete account.alias;
465
+ saveRuntimeConfig(config, runtimeConfig);
466
+ return jsonResponse({ ok: true, id, alias: alias || null });
467
+ }
468
+
445
469
  if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
446
470
  let body: { accountId: string | null };
447
471
  try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
@@ -690,7 +690,7 @@ export function materializeBundledCodexCatalog(path: string, deps: BundledCatalo
690
690
 
691
691
  function loadCatalogForSync(path: string): RawCatalog | null {
692
692
  const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
693
- if (bundled) return bundled;
693
+ if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
694
694
  const catalog = readCatalog(path);
695
695
  if (catalog && findNativeTemplate(catalog)) return catalog;
696
696
  return readCatalog(catalogBackupPathFor(path))
@@ -829,6 +829,66 @@ function ensureUltraReasoningLevel(entry: RawEntry): void {
829
829
  entry.supported_reasoning_levels = levels;
830
830
  }
831
831
 
832
+ /** Reasoning-effort labels accepted by the installed Codex binary's bundled catalog. */
833
+ export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): Set<string> | null {
834
+ const bundled = loadBundledCodexCatalog(deps);
835
+ if (!bundled) return null;
836
+ const efforts = new Set<string>();
837
+ for (const model of bundled.models ?? []) {
838
+ if (typeof model.slug !== "string" || model.slug.includes("/")) continue;
839
+ const levels = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [];
840
+ for (const level of levels) {
841
+ const effort = (level as { effort?: unknown })?.effort;
842
+ if (typeof effort === "string") efforts.add(effort);
843
+ }
844
+ if (typeof model.default_reasoning_level === "string") efforts.add(model.default_reasoning_level);
845
+ }
846
+ return efforts.size > 0 ? efforts : null;
847
+ }
848
+
849
+ /** Highest surviving rung at or below the original default, with a conservative empty fallback. */
850
+ export function clampedDefaultEffort(original: string, surviving: readonly string[]): string {
851
+ if (surviving.length === 0) return "medium";
852
+ const ranked = [...surviving]
853
+ .map(effort => ({ effort, rank: codexEffortRank(effort) }))
854
+ .sort((a, b) => a.rank - b.rank);
855
+ const originalRank = codexEffortRank(original);
856
+ const atOrBelow = ranked.filter(item => item.rank >= 0 && item.rank <= originalRank);
857
+ return (atOrBelow.at(-1) ?? ranked[0]!).effort;
858
+ }
859
+
860
+ /** Remove reasoning efforts the installed Codex binary cannot deserialize from one entry. */
861
+ export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Set<string> | null): void {
862
+ if (!supported) return;
863
+ const levels = Array.isArray(entry.supported_reasoning_levels)
864
+ ? entry.supported_reasoning_levels as Array<{ effort?: string }>
865
+ : null;
866
+ if (levels && levels.length > 0) {
867
+ const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
868
+ entry.supported_reasoning_levels = kept.length > 0
869
+ ? kept
870
+ : CODEX_REASONING_LEVELS
871
+ .filter(level => level.effort === "low" || level.effort === "medium" || level.effort === "high")
872
+ .map(level => ({ ...level }));
873
+ }
874
+ const currentDefault = entry.default_reasoning_level;
875
+ if (typeof currentDefault === "string" && !supported.has(currentDefault)) {
876
+ const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
877
+ .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
878
+ ? [(level as { effort: string }).effort]
879
+ : []);
880
+ entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
881
+ }
882
+ }
883
+
884
+ /** Clamp every catalog entry to the reasoning ladder accepted by the installed Codex binary. */
885
+ export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] {
886
+ const supported = codexSupportedReasoningEfforts(deps);
887
+ if (!supported) return models;
888
+ for (const entry of models) clampEntryToCodexSupportedEfforts(entry, supported);
889
+ return models;
890
+ }
891
+
832
892
  /**
833
893
  * Native entry from the pinned upstream snapshot, finished for emission. Keeps the entry's
834
894
  * OWN identity (display_name, description, priority, availability_nux — it is the model's own
@@ -1450,7 +1510,16 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
1450
1510
  else warnUncataloguedComboOnce(id, combo, members);
1451
1511
  }
1452
1512
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
1453
- return all;
1513
+ const customModels = (config.customModels ?? []).map(cm => ({
1514
+ id: cm.modelId,
1515
+ provider: cm.provider,
1516
+ ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
1517
+ ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
1518
+ }));
1519
+ // Custom rows override discovered rows that encode to the same Codex-facing slug.
1520
+ const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
1521
+ const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
1522
+ return [...deduped, ...customModels];
1454
1523
  }
1455
1524
 
1456
1525
  const openAiApiCollisionWarnings = new Set<string>();
@@ -1926,6 +1995,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
1926
1995
  // native template can never leak supports_websockets while the flag is off.
1927
1996
  const wsEnabled = websocketsEnabled(config);
1928
1997
  catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
1998
+ clampCatalogModelsToCodexSupport(catalog.models);
1929
1999
 
1930
2000
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
1931
2001
  return { added: goEntries.length, path: catalogPath };
package/src/config.ts CHANGED
@@ -6,7 +6,8 @@ import * as z from "zod/v4";
6
6
  import { comboConfigIssues } from "./combos/types";
7
7
  import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
8
8
  import { providerDestinationConfigError } from "./lib/destination-policy";
9
- import type { OcxConfig } from "./types";
9
+ import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
10
+ import { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "./types";
10
11
 
11
12
  let _atomicSeq = 0;
12
13
 
@@ -155,6 +156,28 @@ function isAlreadyExistsError(error: unknown): boolean {
155
156
  return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
156
157
  }
157
158
 
159
+ /**
160
+ * Classify an existing `.pre-openai-tiers-v2.bak` snapshot.
161
+ *
162
+ * - `"stale"`: unparseable JSON (not written by us / truncated) or already a
163
+ * post-migration (tier v2) snapshot — safe to delete or replace.
164
+ * - `"rollback"`: parses as a valid pre-migration (v1) config — a
165
+ * user-intentional rollback point that must never be silently destroyed.
166
+ *
167
+ * Shared by the startup migration backup path and `ocx init` cleanup so both
168
+ * apply the same preservation policy (issue #257 / sol review 260722).
169
+ */
170
+ export function classifyOpenAiTierBackup(backupBytes: Uint8Array): "stale" | "rollback" {
171
+ try {
172
+ // Use Buffer.from to ensure proper UTF-8 decoding from Uint8Array/Buffer.
173
+ const parsed = JSON.parse(Buffer.from(backupBytes).toString("utf8")) as Record<string, unknown>;
174
+ return parsed.openaiProviderTierVersion === 2 ? "stale" : "rollback";
175
+ } catch {
176
+ // Unparseable: not a config file we created, treat as stale.
177
+ return "stale";
178
+ }
179
+ }
180
+
158
181
  export function backupConfigBeforeOpenAiTierMigration(
159
182
  configPath = getConfigPath(),
160
183
  io: OpenAiTierBackupIO = {
@@ -178,8 +201,24 @@ export function backupConfigBeforeOpenAiTierMigration(
178
201
  // docs/fixtures and is never reused or overwritten as the v2 snapshot.
179
202
  const backup = `${source}.pre-openai-tiers-v2.bak`;
180
203
  if (io.exists(backup)) {
181
- if (!sameBytes(original, io.read(backup))) throw new OpenAiTierBackupCollisionError();
182
- return "reused";
204
+ if (!sameBytes(original, io.read(backup))) {
205
+ // The backup differs from the current config. Only treat it as stale when it is
206
+ // clearly not a user-intentional rollback point:
207
+ // - unparseable JSON: written by a different tool or truncated
208
+ // - already at tier version 2: the backup is from a post-migration config (e.g.
209
+ // ocx init wrote a fresh v2 config, making the old backup obsolete)
210
+ // A backup that parses as a valid pre-migration (v1) config is kept as-is and
211
+ // we throw a collision error, because silently replacing a user-created rollback
212
+ // point would be surprising and potentially destructive.
213
+ const backupBytes = io.read(backup);
214
+ if (classifyOpenAiTierBackup(backupBytes) === "rollback") {
215
+ throw new OpenAiTierBackupCollisionError();
216
+ }
217
+ console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration).");
218
+ io.unlink(backup);
219
+ } else {
220
+ return "reused";
221
+ }
183
222
  }
184
223
  const temp = `${backup}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
185
224
  let published = false;
@@ -374,6 +413,20 @@ const configSchema = z.object({
374
413
  });
375
414
  }
376
415
  const provider = config.providers[name];
416
+ const openRouterRoutingError = openRouterRoutingConfigError(provider);
417
+ if (openRouterRoutingError) {
418
+ ctx.addIssue({
419
+ code: "custom",
420
+ path: [
421
+ "providers",
422
+ name,
423
+ openRouterRoutingError.startsWith("modelOpenRouterRouting")
424
+ ? "modelOpenRouterRouting"
425
+ : "openRouterRouting",
426
+ ],
427
+ message: openRouterRoutingError,
428
+ });
429
+ }
377
430
  if (Object.hasOwn(provider, "virtualModels")) {
378
431
  ctx.addIssue({
379
432
  code: "custom",
@@ -641,6 +694,10 @@ export function getDefaultConfig(): OcxConfig {
641
694
  // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
642
695
  return {
643
696
  port: 10100,
697
+ // Fresh/re-initialized configs are already written in the current three-tier
698
+ // OpenAI shape. Mark them as such so startup does not mistake them for a
699
+ // legacy config and collide with an immutable backup from an earlier setup.
700
+ openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION,
644
701
  providers: {
645
702
  openai: {
646
703
  adapter: "openai-responses",
@@ -395,6 +395,7 @@ export async function loginGithubCopilot(ctrl: OAuthController): Promise<OAuthCr
395
395
  ctrl.onAuth?.({
396
396
  url: device.verifyUrl,
397
397
  instructions: `Enter code: ${device.userCode}`,
398
+ deviceCode: device.userCode,
398
399
  });
399
400
  ctrl.onProgress?.("Waiting for GitHub device authorization…");
400
401
  const github = await pollGithubDeviceToken(
@@ -646,7 +646,7 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
646
646
  return { ok: true };
647
647
  }
648
648
 
649
- export interface OAuthAccountSummary { id: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
649
+ export interface OAuthAccountSummary { id: string; alias?: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
650
650
 
651
651
  export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
652
652
  const cred = getCredential(provider);
@@ -654,6 +654,7 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
654
654
  const set = getAccountSet(provider);
655
655
  const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
656
656
  id: a.id,
657
+ ...(a.alias ? { alias: a.alias } : {}),
657
658
  email: maskEmail(a.credential.email) ?? undefined,
658
659
  active: a.id === set.activeAccountId,
659
660
  ...(a.needsReauth ? { needsReauth: true } : {}),
@@ -695,7 +696,7 @@ export function cancelLoginFlow(provider: string): boolean {
695
696
  return true;
696
697
  }
697
698
 
698
- export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string }> {
699
+ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> {
699
700
  const def = OAUTH_PROVIDERS[provider];
700
701
  if (!def) throw new UnsupportedOAuthProviderError(provider);
701
702
  const existing = loginState.get(provider);
@@ -709,9 +710,9 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
709
710
  return new Promise((resolve, reject) => {
710
711
  let urlResolved = false;
711
712
  const ctrl: OAuthController = {
712
- onAuth: ({ url, instructions }) => {
713
+ onAuth: ({ url, instructions, deviceCode }) => {
713
714
  urlResolved = true;
714
- resolve({ url, instructions });
715
+ resolve({ url, instructions, deviceCode });
715
716
  },
716
717
  onProgress: () => {},
717
718
  // GUI fallback when the browser cannot hit the loopback callback server.
package/src/oauth/kiro.ts CHANGED
@@ -50,7 +50,9 @@ export function readKiroCliSqlite(): ImportedKiroToken | null {
50
50
 
51
51
  /**
52
52
  * Import-first login: kiro-cli SQLite → KIRO_ACCESS_TOKEN env → manual paste (CLI only).
53
- * In GUI (no onManualCodeInput) with no SQLite token and no env, throws a clear error never hangs.
53
+ * When no local token is available, resolves the login flow via onAuth with instructions
54
+ * so the GUI renders the paste-input field, then blocks on onManualCodeInput for the token.
55
+ * If neither onAuth nor onManualCodeInput is available, throws a clear error.
54
56
  */
55
57
  export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials> {
56
58
  const imported = readImportedKiroCredential();
@@ -71,6 +73,15 @@ export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials
71
73
  }
72
74
 
73
75
  if (ctrl.onManualCodeInput) {
76
+ // Resolve the login flow immediately so the GUI receives instructions and
77
+ // shows the paste-input field. Without this, onManualCodeInput blocks
78
+ // forever and the HTTP response never reaches the dashboard.
79
+ ctrl.onAuth?.({
80
+ url: "",
81
+ instructions:
82
+ "No kiro-cli token found. Paste a Kiro access token below (starts with 'aoa'). " +
83
+ "Run `kiro-cli login` first, or set KIRO_ACCESS_TOKEN.",
84
+ });
74
85
  ctrl.onProgress?.("No kiro-cli token found. Paste a Kiro access token (starts with 'aoa').");
75
86
  const raw = (await ctrl.onManualCodeInput()).trim();
76
87
  if (raw) return { access: raw, refresh: "", expires: Date.now() + 3600_000, source: "manual" };
@@ -179,6 +179,7 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
179
179
  const credential = normalizeCredential(candidate.credential);
180
180
  if (!credential) return null;
181
181
  const account: ProviderAccount = { id: candidate.id, credential };
182
+ if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim();
182
183
  if (candidate.needsReauth === true) account.needsReauth = true;
183
184
  if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
184
185
  return account;
@@ -341,6 +342,16 @@ export async function setActiveAccount(provider: string, accountId: string): Pro
341
342
  });
342
343
  }
343
344
 
345
+ export async function setAccountAlias(provider: string, accountId: string, alias: string | undefined): Promise<boolean> {
346
+ return await mutateStore(store => {
347
+ const account = store[provider]?.accounts.find(a => a.id === accountId);
348
+ if (!account) return false;
349
+ if (alias) account.alias = alias;
350
+ else delete account.alias;
351
+ return true;
352
+ });
353
+ }
354
+
344
355
  /** Remove one account by id; active removal promotes the first remaining account. */
345
356
  export async function removeAccount(provider: string, accountId: string): Promise<boolean> {
346
357
  return await mutateStore(store => {
@@ -21,6 +21,8 @@ export type OAuthCredentials = {
21
21
  export interface ProviderAccount {
22
22
  /** Stable short id, generated once at append time; never re-derived after rotation. */
23
23
  id: string;
24
+ /** User-owned display label; never participates in auth identity or routing. */
25
+ alias?: string;
24
26
  credential: OAuthCredentials;
25
27
  /** Terminal refresh failure (invalid_grant / reused / revoked) — re-login required. */
26
28
  needsReauth?: boolean;
@@ -34,7 +36,7 @@ export interface ProviderAccountSet {
34
36
  }
35
37
 
36
38
  export interface OAuthController {
37
- onAuth?(info: { url: string; instructions?: string }): void;
39
+ onAuth?(info: { url: string; instructions?: string; deviceCode?: string }): void;
38
40
  onProgress?(message: string): void;
39
41
  onManualCodeInput?(expectedState?: string): Promise<string>;
40
42
  signal?: AbortSignal;