@bitkyc08/opencodex 2.6.17 → 2.6.18

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 (84) hide show
  1. package/README.md +9 -0
  2. package/bin/ocx.mjs +70 -5
  3. package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
  4. package/gui/dist/assets/index-DbTEyo46.js +9 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +9 -2
  8. package/src/adapters/base.ts +6 -0
  9. package/src/adapters/cursor/arg-codec.ts +38 -0
  10. package/src/adapters/cursor/arg-normalize.ts +88 -0
  11. package/src/adapters/cursor/cursor-errors.ts +85 -0
  12. package/src/adapters/cursor/discovery.ts +144 -0
  13. package/src/adapters/cursor/effort-map.ts +74 -0
  14. package/src/adapters/cursor/exec-policy.ts +44 -0
  15. package/src/adapters/cursor/framing.ts +136 -0
  16. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  17. package/src/adapters/cursor/kv-store.ts +25 -0
  18. package/src/adapters/cursor/live-models.ts +93 -0
  19. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  20. package/src/adapters/cursor/live-transport.ts +758 -0
  21. package/src/adapters/cursor/mcp-config.ts +42 -0
  22. package/src/adapters/cursor/mcp-manager.ts +236 -0
  23. package/src/adapters/cursor/message-mapper.ts +46 -0
  24. package/src/adapters/cursor/native-exec-common.ts +55 -0
  25. package/src/adapters/cursor/native-exec-desktop.ts +177 -0
  26. package/src/adapters/cursor/native-exec-fs.ts +284 -0
  27. package/src/adapters/cursor/native-exec-mcp.ts +151 -0
  28. package/src/adapters/cursor/native-exec-network.ts +32 -0
  29. package/src/adapters/cursor/native-exec-shell.ts +191 -0
  30. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  31. package/src/adapters/cursor/native-exec.ts +177 -0
  32. package/src/adapters/cursor/protobuf-events.ts +309 -0
  33. package/src/adapters/cursor/protobuf-request.ts +347 -0
  34. package/src/adapters/cursor/request-builder.ts +98 -0
  35. package/src/adapters/cursor/tool-definitions.ts +301 -0
  36. package/src/adapters/cursor/transport-retry.ts +116 -0
  37. package/src/adapters/cursor/transport.ts +47 -0
  38. package/src/adapters/cursor/types.ts +36 -0
  39. package/src/adapters/cursor.ts +99 -0
  40. package/src/adapters/google.ts +7 -1
  41. package/src/adapters/kiro.ts +15 -0
  42. package/src/adapters/openai-chat.ts +7 -2
  43. package/src/adapters/run-turn-queue.ts +58 -0
  44. package/src/adapters/tool-catalog-nudge.ts +71 -0
  45. package/src/bridge.ts +7 -1
  46. package/src/cli-help.ts +9 -2
  47. package/src/cli-status.ts +7 -5
  48. package/src/cli.ts +122 -79
  49. package/src/codex-catalog.ts +213 -71
  50. package/src/codex-history-provider.ts +31 -14
  51. package/src/codex-inject.ts +17 -9
  52. package/src/codex-paths.ts +2 -1
  53. package/src/codex-shim.ts +30 -7
  54. package/src/codex-sync.ts +70 -0
  55. package/src/config.ts +58 -2
  56. package/src/doctor.ts +4 -2
  57. package/src/index.ts +1 -0
  58. package/src/model-cache.ts +22 -2
  59. package/src/oauth/callback-server.ts +44 -16
  60. package/src/oauth/cursor.ts +188 -0
  61. package/src/oauth/index.ts +29 -3
  62. package/src/oauth/key-providers.ts +20 -33
  63. package/src/oauth/login-cli.ts +7 -4
  64. package/src/open-url.ts +5 -1
  65. package/src/ports.ts +13 -0
  66. package/src/process-control.ts +76 -0
  67. package/src/provider-label.ts +10 -5
  68. package/src/providers/derive.ts +30 -3
  69. package/src/providers/registry.ts +39 -1
  70. package/src/proxy-liveness.ts +122 -0
  71. package/src/responses/parser.ts +1 -0
  72. package/src/responses/state.ts +83 -0
  73. package/src/router.ts +38 -23
  74. package/src/server/adapter-resolve.ts +3 -0
  75. package/src/server.ts +130 -18
  76. package/src/service.ts +94 -32
  77. package/src/types.ts +24 -1
  78. package/src/update-job.ts +360 -0
  79. package/src/update.ts +73 -11
  80. package/src/usage-log.ts +3 -3
  81. package/src/usage-summary.ts +3 -2
  82. package/src/win-paths.ts +68 -0
  83. package/gui/dist/assets/index-DIBiVVC0.css +0 -1
  84. package/gui/dist/assets/index-DcnD944i.js +0 -9
@@ -1,5 +1,5 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
- import { deriveKeyLoginMap } from "../providers/derive";
2
+ import { deriveKeyLoginMap, enrichProviderFromRegistry } from "../providers/derive";
3
3
 
4
4
  /**
5
5
  * API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
@@ -14,6 +14,7 @@ export interface KeyLoginProvider {
14
14
  /** Where the user creates/copies the API key. */
15
15
  dashboardUrl: string;
16
16
  models?: string[];
17
+ liveModels?: boolean;
17
18
  defaultModel?: string;
18
19
  contextWindow?: number;
19
20
  modelContextWindows?: Record<string, number>;
@@ -35,45 +36,19 @@ export interface KeyLoginProvider {
35
36
  autoToolChoiceOnlyModels?: string[];
36
37
  preserveReasoningContentModels?: string[];
37
38
  escapeBuiltinToolNames?: boolean;
39
+ googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
38
40
  }
39
41
 
40
42
  export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLoginMap();
41
43
 
42
44
  /**
43
- * Copy a key-login catalog entry's seed/classification (`models`, `noVisionModels`,
44
- * `noReasoningModels`, `defaultModel`) onto a provider config being created, for any field the caller
45
- * didn't already supply. Lets the vision/reasoning classification actually reach the saved config
46
- * (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for non-catalog provider names.
45
+ * Copy a registry entry's seed/classification (`models`, `liveModels`, `noVisionModels`,
46
+ * `noReasoningModels`, `defaultModel`) onto a provider config being created, for any field the
47
+ * caller didn't already supply. Lets the vision/reasoning classification actually reach the saved
48
+ * config (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for unknown names.
47
49
  */
48
50
  export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig): void {
49
- const e = KEY_LOGIN_PROVIDERS[name];
50
- if (!e) return;
51
- if (!prov.models && e.models) prov.models = [...e.models];
52
- if (!prov.defaultModel && e.defaultModel) prov.defaultModel = e.defaultModel;
53
- if (prov.contextWindow === undefined && e.contextWindow !== undefined) prov.contextWindow = e.contextWindow;
54
- if (!prov.modelContextWindows && e.modelContextWindows) prov.modelContextWindows = { ...e.modelContextWindows };
55
- if (!prov.modelInputModalities && e.modelInputModalities) prov.modelInputModalities = cloneRecordOfArrays(e.modelInputModalities);
56
- if (!prov.reasoningEfforts && e.reasoningEfforts) prov.reasoningEfforts = [...e.reasoningEfforts];
57
- if (!prov.modelReasoningEfforts && e.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(e.modelReasoningEfforts);
58
- if (!prov.reasoningEffortMap && e.reasoningEffortMap) prov.reasoningEffortMap = { ...e.reasoningEffortMap };
59
- if (!prov.modelReasoningEffortMap && e.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(e.modelReasoningEffortMap);
60
- if (!prov.noVisionModels && e.noVisionModels) prov.noVisionModels = [...e.noVisionModels];
61
- if (!prov.noReasoningModels && e.noReasoningModels) prov.noReasoningModels = [...e.noReasoningModels];
62
- if (!prov.noTemperatureModels && e.noTemperatureModels) prov.noTemperatureModels = [...e.noTemperatureModels];
63
- if (!prov.noTopPModels && e.noTopPModels) prov.noTopPModels = [...e.noTopPModels];
64
- if (!prov.noPenaltyModels && e.noPenaltyModels) prov.noPenaltyModels = [...e.noPenaltyModels];
65
- if (!prov.autoToolChoiceOnlyModels && e.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...e.autoToolChoiceOnlyModels];
66
- if (!prov.preserveReasoningContentModels && e.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...e.preserveReasoningContentModels];
67
- if (prov.escapeBuiltinToolNames === undefined && e.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = e.escapeBuiltinToolNames;
68
- }
69
-
70
-
71
- function cloneRecordOfArrays(input: Record<string, string[]>): Record<string, string[]> {
72
- return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, [...value]]));
73
- }
74
-
75
- function cloneNestedRecord(input: Record<string, Record<string, string>>): Record<string, Record<string, string>> {
76
- return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { ...value }]));
51
+ enrichProviderFromRegistry(name, prov);
77
52
  }
78
53
 
79
54
  export function isKeyLoginProvider(name: string): boolean {
@@ -108,6 +83,18 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P
108
83
  return "unknown";
109
84
  }
110
85
 
86
+ if (provider.adapter === "google" && (provider.googleMode ?? "ai-studio") === "ai-studio") {
87
+ // Generative Language API rejects Bearer-wrapped API keys; probe models.list with the
88
+ // documented x-goog-api-key header instead (pageSize=1 — validation only needs a 200).
89
+ const res = await fetch(`${provider.baseUrl}/v1beta/models?pageSize=1`, {
90
+ headers: { "x-goog-api-key": key },
91
+ signal: AbortSignal.timeout(8000),
92
+ });
93
+ if (res.ok) return true;
94
+ if (res.status === 400 || res.status === 401 || res.status === 403) return false;
95
+ return "unknown";
96
+ }
97
+
111
98
  const res = await fetch(`${provider.baseUrl}/models`, {
112
99
  headers: { Authorization: `Bearer ${key}` },
113
100
  signal: AbortSignal.timeout(8000),
@@ -1,6 +1,7 @@
1
1
  import * as readline from "node:readline";
2
2
  import { openUrl } from "../open-url";
3
- import { loadConfig, readPid, saveConfig } from "../config";
3
+ import { loadConfig, saveConfig } from "../config";
4
+ import { findLiveProxy, probeHostname } from "../proxy-liveness";
4
5
  import { OAUTH_PROVIDERS, runLogin } from "./index";
5
6
  import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
6
7
  import type { OcxProviderConfig } from "../types";
@@ -14,10 +15,12 @@ export function runningProxyUpdateHeaders(): Headers {
14
15
 
15
16
  /** Push the new provider into a running proxy's live config so it routes without a restart. */
16
17
  async function notifyRunningProxy(name: string, provider: unknown): Promise<void> {
17
- if (!readPid()) return;
18
- const cfg = loadConfig();
18
+ // Identity-checked runtime-port lookup: reaches a fallback-port proxy and avoids
19
+ // posting credentials-adjacent config to whatever else answers on config.port.
20
+ const live = await findLiveProxy();
21
+ if (!live) return;
19
22
  try {
20
- await fetch(`http://localhost:${cfg.port}/api/providers`, {
23
+ await fetch(`http://${probeHostname(live.hostname)}:${live.port}/api/providers`, {
21
24
  method: "POST",
22
25
  headers: runningProxyUpdateHeaders(),
23
26
  body: JSON.stringify({ name, provider }),
package/src/open-url.ts CHANGED
@@ -17,5 +17,9 @@ export function openUrl(url: string): void {
17
17
  const args = process.platform === "win32"
18
18
  ? ["url.dll,FileProtocolHandler", url]
19
19
  : [url];
20
- spawn(cmd, args, { detached: true, stdio: "ignore", shell: false }).unref();
20
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
21
+ // Headless hosts (no xdg-open) emit ENOENT as an async 'error' event; without a
22
+ // listener that is an uncaught exception that kills the whole proxy/login flow.
23
+ child.on("error", () => {});
24
+ child.unref();
21
25
  }
package/src/ports.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  import { createServer } from "node:net";
2
2
 
3
+ /**
4
+ * True when an error means "this port/address is already bound" — the only bind failure
5
+ * that is safe to answer with a retry on another port. Bun/Node surface it as
6
+ * `code: "EADDRINUSE"` or an EADDRINUSE / "address in use" message depending on the API.
7
+ */
8
+ export function isAddrInUse(err: unknown): boolean {
9
+ if (!err || typeof err !== "object") return false;
10
+ const { code, message } = err as { code?: unknown; message?: unknown };
11
+ if (code === "EADDRINUSE") return true;
12
+ const text = typeof message === "string" ? message.toLowerCase() : "";
13
+ return text.includes("eaddrinuse") || text.includes("in use");
14
+ }
15
+
3
16
  export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
4
17
  return await new Promise(resolve => {
5
18
  const server = createServer();
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { loadConfig, readRuntimePort } from "./config";
2
3
 
3
4
  export function isProcessAlive(pid: number): boolean {
4
5
  try {
@@ -19,6 +20,81 @@ export function waitForExit(pid: number, timeoutMs: number): boolean {
19
20
  return !isProcessAlive(pid);
20
21
  }
21
22
 
23
+ /** Injectable seams so the graceful-stop flow is unit-testable without a live proxy. */
24
+ export interface GracefulStopIo {
25
+ fetchFn?: typeof fetch;
26
+ readRuntime?: (pid: number) => { port: number; hostname?: string } | null;
27
+ waitExit?: (pid: number, timeoutMs: number) => boolean;
28
+ env?: Record<string, string | undefined>;
29
+ exitTimeoutMs?: number;
30
+ }
31
+
32
+ /**
33
+ * Host to POST /api/stop against: follow the recorded bind hostname when it names a
34
+ * concrete address (a proxy bound to ::1 or a LAN IP is unreachable on 127.0.0.1);
35
+ * loopback aliases and wildcard binds all answer on IPv4 loopback.
36
+ */
37
+ export function gracefulStopHost(hostname: string | undefined): string {
38
+ const trimmed = (hostname ?? "").trim();
39
+ const lower = trimmed.toLowerCase();
40
+ if (!trimmed || lower === "localhost" || trimmed === "127.0.0.1" || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") {
41
+ return "127.0.0.1";
42
+ }
43
+ if (lower === "::1" || lower === "[::1]") return "[::1]";
44
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
45
+ return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
46
+ }
47
+
48
+ /**
49
+ * Ask a running proxy to stop itself via the management API (`POST /api/stop`), which
50
+ * drains in-flight turns, restores native Codex, and cleans its pid/runtime files.
51
+ * This is the only way to get a GRACEFUL stop on Windows, where the POSIX
52
+ * SIGTERM-then-SIGKILL ladder does not exist and `taskkill /F` gives the proxy no
53
+ * chance to run its shutdown handlers. Returns false when the proxy can't be reached
54
+ * or doesn't exit in time — callers fall back to {@link killProxy}.
55
+ */
56
+ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<boolean> {
57
+ const readRuntime = io.readRuntime ?? readRuntimePort;
58
+ const runtime = readRuntime(pid);
59
+ if (!runtime?.port) return false;
60
+ const env = io.env ?? process.env;
61
+ const headers: Record<string, string> = {};
62
+ // Non-loopback binds require management auth; loopback ignores the extra header.
63
+ const token = env.OPENCODEX_API_AUTH_TOKEN?.trim();
64
+ if (token) headers["x-opencodex-api-key"] = token;
65
+ const fetchFn = io.fetchFn ?? fetch;
66
+ try {
67
+ const res = await fetchFn(`http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`, {
68
+ method: "POST",
69
+ headers,
70
+ signal: AbortSignal.timeout(2000),
71
+ });
72
+ if (!res.ok) return false;
73
+ } catch {
74
+ return false;
75
+ }
76
+ const waitExit = io.waitExit ?? waitForExit;
77
+ // Honor the server's own drain window: /api/stop answers 200 first, then drains for
78
+ // config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain.
79
+ const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs();
80
+ return waitExit(pid, exitTimeoutMs);
81
+ }
82
+
83
+ function drainDeadlineMs(): number {
84
+ try {
85
+ return (loadConfig().shutdownTimeoutMs ?? 5000) + 3000;
86
+ } catch {
87
+ return 8000;
88
+ }
89
+ }
90
+
91
+ /** Graceful-first stop: management-API drain, then the platform kill ladder. */
92
+ export async function stopProxy(pid: number): Promise<void> {
93
+ if (!isProcessAlive(pid)) return;
94
+ if (await stopProxyGracefully(pid)) return;
95
+ killProxy(pid);
96
+ }
97
+
22
98
  export function killProxy(pid: number): void {
23
99
  if (!isProcessAlive(pid)) return;
24
100
  if (process.platform === "win32") {
@@ -1,12 +1,17 @@
1
1
  import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./codex-account-label";
2
2
 
3
+ function canonicalUsageProviderLabel(provider: string): string {
4
+ return provider === "chatgpt" ? "openai" : provider;
5
+ }
6
+
3
7
  export function baseProviderLabel(provider: string): string {
4
8
  const cut = provider.lastIndexOf("-");
5
- if (cut <= 0) return provider;
9
+ if (cut <= 0) return canonicalUsageProviderLabel(provider);
6
10
  const suffix = provider.slice(cut + 1);
7
11
  // `-main` is the legacy log label for the main Codex account (MAIN_CODEX_ACCOUNT_ID). New entries
8
- // log under the base provider name, but historical `<provider>-main` entries must still collapse so
9
- // the usage table aggregates the main account into a single row instead of a stray split.
10
- if (suffix === "main") return provider.slice(0, cut);
11
- return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? provider.slice(0, cut) : provider;
12
+ // log under the base provider name, but historical `<provider>-main` entries must still collapse.
13
+ // ChatGPT auth-pool and OpenAI passthrough are the same Codex/OpenAI usage surface, so display
14
+ // summaries normalize them to one `openai` row after recognized main/pool suffixes are removed.
15
+ if (suffix === "main") return canonicalUsageProviderLabel(provider.slice(0, cut));
16
+ return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? canonicalUsageProviderLabel(provider.slice(0, cut)) : provider;
12
17
  }
@@ -7,6 +7,7 @@ export interface DerivedKeyLoginProvider {
7
7
  adapter: string;
8
8
  dashboardUrl: string;
9
9
  models?: string[];
10
+ liveModels?: boolean;
10
11
  defaultModel?: string;
11
12
  contextWindow?: number;
12
13
  modelContextWindows?: Record<string, number>;
@@ -41,7 +42,7 @@ export interface DerivedProviderPreset {
41
42
  adapter: string;
42
43
  baseUrl: string;
43
44
  defaultModel?: string;
44
- auth: "oauth" | "forward" | "key";
45
+ auth: "oauth" | "forward" | "key" | "local";
45
46
  oauthProvider?: string;
46
47
  dashboardUrl?: string;
47
48
  note?: string;
@@ -66,6 +67,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
66
67
  authMode: entry.authKind === "local" ? undefined : entry.authKind,
67
68
  ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
68
69
  ...(entry.models ? { models: [...entry.models] } : {}),
70
+ ...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
69
71
  ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
70
72
  ...(entry.modelContextWindows ? { modelContextWindows: { ...entry.modelContextWindows } } : {}),
71
73
  ...(entry.modelInputModalities ? { modelInputModalities: cloneRecordOfArrays(entry.modelInputModalities) } : {}),
@@ -98,6 +100,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
98
100
  adapter: entry.adapter,
99
101
  dashboardUrl: entry.dashboardUrl,
100
102
  ...(entry.models ? { models: [...entry.models] } : {}),
103
+ ...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
101
104
  ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
102
105
  ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
103
106
  ...(entry.modelContextWindows ? { modelContextWindows: { ...entry.modelContextWindows } } : {}),
@@ -149,11 +152,35 @@ export function deriveOAuthIds(): string[] {
149
152
 
150
153
  export function deriveProviderPresets(): DerivedProviderPreset[] {
151
154
  const presets = PROVIDER_REGISTRY
152
- .filter(entry => entry.featured || entry.authKind === "key")
155
+ .filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset)
153
156
  .map(entryToPreset);
154
157
  return [...dedupePresets(presets), customPreset()];
155
158
  }
156
159
 
160
+ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig): void {
161
+ const entry = PROVIDER_REGISTRY.find(row => row.id === name);
162
+ if (!entry) return;
163
+ const seed = providerConfigSeed(entry);
164
+ if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel;
165
+ if (!prov.models && seed.models) prov.models = [...seed.models];
166
+ if (prov.liveModels === undefined && seed.liveModels !== undefined) prov.liveModels = seed.liveModels;
167
+ if (prov.contextWindow === undefined && seed.contextWindow !== undefined) prov.contextWindow = seed.contextWindow;
168
+ if (!prov.modelContextWindows && seed.modelContextWindows) prov.modelContextWindows = { ...seed.modelContextWindows };
169
+ if (!prov.modelInputModalities && seed.modelInputModalities) prov.modelInputModalities = cloneRecordOfArrays(seed.modelInputModalities);
170
+ if (!prov.reasoningEfforts && seed.reasoningEfforts) prov.reasoningEfforts = [...seed.reasoningEfforts];
171
+ if (!prov.modelReasoningEfforts && seed.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(seed.modelReasoningEfforts);
172
+ if (!prov.reasoningEffortMap && seed.reasoningEffortMap) prov.reasoningEffortMap = { ...seed.reasoningEffortMap };
173
+ if (!prov.modelReasoningEffortMap && seed.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(seed.modelReasoningEffortMap);
174
+ if (!prov.noVisionModels && seed.noVisionModels) prov.noVisionModels = [...seed.noVisionModels];
175
+ if (!prov.noReasoningModels && seed.noReasoningModels) prov.noReasoningModels = [...seed.noReasoningModels];
176
+ if (!prov.noTemperatureModels && seed.noTemperatureModels) prov.noTemperatureModels = [...seed.noTemperatureModels];
177
+ if (!prov.noTopPModels && seed.noTopPModels) prov.noTopPModels = [...seed.noTopPModels];
178
+ if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
179
+ if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
180
+ if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
181
+ if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
182
+ }
183
+
157
184
  export function deriveFeaturedProviderIds(): string[] {
158
185
  return PROVIDER_REGISTRY.filter(entry => entry.featured).map(entry => entry.id);
159
186
  }
@@ -181,7 +208,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
181
208
  label: entry.label,
182
209
  adapter: entry.adapter,
183
210
  baseUrl: entry.baseUrl,
184
- auth: entry.authKind === "forward" ? "forward" : entry.authKind === "oauth" ? "oauth" : "key",
211
+ auth: entry.authKind === "forward" ? "forward" : entry.authKind === "oauth" ? "oauth" : entry.authKind === "local" ? "local" : "key",
185
212
  ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
186
213
  ...(entry.authKind === "oauth" ? { oauthProvider: entry.oauthId ?? entry.id } : {}),
187
214
  ...(entry.dashboardUrl ? { dashboardUrl: entry.dashboardUrl } : {}),
@@ -1,6 +1,13 @@
1
1
  import type { 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
+ import {
5
+ CURSOR_STATIC_MODELS,
6
+ cursorModelContextWindows,
7
+ cursorModelIds,
8
+ cursorModelInputModalities,
9
+ cursorModelReasoningEfforts,
10
+ } from "../adapters/cursor/discovery";
4
11
 
5
12
  export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
6
13
  export type MetadataModelIdNormalize = "case-insensitive";
@@ -12,10 +19,12 @@ export interface ProviderRegistryEntry {
12
19
  baseUrl: string;
13
20
  authKind: ProviderAuthKind;
14
21
  featured?: boolean;
22
+ dashboardPreset?: boolean;
15
23
  note?: string;
16
24
  dashboardUrl?: string;
17
25
  defaultModel?: string;
18
26
  models?: string[];
27
+ liveModels?: boolean;
19
28
  contextWindow?: number;
20
29
  modelContextWindows?: Record<string, number>;
21
30
  modelInputModalities?: Record<string, string[]>;
@@ -43,7 +52,7 @@ export interface ProviderRegistryEntry {
43
52
  export type ProviderConfigSeed = Pick<
44
53
  OcxProviderConfig,
45
54
  "adapter" | "baseUrl" | "authMode" | "defaultModel" | "models"
46
- | "contextWindow" | "modelContextWindows" | "modelInputModalities"
55
+ | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities"
47
56
  | "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
48
57
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
49
58
  | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "escapeBuiltinToolNames"
@@ -115,6 +124,22 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
115
124
  featured: true,
116
125
  note: "Uses your codex login — no API key",
117
126
  },
127
+ {
128
+ id: "cursor",
129
+ label: "Cursor (experimental, live native exec)",
130
+ adapter: "cursor",
131
+ baseUrl: "https://api2.cursor.sh",
132
+ authKind: "oauth",
133
+ featured: false,
134
+ dashboardPreset: true,
135
+ note: "Experimental Cursor bridge. Live transport, live model discovery, and native read/write/delete/shell execution are enabled after a standalone PKCE browser login via 'ocx login cursor'.",
136
+ models: cursorModelIds(CURSOR_STATIC_MODELS),
137
+ liveModels: true,
138
+ defaultModel: "auto",
139
+ modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS),
140
+ modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS),
141
+ modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS),
142
+ },
118
143
  {
119
144
  id: "xai",
120
145
  label: "xAI Grok",
@@ -355,3 +380,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
355
380
  export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined {
356
381
  return PROVIDER_REGISTRY.find(entry => entry.id === id);
357
382
  }
383
+
384
+ /**
385
+ * Effective Google wire mode for a provider: config value, else registry backfill (a saved
386
+ * key-login config may omit `googleMode` — mirrors the router's backfill), else "ai-studio"
387
+ * (the Generative Language API default). Null for non-google adapters.
388
+ */
389
+ export function effectiveGoogleMode(
390
+ providerId: string,
391
+ prov: { adapter?: string; googleMode?: "ai-studio" | "vertex" | "cloud-code-assist" },
392
+ ): "ai-studio" | "vertex" | "cloud-code-assist" | null {
393
+ if (prov.adapter !== "google") return null;
394
+ return prov.googleMode ?? getProviderRegistryEntry(providerId)?.googleMode ?? "ai-studio";
395
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Runtime-state-first proxy liveness with identity checking.
3
+ *
4
+ * Historically `ensure`/`start` probed only `config.port` and accepted ANY 2xx /healthz:
5
+ * a proxy that started on a fallback port was invisible (duplicate starts, Codex synced
6
+ * back to a dead port), and a foreign app answering 200 on the configured port counted
7
+ * as "our proxy". Liveness now (1) prefers the pid + runtime-port record and (2) requires
8
+ * the /healthz body to identify as opencodex.
9
+ *
10
+ * Lives outside cli.ts (which dispatches argv at module top level) so tests can import it.
11
+ */
12
+ import { loadConfig, readPid, readRuntimePort } from "./config";
13
+
14
+ export interface HealthzIdentity {
15
+ service?: unknown;
16
+ status?: unknown;
17
+ version?: unknown;
18
+ uptime?: unknown;
19
+ pid?: unknown;
20
+ }
21
+
22
+ export interface LivenessIo {
23
+ fetchFn?: typeof fetch;
24
+ readPidFn?: () => number | null;
25
+ readRuntimeFn?: (pid?: number) => { pid?: number; port: number; hostname?: string } | null;
26
+ configFn?: () => { port?: number; hostname?: string };
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ export interface LiveProxy {
31
+ pid: number | null;
32
+ port: number;
33
+ /** Raw bind hostname the probe succeeded against; compose URLs via `probeHostname`. */
34
+ hostname?: string;
35
+ }
36
+
37
+ /**
38
+ * Host to probe for a given bind hostname: wildcards answer on IPv4 loopback, and raw
39
+ * IPv6 addresses must be bracketed or the composed URL is invalid.
40
+ */
41
+ export function probeHostname(hostname: string | undefined): string {
42
+ const trimmed = (hostname ?? "").trim();
43
+ if (!trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
44
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
45
+ return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
46
+ }
47
+
48
+ /**
49
+ * True when a /healthz body identifies an opencodex proxy. Accepts the explicit
50
+ * `service: "opencodex"` marker, plus the legacy `{status, version, uptime}` trio so a
51
+ * still-running pre-identity proxy (e.g. right after `ocx update`) is not mistaken for a
52
+ * foreign server and shadow-started over.
53
+ */
54
+ export function isOpencodexHealthz(body: HealthzIdentity | null): boolean {
55
+ if (!body) return false;
56
+ if (body.service === "opencodex") return true;
57
+ if (body.service !== undefined) return false;
58
+ return body.status === "ok" && typeof body.version === "string" && typeof body.uptime === "number";
59
+ }
60
+
61
+ /** Identity-checked /healthz probe; null when unreachable, non-OK, or not our proxy. */
62
+ export async function proxyIdentityAt(
63
+ port: number,
64
+ opts: { hostname?: string; expectedPid?: number } = {},
65
+ io: LivenessIo = {},
66
+ ): Promise<{ pid: number | null } | null> {
67
+ const fetchFn = io.fetchFn ?? fetch;
68
+ try {
69
+ const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
70
+ signal: AbortSignal.timeout(io.timeoutMs ?? 750),
71
+ });
72
+ if (!res.ok) return null;
73
+ const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
74
+ if (!isOpencodexHealthz(body)) return null;
75
+ const pid = typeof body?.pid === "number" ? body.pid : null;
76
+ if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
77
+ return { pid };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Locate the live proxy: pid file → runtime-port record → identity probe. Falls back to
85
+ * the configured port ONLY when no runtime record answers, so a fallback-port proxy is
86
+ * found and a foreign listener on the configured port is rejected.
87
+ */
88
+ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | null> {
89
+ const readPidFn = io.readPidFn ?? readPid;
90
+ const readRuntimeFn = io.readRuntimeFn ?? readRuntimePort;
91
+ const configFn = io.configFn ?? loadConfig;
92
+
93
+ const pid = readPidFn();
94
+ let probedPort: number | null = null;
95
+ if (pid) {
96
+ const runtime = readRuntimeFn(pid);
97
+ if (runtime?.port) {
98
+ probedPort = runtime.port;
99
+ const identity = await proxyIdentityAt(runtime.port, { hostname: runtime.hostname, expectedPid: pid }, io);
100
+ if (identity) return { pid, port: runtime.port, hostname: runtime.hostname };
101
+ }
102
+ }
103
+
104
+ // Orphan recovery: the pid file can be lost/corrupt while the proxy is alive (crash of a
105
+ // sibling command, manual deletion). The runtime record still says where it listens —
106
+ // identity-probe it so ensure/update/stop see the live proxy instead of shadowing it.
107
+ const record = readRuntimeFn();
108
+ if (record?.port && record.port !== probedPort) {
109
+ const expectedPid = typeof record.pid === "number" ? record.pid : undefined;
110
+ const identity = await proxyIdentityAt(record.port, { hostname: record.hostname, expectedPid }, io);
111
+ // Only the healthz-reported pid is authoritative here. The record's pid may be stale
112
+ // (its process dead, the port reused by a pidless legacy proxy) — synthesizing it
113
+ // would hand destructive callers (stopProxy → kill fallback) a reusable pid.
114
+ if (identity) return { pid: identity.pid ?? null, port: record.port, hostname: record.hostname };
115
+ }
116
+
117
+ const config = configFn();
118
+ const port = config.port ?? 10100;
119
+ const identity = await proxyIdentityAt(port, { hostname: config.hostname }, io);
120
+ if (identity) return { pid: identity.pid ?? pid ?? null, port, hostname: config.hostname };
121
+ return null;
122
+ }
@@ -388,6 +388,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
388
388
  }
389
389
  const tc = mapToolChoice(data.tool_choice);
390
390
  if (tc !== undefined) options.toolChoice = tc;
391
+ if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls;
391
392
  if (data.reasoning?.effort && REASONING_EFFORTS.has(data.reasoning.effort)) {
392
393
  options.reasoning = data.reasoning.effort;
393
394
  }
@@ -0,0 +1,83 @@
1
+ const MAX_STORED_RESPONSES = 1_000;
2
+ const RESPONSE_TTL_MS = 60 * 60 * 1_000;
3
+
4
+ interface StoredResponseState {
5
+ createdAt: number;
6
+ items: unknown[];
7
+ conversationId?: string;
8
+ cursorCheckpointUsable?: boolean;
9
+ }
10
+
11
+ const states = new Map<string, StoredResponseState>();
12
+
13
+ function now(): number {
14
+ return Date.now();
15
+ }
16
+
17
+ function inputItems(input: unknown): unknown[] {
18
+ if (input === undefined) return [];
19
+ if (Array.isArray(input)) return input;
20
+ if (typeof input === "string") return [{ role: "user", content: input }];
21
+ return [input];
22
+ }
23
+
24
+ function pruneResponses(at = now()): void {
25
+ for (const [id, state] of states) {
26
+ if (at - state.createdAt > RESPONSE_TTL_MS) states.delete(id);
27
+ }
28
+ while (states.size > MAX_STORED_RESPONSES) {
29
+ const oldest = states.keys().next().value;
30
+ if (!oldest) break;
31
+ states.delete(oldest);
32
+ }
33
+ }
34
+
35
+ export function expandPreviousResponseInput(body: unknown): unknown {
36
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
37
+ const request = body as Record<string, unknown>;
38
+ const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
39
+ if (!previousId) return body;
40
+ pruneResponses();
41
+ const previous = states.get(previousId);
42
+ if (!previous) return body;
43
+ return {
44
+ ...request,
45
+ input: [...previous.items, ...inputItems(request.input)],
46
+ };
47
+ }
48
+
49
+ export function previousResponseConversationId(responseId: string | undefined): string | undefined {
50
+ if (!responseId) return undefined;
51
+ pruneResponses();
52
+ return states.get(responseId)?.conversationId;
53
+ }
54
+
55
+ export function rememberResponseState(
56
+ requestBody: unknown,
57
+ response: { id?: unknown; output?: unknown; status?: unknown },
58
+ conversationId?: string,
59
+ ): void {
60
+ if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
61
+ const request = requestBody as Record<string, unknown>;
62
+ if (request.store === false) return;
63
+ if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
64
+ if (response.status !== undefined && response.status !== "completed") return;
65
+ states.set(response.id, {
66
+ createdAt: now(),
67
+ items: [...inputItems(request.input), ...response.output],
68
+ // Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
69
+ // Cursor conversation (multi-turn continuation). Separately track whether Cursor's own
70
+ // checkpoint/cache is safe to reuse: a turn that ended with a pending client tool call produced an
71
+ // incomplete agent turn on the Cursor side (we suspended without a real mcpResult), so its
72
+ // checkpoint must not be reused — but the conversation id string itself is still valid.
73
+ ...(conversationId ? { conversationId } : {}),
74
+ cursorCheckpointUsable: !response.output.some(item => {
75
+ return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
76
+ }),
77
+ });
78
+ pruneResponses();
79
+ }
80
+
81
+ export function clearResponseStateForTests(): void {
82
+ states.clear();
83
+ }