@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.
- package/README.ja.md +438 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +2 -1
- package/README.zh-CN.md +1 -1
- package/bin/ocx.mjs +18 -1
- package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
- package/gui/dist/assets/index-d63HMU0x.js +52 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +17 -2
- package/src/adapters/google-tool-schema.ts +4 -0
- package/src/adapters/google.ts +10 -2
- package/src/adapters/openai-chat.ts +36 -1
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +12 -4
- package/src/cli/account-api.ts +4 -2
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude.ts +6 -1
- package/src/cli/help.ts +25 -4
- package/src/cli/init.ts +38 -3
- package/src/cli/models.ts +206 -7
- package/src/codex/auth-api.ts +27 -3
- package/src/codex/catalog.ts +72 -2
- package/src/config.ts +60 -3
- package/src/oauth/github-copilot.ts +1 -0
- package/src/oauth/index.ts +5 -4
- package/src/oauth/kiro.ts +12 -1
- package/src/oauth/store.ts +11 -0
- package/src/oauth/types.ts +3 -1
- package/src/providers/antigravity-models.ts +103 -5
- package/src/providers/api-keys.ts +12 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/registry.ts +7 -5
- package/src/router.ts +2 -1
- package/src/server/auth-cors.ts +5 -0
- package/src/server/management-api.ts +143 -6
- package/src/server/responses.ts +16 -4
- package/src/types.ts +42 -1
- package/src/update/index.ts +19 -2
- package/src/update/job.ts +13 -3
- package/src/usage/expected-prices.ts +10 -0
- package/src/usage/summary.ts +43 -0
- package/gui/dist/assets/index-BPa0R6EN.js +0 -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
|
|
3
|
-
*
|
|
4
|
-
* Usage:
|
|
5
|
-
* ocx models [--provider <name>] [--json]
|
|
2
|
+
* `ocx models` subcommand — list configured models and manage custom models.
|
|
6
3
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
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
|
-
|
|
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
|
+
}
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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); }
|
package/src/codex/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)))
|
|
182
|
-
|
|
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(
|
package/src/oauth/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
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" };
|
package/src/oauth/store.ts
CHANGED
|
@@ -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 => {
|
package/src/oauth/types.ts
CHANGED
|
@@ -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;
|