@bitkyc08/opencodex 2.7.28 → 2.7.29-preview.20260721

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/src/cli/index.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  import { collectStatus } from "./status";
23
23
  import { installCrashGuards } from "../lib/crash-guard";
24
24
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
25
- import { findAvailablePort, isAddrInUse, shouldPersistSelectedPort } from "../server/ports";
25
+ import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
26
26
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
27
27
  import { stopProxy } from "../lib/process-control";
28
28
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
@@ -85,23 +85,43 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
85
85
  return null;
86
86
  }
87
87
 
88
+ /** Argv for detached `start`, optionally hard-pinning the listen port. */
89
+ function startArgv(port?: number): string[] {
90
+ const args = [process.argv[1], "start"];
91
+ if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
92
+ args.push("--port", String(Math.trunc(port)));
93
+ }
94
+ return args;
95
+ }
96
+
88
97
  async function chooseListenPort(requestedPort?: number): Promise<number> {
89
98
  const config = loadConfig();
90
99
  const preferred = requestedPort ?? config.port ?? 10100;
91
- // Brief prefer-retry covers stop→start races (update restart, `ocx restart`) where the
92
- // old process has exited but the listen socket is still draining.
93
- const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
94
- preferRetryMs: 750,
95
- preferRetryIntervalMs: 50,
96
- });
97
- if (selected !== preferred) {
98
- console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
99
- }
100
- if (shouldPersistSelectedPort(config.port, selected, preferred)) {
101
- config.port = selected;
102
- saveConfig(config);
100
+ const hardPin = requestedPort !== undefined && requestedPort > 0;
101
+ // Soft start: brief prefer-retry then ephemeral hop.
102
+ // Explicit `--port` (service wrappers / update restart): longer prefer-retry, never hop.
103
+ try {
104
+ const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
105
+ preferRetryMs: hardPin ? 8_000 : 750,
106
+ preferRetryIntervalMs: 50,
107
+ allowEphemeralFallback: !hardPin,
108
+ });
109
+ if (selected !== preferred) {
110
+ console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
111
+ }
112
+ if (shouldPersistSelectedPort(config.port, selected, preferred)) {
113
+ config.port = selected;
114
+ saveConfig(config);
115
+ }
116
+ return selected;
117
+ } catch (err) {
118
+ if (err instanceof PortUnavailableError) {
119
+ console.error(`❌ ${err.message}`);
120
+ console.error(" Stop whatever holds that port, or change config.port, then retry.");
121
+ process.exit(1);
122
+ }
123
+ throw err;
103
124
  }
104
- return selected;
105
125
  }
106
126
 
107
127
  async function handleStart(options: { block?: boolean } = {}) {
@@ -128,7 +148,8 @@ async function handleStart(options: { block?: boolean } = {}) {
128
148
  await maybeShowUpdatePrompt();
129
149
 
130
150
  // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port
131
- // between the probe and Bun.serve. Retry the pick instead of dying on EADDRINUSE.
151
+ // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries
152
+ // the same port only (never hop — that was the remaining PR #152 gap).
132
153
  let port = await chooseListenPort(requestedPort);
133
154
  let server: ReturnType<typeof startServer>;
134
155
  for (let attempt = 0; ; attempt++) {
@@ -137,6 +158,16 @@ async function handleStart(options: { block?: boolean } = {}) {
137
158
  break;
138
159
  } catch (err) {
139
160
  if (!isAddrInUse(err) || attempt >= 2) throw err;
161
+ if (requestedPort !== undefined) {
162
+ console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`);
163
+ const hostname = loadConfig().hostname ?? "127.0.0.1";
164
+ const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 });
165
+ if (!freed) {
166
+ console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`);
167
+ process.exit(1);
168
+ }
169
+ continue;
170
+ }
140
171
  console.log(`⚠️ Port ${port} was taken while starting; picking another...`);
141
172
  port = await chooseListenPort(requestedPort);
142
173
  }
@@ -249,7 +280,8 @@ async function handleEnsure() {
249
280
  return;
250
281
  }
251
282
 
252
- const child = spawn(process.execPath, [process.argv[1], "start"], {
283
+ const pinPort = config.port ?? 10100;
284
+ const child = spawn(process.execPath, startArgv(pinPort > 0 ? pinPort : undefined), {
253
285
  detached: true,
254
286
  stdio: "ignore",
255
287
  windowsHide: true,
@@ -537,7 +569,7 @@ switch (command) {
537
569
  let live = await findLiveProxy();
538
570
  if (!live) {
539
571
  console.log("Proxy not running. Starting...");
540
- const child = spawn(process.execPath, [process.argv[1], "start"], {
572
+ const child = spawn(process.execPath, startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined), {
541
573
  detached: true,
542
574
  stdio: "ignore",
543
575
  windowsHide: true,
@@ -545,6 +577,10 @@ switch (command) {
545
577
  });
546
578
  child.unref();
547
579
  live = await waitForProxy();
580
+ if (!live) {
581
+ console.error("❌ Proxy did not become healthy after starting. Not opening the GUI.");
582
+ process.exit(1);
583
+ }
548
584
  }
549
585
  // Open the host the proxy actually binds — `localhost` only answers for
550
586
  // loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
@@ -628,6 +664,11 @@ switch (command) {
628
664
  await handleProviderCommand(args.slice(1));
629
665
  break;
630
666
  }
667
+ case "account": {
668
+ const { cmdAccount } = await import("./account");
669
+ process.exitCode = await cmdAccount(args.slice(1));
670
+ break;
671
+ }
631
672
  case "models": {
632
673
  const { handleModels } = await import("./models");
633
674
  handleModels(args.slice(1));
package/src/cli/init.ts CHANGED
@@ -82,6 +82,16 @@ export async function runInit(): Promise<void> {
82
82
  } else {
83
83
  // key + local: collect a key (local usually blank).
84
84
  if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
85
+ // Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
86
+ let baseUrl = p.baseUrl;
87
+ if (/\{[^}]*\}/.test(baseUrl)) {
88
+ const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
89
+ if (!resolved) {
90
+ console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
91
+ process.exit(1);
92
+ }
93
+ baseUrl = resolved;
94
+ }
85
95
  const env = envKeyFor(p.id);
86
96
  const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
87
97
  const apiKey = (await prompt.ask(`\n${hint}`)).trim();
@@ -89,7 +99,7 @@ export async function runInit(): Promise<void> {
89
99
  const defaultModel = modelChoice || p.defaultModel;
90
100
  providerConfig = {
91
101
  adapter: p.adapter,
92
- baseUrl: p.baseUrl,
102
+ baseUrl,
93
103
  ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
94
104
  ...(defaultModel ? { defaultModel } : {}),
95
105
  };
@@ -1266,7 +1266,12 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1266
1266
  const staleCursor = getStaleCached(name);
1267
1267
  return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured;
1268
1268
  }
1269
- if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
1269
+ if (prov.authMode === "oauth" && !apiKey) {
1270
+ // No usable token (logged out, or account marked needsReauth). Still surface the
1271
+ // configured static catalog so the GUI Models tab / rail counts are not empty —
1272
+ // matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
1273
+ return configured;
1274
+ }
1270
1275
  if (prov.liveModels === false) {
1271
1276
  return configured;
1272
1277
  }
package/src/lib/errors.ts CHANGED
@@ -99,6 +99,9 @@ export function classifyError(status: number, type: string, message: string): Oc
99
99
  ) {
100
100
  return { message, type: "invalid_request_error", code: "context_length_exceeded" };
101
101
  }
102
+ if (text.includes("cursor resource limit exceeded")) {
103
+ return { message, type: "invalid_request_error", code: "tool_catalog_too_large" };
104
+ }
102
105
  if (
103
106
  text.includes("insufficient_quota") ||
104
107
  text.includes("exceeded your current quota") ||
@@ -199,6 +202,7 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
199
202
  const lower = message.toLowerCase();
200
203
  // Client aborts (e.g. mid web-search loop) must not look like upstream 502s in /api/logs.
201
204
  if (isClientClosedMessage(lower)) return 499;
205
+ if (lower.includes("cursor resource limit exceeded")) return 400;
202
206
  if (
203
207
  lower.includes("resource_exhausted") ||
204
208
  lower.includes("resource exhausted") ||
package/src/lib/winsw.ts CHANGED
@@ -18,7 +18,7 @@ import { execFileSync } from "node:child_process";
18
18
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { join, resolve } from "node:path";
21
- import { expandUserPath, getConfigDir } from "../config";
21
+ import { expandUserPath, getConfigDir, loadConfig } from "../config";
22
22
  import { durableBunPath } from "./bun-runtime";
23
23
  import { serviceApiTokenFilePath } from "./service-secrets";
24
24
 
@@ -72,9 +72,20 @@ export interface WinswEntry {
72
72
  * Task Scheduler wrapper / launchd / systemd: the SCM service environment lacks the
73
73
  * user's interactive PATH, which provider subprocesses may need.
74
74
  */
75
- export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env): string {
75
+ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env, port?: number): string {
76
76
  const domain = env.USERDOMAIN?.trim() || ".";
77
77
  const user = env.USERNAME?.trim() || "";
78
+ const listenPort = (() => {
79
+ if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) return Math.trunc(port);
80
+ const baked = env.OCX_BAKE_PORT?.trim();
81
+ if (baked && /^\d+$/.test(baked)) {
82
+ const n = Number(baked);
83
+ if (n > 0 && n <= 65535) return n;
84
+ }
85
+ return loadConfig().port ?? 10100;
86
+ })();
87
+ // Services never bake `--port 0` (parsePortOption rejects it); treat as default.
88
+ const safeListenPort = listenPort > 0 && listenPort <= 65535 ? listenPort : 10100;
78
89
  const envLines = [
79
90
  ` <env name="OCX_SERVICE" value="1"/>`,
80
91
  ` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
@@ -88,7 +99,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
88
99
  <name>OpenCodex Proxy (native)</name>
89
100
  <description>OpenCodex proxy running as a native Windows service (windowless, starts at boot).</description>
90
101
  <executable>${xmlEscape(entry.bun)}</executable>
91
- <arguments>${xmlEscape(`"${entry.cli}" start`)}</arguments>
102
+ <arguments>${xmlEscape(`"${entry.cli}" start --port ${safeListenPort}`)}</arguments>
92
103
  ${envLines.join("\n")}
93
104
  <logpath>${xmlEscape(winswLogDir())}</logpath>
94
105
  <log mode="roll-by-size">
@@ -62,10 +62,10 @@ async function handleOAuthLogin(name: string): Promise<void> {
62
62
  console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
63
63
  }
64
64
 
65
- export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: string): OcxProviderConfig {
65
+ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: string, baseUrlOverride?: string): OcxProviderConfig {
66
66
  return {
67
67
  adapter: def.adapter,
68
- baseUrl: def.baseUrl,
68
+ baseUrl: baseUrlOverride ?? def.baseUrl,
69
69
  apiKey: key,
70
70
  ...(def.defaultModel ? { defaultModel: def.defaultModel } : {}),
71
71
  ...(def.models ? { models: [...def.models] } : {}),
@@ -94,19 +94,30 @@ async function handleKeyLogin(name: string): Promise<void> {
94
94
  openUrl(def.dashboardUrl);
95
95
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
96
96
  const key = (await new Promise<string>((res) => rl.question(`Paste your ${def.label} API key: `, res))).trim();
97
+ // Template URL with placeholders needs resolution before saving.
98
+ let baseUrl = def.baseUrl;
99
+ if (/\{[^}]*\}/.test(baseUrl)) {
100
+ const resolved = (await new Promise<string>((res) => rl.question(`Your endpoint URL (${baseUrl}): `, res))).trim();
101
+ if (!resolved) {
102
+ rl.close();
103
+ console.error("A resolved URL is required — replace the {placeholder} with your actual value.");
104
+ process.exit(1);
105
+ }
106
+ baseUrl = resolved;
107
+ }
97
108
  rl.close();
98
109
  if (!key) {
99
110
  console.error("No key entered.");
100
111
  process.exit(1);
101
112
  }
102
113
  process.stdout.write(" validating… ");
103
- const valid = await validateApiKey(def, key);
114
+ const valid = await validateApiKey({ ...def, baseUrl }, key);
104
115
  console.log(valid === true ? "valid ✅" : valid === false ? "INVALID ❌" : "couldn't validate (may still work)");
105
116
  if (valid === false) {
106
117
  console.error("Provider rejected the key. Not saved.");
107
118
  process.exit(1);
108
119
  }
109
- const provider = providerConfigFromKeyLoginProvider(def, key);
120
+ const provider = providerConfigFromKeyLoginProvider(def, key, baseUrl);
110
121
  const config = loadConfig();
111
122
  config.providers[name] = provider;
112
123
  saveConfig(config);
@@ -21,6 +21,21 @@ export const QWEN_CLOUD_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
21
21
  { id: "custom", label: "Custom" },
22
22
  ];
23
23
 
24
+ /**
25
+ * Alibaba Token Plan International (ap-southeast-1) endpoint presets.
26
+ * Same product as the Beijing Token Plan but for international accounts.
27
+ */
28
+ export const ALIBABA_INTL_TOKEN_PLAN_BASE_URL =
29
+ "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
30
+ export const ALIBABA_INTL_PAYG_BASE_URL =
31
+ "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
32
+
33
+ export const ALIBABA_INTL_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
34
+ { id: "token-plan", label: "Token plan", baseUrl: ALIBABA_INTL_TOKEN_PLAN_BASE_URL },
35
+ { id: "payg", label: "Pay as you go", baseUrl: ALIBABA_INTL_PAYG_BASE_URL },
36
+ { id: "custom", label: "Custom" },
37
+ ];
38
+
24
39
  /** Match a saved baseUrl to a known choice id (`custom` when it does not match). */
25
40
  export function matchBaseUrlChoice(
26
41
  choices: readonly ProviderBaseUrlChoice[],
@@ -2,7 +2,10 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types";
2
2
  import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models";
3
3
  import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS } from "./antigravity-models";
4
4
  import type { ProviderBaseUrlChoice } from "./base-url-choices";
5
- import { QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL } from "./base-url-choices";
5
+ import {
6
+ QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL,
7
+ ALIBABA_INTL_BASE_URL_CHOICES, ALIBABA_INTL_TOKEN_PLAN_BASE_URL,
8
+ } from "./base-url-choices";
6
9
  import {
7
10
  CURSOR_STATIC_MODELS,
8
11
  cursorModelContextWindows,
@@ -199,6 +202,33 @@ const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
199
202
  "glm-5.2": ["text"],
200
203
  "deepseek-v4-pro": ["text"],
201
204
  };
205
+
206
+ // 260721 Alibaba Token Plan International (ap-southeast-1 / Singapore).
207
+ // Multi-vendor lineup distinct from Beijing — includes DeepSeek V4 flash, Kimi K2.7, MiniMax.
208
+ // Evidence: https://www.alibabacloud.com/help/en/model-studio/token-plan-overview
209
+ const ALIBABA_INTL_TOKEN_PLAN_MODELS = [
210
+ "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
211
+ "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2",
212
+ "kimi-k2.7-code",
213
+ "glm-5.2",
214
+ "MiniMax-M2.5",
215
+ ];
216
+ const ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS = [
217
+ "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
218
+ ];
219
+ const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
220
+ "qwen3.7-max": ["text"],
221
+ "qwen3.7-plus": ["text", "image"],
222
+ "qwen3.6-plus": ["text", "image"],
223
+ "qwen3.6-flash": ["text", "image"],
224
+ "deepseek-v4-pro": ["text"],
225
+ "deepseek-v4-flash": ["text"],
226
+ "deepseek-v3.2": ["text"],
227
+ "kimi-k2.7-code": ["text"],
228
+ "glm-5.2": ["text"],
229
+ "MiniMax-M2.5": ["text"],
230
+ };
231
+
202
232
  // 260717 Kimi K3: the subscription endpoint uses one upstream id (`k3`) for both
203
233
  // entitlement tiers. Bare `k3` advertises the Moderato 256K ceiling; the local `[1m]`
204
234
  // alias advertises Allegretto's 1M ceiling and is stripped before the upstream request.
@@ -564,6 +594,39 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
564
594
  preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS,
565
595
  },
566
596
  { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } },
597
+ {
598
+ // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). Model ids are
599
+ // vendor-namespaced (`<vendor>/<model>`) and pass through to the upstream as-is.
600
+ // The default pins a tool-capable model; the adaptive `orcarouter/auto` router is also
601
+ // selectable. Live-verified 2026-07-20: /v1/chat/completions accepts the `tools` field
602
+ // and routes to a function-calling-capable upstream.
603
+ id: "orcarouter", label: "OrcaRouter", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1",
604
+ authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console",
605
+ defaultModel: "openai/gpt-5.5",
606
+ models: [
607
+ "openai/gpt-5.5",
608
+ "anthropic/claude-opus-4.8",
609
+ "google/gemini-3.5-flash",
610
+ "deepseek/deepseek-v4-pro",
611
+ "orcarouter/auto",
612
+ ],
613
+ // Text-only models → the vision sidecar describes images instead.
614
+ noVisionModels: ["deepseek/deepseek-v4-pro"],
615
+ // Reasoning/temperature behavior verified live 2026-07-20 against api.orcarouter.ai:
616
+ // - openai/gpt-5.5 accepts reasoning_effort none|low|medium|high|xhigh but rejects `max` (400),
617
+ // so advertise up to xhigh and let mapReasoningEffort clamp a `max`/`ultra` request to xhigh.
618
+ // - deepseek/deepseek-v4-pro mirrors the direct-DeepSeek wiring (thinking-effort map +
619
+ // reasoning_content history replay) so the namespaced selection behaves identically.
620
+ // - temperature is accepted by every seeded model (gpt-5.5, claude-opus-4.8, deepseek-v4-pro all
621
+ // returned 200), so no noTemperatureModels entry is warranted here.
622
+ modelReasoningEfforts: {
623
+ "openai/gpt-5.5": ["low", "medium", "high", "xhigh"],
624
+ "deepseek/deepseek-v4-pro": DEEPSEEK_THINKING_EFFORTS,
625
+ },
626
+ modelReasoningEffortMap: { "deepseek/deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP },
627
+ preserveReasoningContentModels: ["deepseek/deepseek-v4-pro"],
628
+ note: "OpenAI-compatible adaptive router. Default is a tool-capable model; orcarouter/auto (adaptive routing) is also selectable. Full catalog: https://www.orcarouter.ai/models",
629
+ },
567
630
  { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
568
631
  // 2026-07-10 Gemini API refresh: Tier-2 ai.google.dev evidence recorded in
569
632
  // devlog/_plan/260710_provider_hardening/001_research_frontier.md.
@@ -706,7 +769,36 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
706
769
  },
707
770
  modelReasoningEffortMap: { "deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP },
708
771
  thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS,
709
- preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro"],
772
+ preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "qwen3.8-max-preview"],
773
+ },
774
+ {
775
+ id: "alibaba-token-plan-intl",
776
+ label: "Alibaba Token Plan (International)",
777
+ baseUrl: ALIBABA_INTL_TOKEN_PLAN_BASE_URL,
778
+ adapter: "openai-chat",
779
+ authKind: "key",
780
+ allowBaseUrlOverride: true,
781
+ baseUrlChoices: ALIBABA_INTL_BASE_URL_CHOICES,
782
+ dashboardUrl: "https://modelstudio.console.alibabacloud.com/?tab=api#/api",
783
+ defaultModel: "qwen3.7-max",
784
+ models: ALIBABA_INTL_TOKEN_PLAN_MODELS,
785
+ liveModels: false,
786
+ note: "Token Plan Team Edition · Singapore (ap-southeast-1)",
787
+ modelInputModalities: ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES,
788
+ modelContextWindows: { "deepseek-v4-pro": 1_000_000, "deepseek-v4-flash": 1_000_000, "glm-5.2": 1_000_000 },
789
+ modelReasoningEfforts: {
790
+ ...Object.fromEntries(ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
791
+ "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
792
+ "deepseek-v4-pro": DEEPSEEK_THINKING_EFFORTS,
793
+ "deepseek-v4-flash": DEEPSEEK_THINKING_EFFORTS,
794
+ },
795
+ modelReasoningEffortMap: {
796
+ "deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP,
797
+ "deepseek-v4-flash": DEEPSEEK_THINKING_REASONING_MAP,
798
+ },
799
+ thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS,
800
+ preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.7-max"],
801
+ noVisionModels: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2", "kimi-k2.7-code", "glm-5.2", "MiniMax-M2.5", "qwen3.7-max"],
710
802
  },
711
803
  // NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL,
712
804
  // no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai.
@@ -816,6 +908,25 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
816
908
  note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
817
909
  },
818
910
  { id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
911
+ {
912
+ // Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id}
913
+ // which must be resolved by the user at setup time. Model IDs use the @cf/ prefix.
914
+ // Live-verified 2026-07-21 against https://developers.cloudflare.com/workers-ai/models/
915
+ id: "cloudflare-workers-ai", label: "Cloudflare Workers AI",
916
+ baseUrl: "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1",
917
+ adapter: "openai-chat", authKind: "key", freeTier: true,
918
+ dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/workers-ai",
919
+ defaultModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
920
+ models: [
921
+ "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
922
+ "@cf/qwen/qwq-32b",
923
+ "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
924
+ "@cf/moonshotai/kimi-k2.7-code",
925
+ "@cf/zai-org/glm-5.2",
926
+ "@cf/mistralai/mistral-small-3.1-24b-instruct",
927
+ ],
928
+ note: "Workers AI · Free tier included · Account ID required in base URL",
929
+ },
819
930
  // FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal
820
931
  // exchange (issue #151) unlocks live discovery; static seed is a cold-start fallback only.
821
932
  {
@@ -525,13 +525,18 @@ export function parseRequest(body: unknown): OcxParsedRequest {
525
525
 
526
526
  const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? [];
527
527
  const loadedTools = buildTools(loadedToolSpecs) ?? [];
528
+ const loadedToolNames = new Set(loadedTools.map(t => namespacedToolName(t.namespace, t.name)));
528
529
  const seenTools = new Set<string>();
529
- const mergedTools = [...declaredTools, ...loadedTools].filter(t => {
530
- const k = namespacedToolName(t.namespace, t.name);
531
- if (seenTools.has(k)) return false;
532
- seenTools.add(k);
533
- return true;
534
- });
530
+ const mergedTools = [...declaredTools, ...loadedTools]
531
+ .filter(t => {
532
+ const k = namespacedToolName(t.namespace, t.name);
533
+ if (seenTools.has(k)) return false;
534
+ seenTools.add(k);
535
+ return true;
536
+ })
537
+ .map(t => loadedToolNames.has(namespacedToolName(t.namespace, t.name))
538
+ ? { ...t, loadedFromToolSearch: true }
539
+ : t);
535
540
  const context: OcxContext = {
536
541
  ...(systemPrompt.length > 0 ? { systemPrompt } : {}),
537
542
  messages,
package/src/router.ts CHANGED
@@ -253,9 +253,14 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
253
253
  if (hasOwnProvider(config.providers, provName)) {
254
254
  const prov = config.providers[provName];
255
255
  if (prov.disabled === true) throw new Error(`Provider is disabled: ${provName}`);
256
+ const known = knownModelIdsForProvider(provName, prov);
257
+ // Self-namespaced native id — the vendor segment equals the provider id, so the FULL ref is
258
+ // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
259
+ // remainder, which would send a bare `auto` the upstream cannot resolve.
260
+ if (known.includes(modelId)) return routeResult(provName, prov, modelId);
256
261
  // Codex-facing alias ids (`provider/vendor-model`) decode back to the native
257
262
  // slash id via an exact known-id lookup; raw full-slash selectors keep working.
258
- return routeResult(provName, prov, decodeRoutedModelId(modelId.slice(slash + 1), knownModelIdsForProvider(provName, prov)));
263
+ return routeResult(provName, prov, decodeRoutedModelId(modelId.slice(slash + 1), known));
259
264
  }
260
265
  }
261
266
 
@@ -54,6 +54,7 @@ export {
54
54
  } from "./lifecycle";
55
55
  import {
56
56
  addFinalRequestLog,
57
+ hydrateRequestLogsFromDisk,
57
58
  httpStatusForRequestLogTerminal,
58
59
  httpStatusForTerminalStatus,
59
60
  inspectResponseLogSsePayload,
@@ -65,6 +66,7 @@ import {
65
66
  export {
66
67
  addFinalRequestLog,
67
68
  filterRequestLogs,
69
+ hydrateRequestLogsFromDisk,
68
70
  httpStatusForTerminalStatus,
69
71
  httpStatusFromTerminalError,
70
72
  nextRequestLogId,
@@ -175,6 +177,9 @@ export function startServer(port?: number) {
175
177
  }
176
178
  }
177
179
  invalidateCodexModelsCache();
180
+ // usage.jsonl already persists every request; rehydrate the in-memory Logs ring so
181
+ // /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts.
182
+ hydrateRequestLogsFromDisk();
178
183
 
179
184
  const listenPort = port ?? config.port ?? 10100;
180
185
  setCorsOrigin(listenPort);
@@ -49,14 +49,30 @@ export type FindAvailablePortOptions = {
49
49
  /** How long to keep retrying the preferred port before falling back to an ephemeral port. */
50
50
  preferRetryMs?: number;
51
51
  preferRetryIntervalMs?: number;
52
+ /**
53
+ * When false, never bind `port: 0` — prefer-retry then throw if the preferred port
54
+ * stays busy. Used for explicit `ocx start --port N` and service-baked pins so an
55
+ * update restart cannot hop to a random ephemeral listener (PR #152 gap).
56
+ */
57
+ allowEphemeralFallback?: boolean;
52
58
  };
53
59
 
60
+ export class PortUnavailableError extends Error {
61
+ readonly port: number;
62
+ constructor(port: number, hostname: string) {
63
+ super(`Port ${port} on ${hostname} is still busy after prefer-retry; refusing ephemeral fallback.`);
64
+ this.name = "PortUnavailableError";
65
+ this.port = port;
66
+ }
67
+ }
68
+
54
69
  export async function findAvailablePort(
55
70
  preferredPort: number,
56
71
  hostname = "127.0.0.1",
57
72
  opts: FindAvailablePortOptions = {},
58
73
  ): Promise<number> {
59
74
  const preferRetryMs = opts.preferRetryMs ?? 0;
75
+ const allowEphemeral = opts.allowEphemeralFallback !== false;
60
76
  if (preferRetryMs > 0) {
61
77
  if (await waitForPortAvailable(preferredPort, hostname, {
62
78
  timeoutMs: preferRetryMs,
@@ -68,6 +84,10 @@ export async function findAvailablePort(
68
84
  return preferredPort;
69
85
  }
70
86
 
87
+ if (!allowEphemeral) {
88
+ throw new PortUnavailableError(preferredPort, hostname);
89
+ }
90
+
71
91
  return await new Promise((resolve, reject) => {
72
92
  const server = createServer();
73
93
  server.once("error", reject);