@koreanhole/opencodex 2.96.0 → 2.97.0

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/AGENTS_INSTALL.md CHANGED
@@ -23,11 +23,17 @@ To use Gemini instead, authenticate it through its official CLI and select the
23
23
  provider:
24
24
 
25
25
  ```bash
26
- gemini # official login or existing GEMINI_API_KEY configuration
26
+ gemini # configure a Gemini Developer API or Vertex AI API key
27
27
  ocx provider add gemini-cli --set-default
28
28
  ocx start
29
+ ocx codex sync
29
30
  ```
30
31
 
32
+ `ocx codex sync` refreshes the Codex app catalog with locally discovered Gemini
33
+ models and reasoning levels. Restart the Codex app/app-server after refresh. The
34
+ catalog file is written only when `model_catalog_json` names an opencodex catalog
35
+ or through the documented default path; unrelated Codex settings are not changed.
36
+
31
37
  ## Credential rules
32
38
 
33
39
  - Use the operator's existing Cline or Gemini CLI sign-in. Do not copy vendor
@@ -37,7 +43,12 @@ ocx start
37
43
  - Do not edit the operator's real Cline provider store unless the operator asks.
38
44
  - Do not import or replay Gemini OAuth/API-key material; the Gemini CLI remains
39
45
  the authentication authority.
40
- - The proxy writes only under `$OPENCODEX_HOME` (default `~/.opencodex`).
46
+ - Third-party use must use a Gemini Developer API key or Vertex AI GenAI API key.
47
+ Gemini CLI's official FAQ identifies third-party use of its personal Google
48
+ OAuth authentication as a terms violation. OAuth is not a supported
49
+ opencodex setup path.
50
+ - The proxy writes under `$OPENCODEX_HOME` (default `~/.opencodex`) and may update
51
+ the active opencodex Codex model catalog selected by `model_catalog_json`.
41
52
 
42
53
  ## Operation
43
54
 
@@ -59,6 +70,7 @@ bun run typecheck
59
70
  bun test tests/e2e/cline-only.test.ts
60
71
  bun run test:e2e:gemini
61
72
  bun run test:e2e:gemini-tool
73
+ bun run test:e2e:gemini-reasoning
62
74
  ```
63
75
 
64
76
  Report whether each evidence path used a deterministic ACP fixture or a real
package/README.md CHANGED
@@ -17,13 +17,19 @@ npm install -g cline
17
17
  cline auth
18
18
 
19
19
  npm install -g @google/gemini-cli
20
- gemini # use the official login or configure GEMINI_API_KEY
20
+ gemini # configure GEMINI_API_KEY before using it through opencodex
21
21
  ```
22
22
 
23
23
  Gemini authentication remains in Gemini CLI's own home/settings. The proxy never
24
24
  copies, logs, or stores an API key, OAuth token, bearer token, or full request
25
25
  body.
26
26
 
27
+ For third-party clients such as opencodex, use a Gemini Developer API key
28
+ (Google AI Studio) or a Vertex AI GenAI API key. Do not use Gemini CLI's
29
+ personal Google OAuth flow: Gemini CLI's official FAQ identifies third-party
30
+ software piggybacking on its OAuth authentication as a terms violation and says
31
+ the supported third-party method is an API key.
32
+
27
33
  ## Install
28
34
 
29
35
  ```bash
@@ -56,6 +62,7 @@ name = "opencodex (Cline or Gemini CLI)"
56
62
  base_url = "http://127.0.0.1:10100/v1"
57
63
  wire_api = "responses"
58
64
  requires_openai_auth = false
65
+ model_catalog_url = "http://127.0.0.1:10100/v1/model-catalog"
59
66
  ```
60
67
 
61
68
  The model list is live. Cline ACP models are exposed as `cline-cli/<encoded-id>`;
@@ -63,6 +70,20 @@ Gemini ACP models are exposed as `gemini-cli/<encoded-id>`. Each provider keeps
63
70
  `default` selector. A provider prefix routes only to that vendor; unprefixed
64
71
  `default` uses the configured default provider.
65
72
 
73
+ Gemini model IDs and reasoning ladders are discovered from the installed Gemini
74
+ CLI. Thinking-capable models expose the locally supported levels; non-thinking
75
+ models do not get an invented ladder. For a running Codex app, refresh the active
76
+ catalog and reload the app/app-server:
77
+
78
+ ```bash
79
+ bun run src/cli/index.ts codex sync
80
+ ```
81
+
82
+ The command updates the root `model_catalog_json` target when it names an
83
+ opencodex catalog, otherwise it writes `$CODEX_HOME/opencodex-catalog.json` and
84
+ prints the activation setting. `model_catalog_url` avoids the file when the app
85
+ catalog is fetched dynamically.
86
+
66
87
  ## Claude Code
67
88
 
68
89
  ```bash
@@ -114,6 +135,7 @@ bun test tests
114
135
  bun run test:e2e
115
136
  bun run test:e2e:gemini
116
137
  bun run test:e2e:gemini-tool
138
+ bun run test:e2e:gemini-reasoning
117
139
  bun run privacy:scan
118
140
  bun run structure:check
119
141
  ```
@@ -131,6 +153,11 @@ Codex's `exec_command`, Codex executes a fixed read-only `printf`, and Gemini
131
153
  returns the final answer. Both commands use no retries. Missing binaries,
132
154
  authentication, or a Flash Lite model cause a hard failure.
133
155
 
156
+ `test:e2e:gemini-reasoning` discovers a thinking-capable model and level from the
157
+ local Gemini CLI, validates the refreshed Codex catalog, and runs one minimal
158
+ reasoning-enabled turn. It fails when the authenticated account cannot execute
159
+ the discovered model.
160
+
134
161
  Optional overrides are `CLINE_BIN_PATH`, `GEMINI_BIN_PATH`, and `CODEX_BIN_PATH`.
135
162
 
136
163
  ## Troubleshooting
@@ -141,9 +168,14 @@ Optional overrides are `CLINE_BIN_PATH`, `GEMINI_BIN_PATH`, and `CODEX_BIN_PATH`
141
168
  the model is `default`; native model selection requires ACP.
142
169
  - **Gemini not found**: install it and verify `gemini --help` works, or set
143
170
  `GEMINI_BIN_PATH`.
144
- - **Gemini authentication required**: configure Gemini CLI's official login or API
145
- key; do not pass the key to opencodex.
171
+ - **Gemini authentication required**: configure a Gemini Developer API key or
172
+ Vertex AI API key for Gemini CLI; do not use personal Google OAuth with this
173
+ third-party proxy and do not pass the key to opencodex.
146
174
  - **Gemini ACP unavailable**: upgrade Gemini CLI to a version advertising `--acp`.
175
+ - **Gemini Pro quota exhausted**: reasoning-capable models may require paid Pro
176
+ quota; the catalog stays accurate but that model cannot complete a live turn.
177
+ - **Codex model picker is stale**: run `ocx codex sync`, then restart the Codex
178
+ app/app-server.
147
179
  - **Port in use**: choose another port with `ocx start --port <port>`.
148
180
  - **Stale runtime state**: run `ocx stop`; it removes stale state only when the
149
181
  recorded process and health endpoint cannot be verified.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koreanhole/opencodex",
3
- "version": "2.96.0",
3
+ "version": "2.97.0",
4
4
  "description": "Cline and Gemini CLI provider proxy for OpenAI Codex and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -32,6 +32,7 @@
32
32
  "test:e2e": "bun test tests/e2e/cline-only.test.ts",
33
33
  "test:e2e:gemini": "bun test ./tests/e2e/gemini-codex.live.ts -t \"one minimal Flash Lite completion\"",
34
34
  "test:e2e:gemini-tool": "bun test ./tests/e2e/gemini-codex.live.ts -t \"one short Gemini tool call\"",
35
+ "test:e2e:gemini-reasoning": "bun test ./tests/e2e/gemini-reasoning.live.ts",
35
36
  "typecheck": "bun check --no-script --noEmit",
36
37
  "privacy:scan": "bun scripts/privacy-scan.ts",
37
38
  "structure:check": "bun scripts/structure-check.ts",
@@ -203,8 +203,35 @@ export interface GeminiAcpWorkspace {
203
203
  cleanup: () => Promise<void>;
204
204
  }
205
205
 
206
+ export interface GeminiReasoningSelection {
207
+ modelId: string;
208
+ thinkingConfig: {
209
+ thinkingLevel?: string;
210
+ thinkingBudget?: number;
211
+ includeThoughts?: boolean;
212
+ };
213
+ }
214
+
215
+ function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
216
+ const settings = JSON.parse(GEMINI_SYSTEM_SETTINGS) as Record<string, unknown>;
217
+ if (reasoning) {
218
+ settings.modelConfigs = {
219
+ customOverrides: [{
220
+ match: { model: reasoning.modelId },
221
+ modelConfig: {
222
+ generateContentConfig: {
223
+ thinkingConfig: reasoning.thinkingConfig,
224
+ },
225
+ },
226
+ }],
227
+ };
228
+ }
229
+ return `${JSON.stringify(settings, null, 2)}\n`;
230
+ }
231
+
206
232
  export async function stageGeminiAcpWorkspace(
207
233
  catalog: readonly Record<string, unknown>[] = [],
234
+ reasoning?: GeminiReasoningSelection,
208
235
  ): Promise<GeminiAcpWorkspace> {
209
236
  const cwd = await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
210
237
  const policyPath = join(cwd, "policy.toml");
@@ -213,7 +240,7 @@ export async function stageGeminiAcpWorkspace(
213
240
  const capturePath = join(cwd, "tool-intents.ndjson");
214
241
  await Promise.all([
215
242
  writeFile(policyPath, GEMINI_POLICY, { encoding: "utf8", mode: 0o600, flag: "wx" }),
216
- writeFile(settingsPath, GEMINI_SYSTEM_SETTINGS, { encoding: "utf8", mode: 0o600, flag: "wx" }),
243
+ writeFile(settingsPath, geminiSystemSettings(reasoning), { encoding: "utf8", mode: 0o600, flag: "wx" }),
217
244
  writeFile(catalogPath, JSON.stringify(catalog), { encoding: "utf8", mode: 0o600, flag: "wx" }),
218
245
  ...(catalog.length > 0
219
246
  ? [writeFile(capturePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" })]
@@ -7,6 +7,11 @@ import { isStandaloneBinary } from "../../../lib/standalone";
7
7
  import type { CliAgentTurnDeps } from "../types";
8
8
  import { ToolIntentError } from "../tool-intent";
9
9
  import { resolveGeminiBinary } from "./binary";
10
+ import {
11
+ discoverGeminiModelMetadata,
12
+ reasoningLevelForEffort,
13
+ type GeminiMetadataResult,
14
+ } from "./model-metadata";
10
15
  import {
11
16
  GeminiAcpError,
12
17
  buildGeminiAcpEnv,
@@ -35,6 +40,7 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
35
40
  env?: NodeJS.ProcessEnv;
36
41
  probeAcp?: typeof probeGeminiAcpSupport;
37
42
  stageAcp?: typeof stageGeminiAcpWorkspace;
43
+ metadata?: typeof discoverGeminiModelMetadata;
38
44
  runAcp?: (input: GeminiAcpTurnInput, emit: (event: AdapterEvent) => void, deps?: GeminiAcpDeps) => Promise<void>;
39
45
  acpDeps?: GeminiAcpDeps;
40
46
  }
@@ -159,6 +165,28 @@ export async function runGeminiTurn(
159
165
  return;
160
166
  }
161
167
 
168
+ let metadata: GeminiMetadataResult | undefined;
169
+ let reasoning;
170
+ try {
171
+ metadata = await (deps.metadata ?? discoverGeminiModelMetadata)({ binary, env: sourceEnv });
172
+ const capability = metadata.capabilities[requestedModel];
173
+ reasoning = reasoningLevelForEffort(capability, parsed.options.reasoning);
174
+ if (capability?.thinkingSupported && parsed.options.reasoning && !reasoning) {
175
+ emit({
176
+ type: "error",
177
+ message: `Gemini model "${requestedModel}" does not support reasoning effort "${parsed.options.reasoning}". Advertised levels: ${capability.reasoningLevels.map(level => level.effort).join(", ") || "none"}.`,
178
+ status: 400,
179
+ errorType: "invalid_request_error",
180
+ code: "reasoning_effort_unsupported",
181
+ retryable: false,
182
+ });
183
+ return;
184
+ }
185
+ } catch {
186
+ metadata = undefined;
187
+ reasoning = undefined;
188
+ }
189
+
162
190
  const acpDeps: GeminiAcpDeps = {
163
191
  ...deps.acpDeps,
164
192
  env: sourceEnv,
@@ -183,7 +211,10 @@ export async function runGeminiTurn(
183
211
 
184
212
  const vendorNames = toolNameMap(catalog);
185
213
  const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [wireName, wireName]));
186
- const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(catalog.tools);
214
+ const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(
215
+ catalog.tools,
216
+ reasoning ? { modelId: requestedModel, thinkingConfig: reasoning.thinkingConfig } : undefined,
217
+ );
187
218
  try {
188
219
  const turnEnv = { ...acpEnv, GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath };
189
220
  const mcpServers = workspace.capturePath && catalog.emittedNameMap.size > 0
@@ -12,6 +12,8 @@ import {
12
12
  probeGeminiAcpSupport,
13
13
  stageGeminiAcpWorkspace,
14
14
  } from "./acp";
15
+ import { discoverGeminiModelMetadata, mergeGeminiModelDescriptors } from "./model-metadata";
16
+ import type { OcxProviderModel } from "../../../types";
15
17
 
16
18
  export type GeminiLiveModelsFailure = "not_found" | "unsupported" | "auth_required" | "backoff" | "transport";
17
19
 
@@ -19,9 +21,11 @@ export type GeminiLiveModelsResult =
19
21
  | {
20
22
  ok: true;
21
23
  models: string[];
24
+ modelInfo: OcxProviderModel[];
22
25
  source: "models" | "default";
23
26
  fingerprint: string;
24
27
  stale?: true;
28
+ metadataWarning?: string;
25
29
  }
26
30
  | {
27
31
  ok: false;
@@ -34,16 +38,20 @@ export interface GeminiLiveModelsOptions {
34
38
  binary?: string;
35
39
  env?: NodeJS.ProcessEnv;
36
40
  deps?: AcpDeps;
41
+ packageRoot?: string;
37
42
  ttlMs?: number;
43
+ forceRefresh?: boolean;
38
44
  failureBackoffMs?: number;
39
45
  now?: () => number;
40
46
  }
41
47
 
42
48
  interface CacheEntry {
43
49
  models: string[];
50
+ modelInfo: OcxProviderModel[];
44
51
  source: "models" | "default";
45
52
  fetchedAt: number;
46
53
  lastFailureAt?: number;
54
+ metadataWarning?: string;
47
55
  }
48
56
 
49
57
  const cache = new Map<string, CacheEntry>();
@@ -59,6 +67,10 @@ function staleResult(fingerprint: string, entry: CacheEntry | undefined): Gemini
59
67
  return {
60
68
  ok: true,
61
69
  models: [...entry.models],
70
+ modelInfo: entry.modelInfo.map(model => ({
71
+ ...model,
72
+ supportedReasoningLevels: [...(model.supportedReasoningLevels ?? [])],
73
+ })),
62
74
  source: entry.source,
63
75
  fingerprint,
64
76
  stale: true,
@@ -79,8 +91,8 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
79
91
  const fingerprint = geminiBinaryFingerprintSync(binary);
80
92
  const current = cache.get(fingerprint);
81
93
  const timestamp = now();
82
- if (current && timestamp - current.fetchedAt < ttlMs) {
83
- return { ok: true, models: [...current.models], source: current.source, fingerprint };
94
+ if (!options.forceRefresh && current && timestamp - current.fetchedAt < ttlMs) {
95
+ return { ok: true, models: [...current.models], modelInfo: current.modelInfo, source: current.source, fingerprint, ...(current.metadataWarning ? { metadataWarning: current.metadataWarning } : {}) };
84
96
  }
85
97
  const lastFailure = failureCache.get(fingerprint) ?? current?.lastFailureAt;
86
98
  if (lastFailure !== undefined && timestamp - lastFailure < failureBackoffMs) {
@@ -110,10 +122,18 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
110
122
  }, options.deps);
111
123
  const session = await client.newSession({ cwd: workspace.cwd, mcpServers: [] });
112
124
  const models = geminiModelIds(session);
125
+ const metadata = await discoverGeminiModelMetadata({
126
+ binary,
127
+ env: sourceEnv,
128
+ ...(options.packageRoot ? { packageRoot: options.packageRoot } : {}),
129
+ ...(options.now ? { now: options.now } : {}),
130
+ ...(options.forceRefresh ? { ttlMs: 0 } : {}),
131
+ });
132
+ const modelInfo = mergeGeminiModelDescriptors(session.availableModels, metadata);
113
133
  const source = session.availableModels.length > 0 ? "models" : "default";
114
- cache.set(fingerprint, { models, source, fetchedAt: timestamp });
134
+ cache.set(fingerprint, { models, modelInfo, source, fetchedAt: timestamp, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) });
115
135
  failureCache.delete(fingerprint);
116
- return { ok: true, models, source, fingerprint };
136
+ return { ok: true, models, modelInfo, source, fingerprint, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) };
117
137
  } catch (error) {
118
138
  failureCache.set(fingerprint, timestamp);
119
139
  if (current) current.lastFailureAt = timestamp;
@@ -0,0 +1,291 @@
1
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import type { OcxProviderModel, OcxReasoningLevel } from "../../../types";
5
+
6
+ const DEFAULT_METADATA_TTL_MS = 5 * 60_000;
7
+
8
+ export interface GeminiThinkingConfig {
9
+ thinkingLevel?: string;
10
+ thinkingBudget?: number;
11
+ includeThoughts?: boolean;
12
+ }
13
+
14
+ export interface GeminiReasoningLevel extends OcxReasoningLevel {
15
+ thinkingConfig: GeminiThinkingConfig;
16
+ originalEffort: string;
17
+ }
18
+
19
+ export interface GeminiModelCapability {
20
+ thinkingSupported: boolean;
21
+ reasoningLevels: GeminiReasoningLevel[];
22
+ defaultReasoningLevel?: string;
23
+ }
24
+
25
+ export interface GeminiMetadataResult {
26
+ packageRoot?: string;
27
+ settingsPath?: string;
28
+ capabilities: Record<string, GeminiModelCapability>;
29
+ warning?: string;
30
+ }
31
+
32
+ export interface GeminiMetadataOptions {
33
+ binary: string;
34
+ env?: NodeJS.ProcessEnv;
35
+ packageRoot?: string;
36
+ ttlMs?: number;
37
+ now?: () => number;
38
+ }
39
+
40
+ interface CachedMetadata extends GeminiMetadataResult {
41
+ fetchedAt: number;
42
+ }
43
+
44
+ const metadataCache = new Map<string, CachedMetadata>();
45
+
46
+ export function resetGeminiMetadataCacheForTests(): void {
47
+ metadataCache.clear();
48
+ }
49
+
50
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
51
+ return value && typeof value === "object" && !Array.isArray(value)
52
+ ? value as Record<string, unknown>
53
+ : undefined;
54
+ }
55
+
56
+ function stringValue(value: unknown): string | undefined {
57
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
58
+ }
59
+
60
+ function extractDefaultJsonValue(markdown: string, property: string): unknown {
61
+ const marker = `- **\`modelConfigs.${property}\`**`;
62
+ const start = markdown.indexOf(marker);
63
+ if (start < 0) return undefined;
64
+ const fenceStart = markdown.indexOf("```json", start);
65
+ if (fenceStart < 0) return undefined;
66
+ const bodyStart = markdown.indexOf("\n", fenceStart) + 1;
67
+ const fenceEnd = markdown.indexOf("```", bodyStart);
68
+ if (bodyStart <= 0 || fenceEnd < 0) return undefined;
69
+ try {
70
+ return JSON.parse(markdown.slice(bodyStart, fenceEnd).trim()) as unknown;
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function extractDefaultJson(markdown: string, property: string): Record<string, unknown> | undefined {
77
+ return asRecord(extractDefaultJsonValue(markdown, property));
78
+ }
79
+
80
+ async function findPackageRoot(binary: string): Promise<string | undefined> {
81
+ let current: string;
82
+ try {
83
+ current = dirname(await realpath(binary));
84
+ } catch {
85
+ current = dirname(resolve(binary));
86
+ }
87
+ for (let depth = 0; depth < 10; depth++) {
88
+ try {
89
+ const manifest = JSON.parse(await readFile(join(current, "package.json"), "utf8")) as { name?: unknown };
90
+ if (manifest.name === "@google/gemini-cli") return current;
91
+ } catch {
92
+ // Keep walking toward the package root.
93
+ }
94
+ const parent = dirname(current);
95
+ if (parent === current) break;
96
+ current = parent;
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ async function readThinkingLevels(packageRoot: string): Promise<Array<{ effort: string; original: string }>> {
102
+ let files: string[];
103
+ try {
104
+ files = (await readdir(join(packageRoot, "bundle"))).filter(name => name.endsWith(".js"));
105
+ } catch {
106
+ return [];
107
+ }
108
+ const candidates = await Promise.all(files.map(async name => {
109
+ const path = join(packageRoot, "bundle", name);
110
+ const info = await stat(path).catch(() => undefined);
111
+ return info ? { path, size: info.size } : undefined;
112
+ }));
113
+ candidates.sort((left, right) => (left?.size ?? Infinity) - (right?.size ?? Infinity));
114
+ for (const candidate of candidates) {
115
+ if (!candidate) continue;
116
+ const text = await readFile(candidate.path, "utf8").catch(() => "");
117
+ if (!text.includes("ThinkingLevel2[\"LOW\"]")) continue;
118
+ const levels = [...text.matchAll(/ThinkingLevel2\["([A-Z0-9_]+)"\]/g)]
119
+ .map(match => match[1]!)
120
+ .filter(level => level !== "THINKING_LEVEL_UNSPECIFIED");
121
+ return [...new Set(levels)].map(level => ({ effort: level.toLowerCase(), original: level }));
122
+ }
123
+ return [];
124
+ }
125
+
126
+ function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
127
+ const out = { ...target };
128
+ for (const [key, value] of Object.entries(source)) {
129
+ const targetRecord = asRecord(out[key]);
130
+ const sourceRecord = asRecord(value);
131
+ out[key] = targetRecord && sourceRecord ? mergeConfig(targetRecord, sourceRecord) : value;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ function resolveAlias(
137
+ id: string,
138
+ aliases: Record<string, unknown>,
139
+ seen = new Set<string>(),
140
+ ): Record<string, unknown> {
141
+ if (seen.has(id)) return {};
142
+ seen.add(id);
143
+ const alias = asRecord(aliases[id]);
144
+ if (!alias) return {};
145
+ const parent = stringValue(alias.extends);
146
+ const inherited = parent ? resolveAlias(parent, aliases, seen) : {};
147
+ return mergeConfig(inherited, asRecord(alias.modelConfig) ?? {});
148
+ }
149
+
150
+ function capabilitiesFromMetadata(
151
+ definitions: Record<string, unknown>,
152
+ aliases: Record<string, unknown>,
153
+ levels: Array<{ effort: string; original: string }>,
154
+ overrides: readonly unknown[],
155
+ ): Record<string, GeminiModelCapability> {
156
+ const capabilities: Record<string, GeminiModelCapability> = {};
157
+ for (const [modelId, rawDefinition] of Object.entries(definitions)) {
158
+ const definition = asRecord(rawDefinition);
159
+ const features = asRecord(definition?.features);
160
+ const thinkingSupported = features?.thinking === true;
161
+ if (!thinkingSupported) {
162
+ capabilities[modelId] = { thinkingSupported: false, reasoningLevels: [] };
163
+ continue;
164
+ }
165
+ let generateContentConfig = asRecord(resolveAlias(modelId, aliases).generateContentConfig) ?? {};
166
+ for (const rawOverride of overrides) {
167
+ const override = asRecord(rawOverride);
168
+ if (stringValue(asRecord(override?.match)?.model) !== modelId) continue;
169
+ generateContentConfig = mergeConfig(generateContentConfig, asRecord(asRecord(override?.modelConfig)?.generateContentConfig) ?? {});
170
+ }
171
+ const config = asRecord(generateContentConfig.thinkingConfig);
172
+ const configuredLevel = stringValue(config?.thinkingLevel)?.toLowerCase();
173
+ const includeThoughts = config?.includeThoughts === true;
174
+ const reasoningLevels = levels.map(level => ({
175
+ effort: level.effort,
176
+ originalEffort: level.original,
177
+ description: `Gemini CLI ${level.original} thinking level`,
178
+ thinkingConfig: {
179
+ thinkingLevel: level.original,
180
+ ...(includeThoughts ? { includeThoughts: true } : {}),
181
+ },
182
+ }));
183
+ const defaultReasoningLevel = reasoningLevels.some(level => level.effort === configuredLevel)
184
+ ? configuredLevel
185
+ : reasoningLevels.find(level => level.effort === "high")?.effort ?? reasoningLevels[0]?.effort;
186
+ capabilities[modelId] = {
187
+ thinkingSupported: true,
188
+ reasoningLevels,
189
+ ...(defaultReasoningLevel ? { defaultReasoningLevel } : {}),
190
+ };
191
+ }
192
+ return capabilities;
193
+ }
194
+
195
+ async function loadMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
196
+ const env = options.env ?? process.env;
197
+ const packageRoot = options.packageRoot ?? await findPackageRoot(options.binary);
198
+ if (!packageRoot) return { capabilities: {}, warning: "Gemini CLI package metadata was not found." };
199
+ const docsPath = join(packageRoot, "bundle", "docs", "reference", "configuration.md");
200
+ let markdown: string;
201
+ try {
202
+ markdown = await readFile(docsPath, "utf8");
203
+ } catch {
204
+ return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration documentation was not found." };
205
+ }
206
+ const defaultAliases = extractDefaultJson(markdown, "aliases");
207
+ const defaultDefinitions = extractDefaultJson(markdown, "modelDefinitions");
208
+ const defaultOverrides = extractDefaultJsonValue(markdown, "overrides");
209
+ if (!defaultAliases || !defaultDefinitions) {
210
+ return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration metadata could not be parsed." };
211
+ }
212
+
213
+ const home = stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir();
214
+ const settingsPath = join(home, ".gemini", "settings.json");
215
+ let userSettings: Record<string, unknown> = {};
216
+ try {
217
+ userSettings = asRecord(JSON.parse(await readFile(settingsPath, "utf8"))) ?? {};
218
+ } catch {
219
+ // User settings are optional; absence is not a metadata failure.
220
+ }
221
+ const modelConfigs = asRecord(userSettings.modelConfigs) ?? {};
222
+ const aliases = mergeConfig(
223
+ mergeConfig(defaultAliases, asRecord(modelConfigs.customAliases) ?? {}),
224
+ asRecord(modelConfigs.aliases) ?? {},
225
+ );
226
+ const definitions = mergeConfig(defaultDefinitions, asRecord(modelConfigs.modelDefinitions) ?? {});
227
+ const overrides = [
228
+ ...(Array.isArray(defaultOverrides) ? defaultOverrides : []),
229
+ ...(Array.isArray(modelConfigs.overrides) ? modelConfigs.overrides : []),
230
+ ...(Array.isArray(modelConfigs.customOverrides) ? modelConfigs.customOverrides : []),
231
+ ];
232
+ const levels = await readThinkingLevels(packageRoot);
233
+ if (levels.length === 0) {
234
+ return { packageRoot, settingsPath, capabilities: {}, warning: "Gemini CLI thinking-level metadata was not found." };
235
+ }
236
+ return {
237
+ packageRoot,
238
+ settingsPath,
239
+ capabilities: capabilitiesFromMetadata(definitions, aliases, levels, overrides),
240
+ };
241
+ }
242
+
243
+ export async function discoverGeminiModelMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
244
+ const env = options.env ?? process.env;
245
+ const key = [
246
+ options.packageRoot ?? "auto",
247
+ resolve(options.binary),
248
+ stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir(),
249
+ ].join("\n");
250
+ const now = options.now ?? Date.now;
251
+ const ttlMs = options.ttlMs ?? DEFAULT_METADATA_TTL_MS;
252
+ const cached = metadataCache.get(key);
253
+ if (cached && now() - cached.fetchedAt < ttlMs) return cached;
254
+ const result = await loadMetadata(options);
255
+ const entry: CachedMetadata = { ...result, fetchedAt: now() };
256
+ metadataCache.set(key, entry);
257
+ return entry;
258
+ }
259
+
260
+ export function mergeGeminiModelDescriptors(
261
+ advertised: readonly { modelId: string; name?: string; description?: string }[],
262
+ metadata: GeminiMetadataResult,
263
+ ): OcxProviderModel[] {
264
+ return advertised.map(model => {
265
+ const capability = metadata.capabilities[model.modelId];
266
+ const reasoningLevels = capability?.reasoningLevels.map(level => ({
267
+ effort: level.effort,
268
+ description: level.description,
269
+ })) ?? [];
270
+ return {
271
+ id: model.modelId,
272
+ ...(model.name ? { name: model.name } : {}),
273
+ ...(model.description ? { description: model.description } : {}),
274
+ supportedReasoningLevels: reasoningLevels,
275
+ ...(capability?.defaultReasoningLevel ? { defaultReasoningLevel: capability.defaultReasoningLevel } : {}),
276
+ };
277
+ });
278
+ }
279
+
280
+ export function reasoningLevelForEffort(
281
+ capability: GeminiModelCapability | undefined,
282
+ effort: string | undefined,
283
+ ): GeminiReasoningLevel | undefined {
284
+ if (!capability?.thinkingSupported || capability.reasoningLevels.length === 0) return undefined;
285
+ const wanted = effort?.trim().toLowerCase();
286
+ if (!wanted) {
287
+ return capability.reasoningLevels.find(level => level.effort === capability.defaultReasoningLevel)
288
+ ?? capability.reasoningLevels[0];
289
+ }
290
+ return capability.reasoningLevels.find(level => level.effort === wanted);
291
+ }
package/src/cli/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { startServer } from "../server";
20
20
  import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
21
21
  import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
22
+ import { syncCodexCatalog } from "../lib/codex-catalog";
22
23
 
23
24
  const here = dirname(fileURLToPath(import.meta.url));
24
25
 
@@ -61,6 +62,7 @@ function help(): string {
61
62
  " start [--port <port>] [--host <host>] Start the proxy",
62
63
  " status [--json] Check the running proxy",
63
64
  " stop [--json] Stop the running proxy",
65
+ " codex sync [--json] Refresh the Codex model catalog",
64
66
  " version Print the package version",
65
67
  " help Show this help",
66
68
  "",
@@ -177,6 +179,20 @@ async function startCommand(args: string[]): Promise<number> {
177
179
  config.port = parsed;
178
180
  }
179
181
  if (host !== undefined) config.host = host;
182
+ try {
183
+ const catalog = await syncCodexCatalog({ env });
184
+ if (catalog.ok && catalog.written) {
185
+ console.log(`Codex catalog refreshed: ${catalog.path}`);
186
+ console.log("Restart the Codex app/app-server to reload the model picker.");
187
+ if (!catalog.configured) {
188
+ console.log(`Activate it with: model_catalog_json = "${catalog.path}"`);
189
+ }
190
+ } else if (!catalog.ok) {
191
+ console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
192
+ }
193
+ } catch (error) {
194
+ console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
195
+ }
180
196
  const running = startServer(config, { env });
181
197
  await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
182
198
  console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
@@ -194,6 +210,31 @@ async function startCommand(args: string[]): Promise<number> {
194
210
  return 0;
195
211
  }
196
212
 
213
+ async function codexCommand(args: string[]): Promise<number> {
214
+ const sub = args.shift();
215
+ if (sub !== "sync") throw new ConfigError(`Unknown codex command: ${sub ?? "(missing)"}. Expected sync.`);
216
+ const json = takeFlag(args, "--json");
217
+ if (args.length > 0) throw new ConfigError(`Unknown codex sync option: ${args[0]}`);
218
+ const result = await syncCodexCatalog({ env: process.env });
219
+ if (!result.ok) {
220
+ if (json) console.log(JSON.stringify({ ok: false, path: result.path, reason: result.reason, message: result.message }));
221
+ else console.error(`ocx: ${result.message}`);
222
+ return 1;
223
+ }
224
+ if (json) {
225
+ console.log(JSON.stringify(result));
226
+ } else {
227
+ console.log(`Catalog: ${result.path}`);
228
+ console.log(`Models: ${result.modelCount} (Gemini ${result.geminiModelCount}, reasoning-enabled ${result.reasoningModelCount})`);
229
+ for (const warning of result.warnings) console.warn(`[opencodex] ${warning}`);
230
+ if (!result.configured) {
231
+ console.log(`Activate it with: model_catalog_json = "${result.path}"`);
232
+ }
233
+ console.log("Codex reload required: restart the Codex app or app-server model picker.");
234
+ }
235
+ return 0;
236
+ }
237
+
197
238
  async function statusCommand(args: string[]): Promise<number> {
198
239
  const json = takeFlag(args, "--json");
199
240
  if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
@@ -256,10 +297,11 @@ async function main(argv: string[]): Promise<number> {
256
297
  }
257
298
  if (command === "login") return await loginCommand(argv);
258
299
  if (command === "provider") return await providerCommand(argv);
300
+ if (command === "codex") return await codexCommand(argv);
259
301
  if (command === "start") return await startCommand(argv);
260
302
  if (command === "status") return await statusCommand(argv);
261
303
  if (command === "stop") return await stopCommand(argv);
262
- throw new ConfigError(`Unknown command: ${command}. Expected login, provider, start, status, stop, version, or help.`);
304
+ throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
263
305
  }
264
306
 
265
307
  try {
@@ -0,0 +1,318 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import type { OcxProviderModel } from "../types";
5
+ import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
6
+ import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
7
+ import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
8
+ import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
9
+
10
+ const CATALOG_FALLBACK_CONTEXT = 128_000;
11
+ const BASE_INSTRUCTIONS = "You are a helpful coding assistant.";
12
+ const MANAGED_PREFIXES = ["cline-cli/", "gemini-cli/"] as const;
13
+
14
+ export interface CodexCatalogModel {
15
+ slug: string;
16
+ display_name: string;
17
+ description: string;
18
+ default_reasoning_level: string;
19
+ supported_reasoning_levels: Array<{ effort: string; description: string }>;
20
+ shell_type: "unified_exec";
21
+ visibility: "list";
22
+ supported_in_api: true;
23
+ priority: number;
24
+ base_instructions: string;
25
+ web_search_tool_type: "text_and_image";
26
+ supports_search_tool: false;
27
+ tool_mode: "code_mode_only";
28
+ context_window: number;
29
+ max_context_window: number;
30
+ auto_compact_token_limit: number;
31
+ input_modalities: ["text"];
32
+ node_repl_disabled: false;
33
+ node_repl_auto_review_required: false;
34
+ include_plugin_usage_instructions: false;
35
+ include_apps_usage_instructions: false;
36
+ include_skills_usage_instructions: false;
37
+ supports_reasoning_summaries: boolean;
38
+ default_reasoning_summary: string;
39
+ support_verbosity: true;
40
+ default_verbosity: "low";
41
+ apply_patch_tool_type: "freeform";
42
+ truncation_policy: { mode: "tokens"; limit: number };
43
+ supports_parallel_tool_calls: true;
44
+ supports_image_detail_original: false;
45
+ experimental_supported_tools: [];
46
+ effective_context_window_percent: 95;
47
+ comp_hash: null;
48
+ multi_agent_version: "v1";
49
+ auto_review_model_override: null;
50
+ supports_reasoning_effort_updates: false;
51
+ use_responses_lite: false;
52
+ additional_speed_tiers: [];
53
+ service_tiers: [];
54
+ availability_nux: null;
55
+ upgrade: null;
56
+ }
57
+
58
+ export interface CodexCatalogDocument {
59
+ fetched_at: string;
60
+ etag: null;
61
+ client_version: string;
62
+ identity: string;
63
+ models: CodexCatalogModel[];
64
+ }
65
+
66
+ export interface CodexCatalogDiscoveryOptions {
67
+ env?: NodeJS.ProcessEnv;
68
+ clineDeps?: ClineTurnDeps;
69
+ geminiDeps?: GeminiTurnDeps;
70
+ geminiBinary?: string;
71
+ geminiPackageRoot?: string;
72
+ now?: Date;
73
+ forceRefresh?: boolean;
74
+ }
75
+
76
+ export interface CodexCatalogModels {
77
+ models: CodexCatalogModel[];
78
+ warnings: string[];
79
+ }
80
+
81
+ export type CodexCatalogSyncResult =
82
+ | {
83
+ ok: true;
84
+ path: string;
85
+ configured: boolean;
86
+ written: boolean;
87
+ modelCount: number;
88
+ geminiModelCount: number;
89
+ reasoningModelCount: number;
90
+ reloadRequired: true;
91
+ warnings: string[];
92
+ }
93
+ | {
94
+ ok: false;
95
+ path?: string;
96
+ reason: "unsafe_path" | "malformed_catalog" | "write_failed" | "discovery_failed";
97
+ message: string;
98
+ };
99
+
100
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
101
+ return value && typeof value === "object" && !Array.isArray(value)
102
+ ? value as Record<string, unknown>
103
+ : undefined;
104
+ }
105
+
106
+ function encodeRoutedId(id: string): string {
107
+ return id.replaceAll("/", "-");
108
+ }
109
+
110
+ export function buildCodexCatalogModel(providerId: "cline-cli" | "gemini-cli", model: OcxProviderModel, index: number): CodexCatalogModel {
111
+ const levels = (model.supportedReasoningLevels ?? []).map(level => ({
112
+ effort: level.effort,
113
+ description: level.description,
114
+ }));
115
+ const defaultLevel = model.defaultReasoningLevel ?? levels[0]?.effort ?? "low";
116
+ const isDefault = model.id === "default";
117
+ const providerLabel = providerId === "gemini-cli" ? "Gemini CLI" : "Cline CLI";
118
+ return {
119
+ slug: `${providerId}/${encodeRoutedId(model.id)}`,
120
+ display_name: model.name ?? (isDefault ? `${providerLabel} default` : model.id),
121
+ description: model.description ?? `Routed via opencodex -> ${providerLabel}.`,
122
+ default_reasoning_level: levels.length > 0 ? defaultLevel : "low",
123
+ supported_reasoning_levels: levels,
124
+ shell_type: "unified_exec",
125
+ visibility: "list",
126
+ supported_in_api: true,
127
+ priority: 10 + index,
128
+ base_instructions: BASE_INSTRUCTIONS,
129
+ web_search_tool_type: "text_and_image",
130
+ supports_search_tool: false,
131
+ tool_mode: "code_mode_only",
132
+ context_window: CATALOG_FALLBACK_CONTEXT,
133
+ max_context_window: CATALOG_FALLBACK_CONTEXT,
134
+ auto_compact_token_limit: Math.floor(CATALOG_FALLBACK_CONTEXT * 0.9),
135
+ input_modalities: ["text"],
136
+ node_repl_disabled: false,
137
+ node_repl_auto_review_required: false,
138
+ include_plugin_usage_instructions: false,
139
+ include_apps_usage_instructions: false,
140
+ include_skills_usage_instructions: false,
141
+ supports_reasoning_summaries: levels.length > 0,
142
+ default_reasoning_summary: levels.length > 0 ? "auto" : "none",
143
+ support_verbosity: true,
144
+ default_verbosity: "low",
145
+ apply_patch_tool_type: "freeform",
146
+ truncation_policy: { mode: "tokens", limit: 10_000 },
147
+ supports_parallel_tool_calls: true,
148
+ supports_image_detail_original: false,
149
+ experimental_supported_tools: [],
150
+ effective_context_window_percent: 95,
151
+ comp_hash: null,
152
+ multi_agent_version: "v1",
153
+ auto_review_model_override: null,
154
+ supports_reasoning_effort_updates: false,
155
+ use_responses_lite: false,
156
+ additional_speed_tiers: [],
157
+ service_tiers: [],
158
+ availability_nux: null,
159
+ upgrade: null,
160
+ };
161
+ }
162
+
163
+ function defaultModel(): OcxProviderModel {
164
+ return { id: "default" };
165
+ }
166
+
167
+ function normalizedModels(models: readonly OcxProviderModel[]): OcxProviderModel[] {
168
+ const seen = new Set<string>();
169
+ const out: OcxProviderModel[] = [];
170
+ for (const model of [defaultModel(), ...models]) {
171
+ if (!seen.has(model.id)) {
172
+ seen.add(model.id);
173
+ out.push(model);
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+
179
+ export async function discoverCodexCatalogModels(options: CodexCatalogDiscoveryOptions = {}): Promise<CodexCatalogModels> {
180
+ const env = options.env ?? process.env;
181
+ const [cline, gemini] = await Promise.all([
182
+ discoverClineLiveModels({ env, ...(options.clineDeps ? { deps: options.clineDeps } : {}) }),
183
+ discoverGeminiLiveModels({
184
+ env,
185
+ ...(options.geminiBinary ? { binary: options.geminiBinary } : {}),
186
+ ...(options.geminiDeps ? { deps: options.geminiDeps } : {}),
187
+ ...(options.geminiPackageRoot ? { packageRoot: options.geminiPackageRoot } : {}),
188
+ ...(options.forceRefresh ? { forceRefresh: true } : {}),
189
+ }),
190
+ ]);
191
+ const warnings: string[] = [];
192
+ if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") warnings.push(`Cline: ${cline.detail}`);
193
+ if (!gemini.ok && gemini.reason !== "not_found" && env.OPENCODEX_GEMINI_ACP?.trim() !== "0") warnings.push(`Gemini: ${gemini.detail}`);
194
+ if (gemini.ok && gemini.metadataWarning) warnings.push(`Gemini metadata: ${gemini.metadataWarning}`);
195
+ const clineModels = normalizedModels(cline.ok ? cline.models.map(id => ({ id })) : []);
196
+ const geminiModels = normalizedModels(gemini.ok ? gemini.modelInfo : []);
197
+ return {
198
+ models: [
199
+ ...clineModels.map((model, index) => buildCodexCatalogModel("cline-cli", model, index)),
200
+ ...geminiModels.map((model, index) => buildCodexCatalogModel("gemini-cli", model, clineModels.length + index)),
201
+ ],
202
+ warnings,
203
+ };
204
+ }
205
+
206
+ export function buildCodexCatalogDocument(
207
+ models: CodexCatalogModel[],
208
+ now = new Date(),
209
+ preserved: CodexCatalogModel[] = [],
210
+ ): CodexCatalogDocument {
211
+ return {
212
+ fetched_at: now.toISOString(),
213
+ etag: null,
214
+ client_version: "0.165.0",
215
+ identity: "opencodex",
216
+ models: [...preserved, ...models],
217
+ };
218
+ }
219
+
220
+ function readRootTomlString(text: string, key: string): string | undefined {
221
+ const firstTable = text.search(/^\s*\[/m);
222
+ const root = firstTable < 0 ? text : text.slice(0, firstTable);
223
+ const match = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, "m").exec(root);
224
+ return match?.[1]?.trim() || undefined;
225
+ }
226
+
227
+ export async function writeCodexCatalog(
228
+ path: string,
229
+ document: CodexCatalogDocument,
230
+ ): Promise<void> {
231
+ const parent = dirname(path);
232
+ await mkdir(parent, { recursive: true, mode: 0o700 });
233
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
234
+ try {
235
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
236
+ await chmod(temporary, 0o600);
237
+ await rename(temporary, path);
238
+ await chmod(path, 0o600).catch(() => undefined);
239
+ } finally {
240
+ await rm(temporary, { force: true }).catch(() => undefined);
241
+ }
242
+ }
243
+
244
+ function managedSlug(slug: unknown): boolean {
245
+ return typeof slug === "string" && MANAGED_PREFIXES.some(prefix => slug.startsWith(prefix));
246
+ }
247
+
248
+ export async function syncCodexCatalog(
249
+ options: CodexCatalogDiscoveryOptions & {
250
+ path?: string;
251
+ discover?: typeof discoverCodexCatalogModels;
252
+ } = {},
253
+ ): Promise<CodexCatalogSyncResult> {
254
+ const env = options.env ?? process.env;
255
+ const codexHome = env.CODEX_HOME?.trim() || join(homedir(), ".codex");
256
+ let configuredPath: string | undefined;
257
+ try {
258
+ configuredPath = readRootTomlString(await readFile(join(codexHome, "config.toml"), "utf8"), "model_catalog_json");
259
+ } catch {
260
+ configuredPath = undefined;
261
+ }
262
+ const configured = configuredPath !== undefined;
263
+ const selected = options.path
264
+ ? (isAbsolute(options.path) ? options.path : resolve(options.path))
265
+ : configuredPath
266
+ ? resolve(codexHome, configuredPath)
267
+ : join(codexHome, "opencodex-catalog.json");
268
+ if (configured && basename(selected) !== "opencodex-catalog.json") {
269
+ return { ok: false, path: selected, reason: "unsafe_path", message: "Configured model_catalog_json is not an opencodex catalog; refusing to overwrite it." };
270
+ }
271
+
272
+ let existing: Record<string, unknown> = {};
273
+ try {
274
+ const parsed = asRecord(JSON.parse(await readFile(selected, "utf8")));
275
+ if (!parsed) return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog is not a JSON object." };
276
+ existing = parsed;
277
+ } catch (error) {
278
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
279
+ return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog could not be parsed; it was preserved." };
280
+ }
281
+ }
282
+
283
+ let discovered: CodexCatalogModels;
284
+ try {
285
+ discovered = await (options.discover ?? discoverCodexCatalogModels)({ ...options, forceRefresh: true });
286
+ } catch (error) {
287
+ return { ok: false, reason: "discovery_failed", message: error instanceof Error ? error.message : String(error) };
288
+ }
289
+ const existingModels = Array.isArray(existing.models) ? existing.models : [];
290
+ if (existing.models !== undefined && !Array.isArray(existing.models)) {
291
+ return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog models field is not an array; it was preserved." };
292
+ }
293
+ if (existingModels.some(model => !asRecord(model))) {
294
+ return { ok: false, path: selected, reason: "malformed_catalog", message: "Existing Codex catalog contains a non-object model row; it was preserved." };
295
+ }
296
+ const preserved = existingModels.filter(model => !managedSlug(asRecord(model)?.slug));
297
+ const document = buildCodexCatalogDocument(
298
+ discovered.models,
299
+ options.now ?? new Date(),
300
+ preserved as CodexCatalogModel[],
301
+ );
302
+ try {
303
+ await writeCodexCatalog(selected, document);
304
+ } catch (error) {
305
+ return { ok: false, path: selected, reason: "write_failed", message: error instanceof Error ? error.message : String(error) };
306
+ }
307
+ return {
308
+ ok: true,
309
+ path: selected,
310
+ configured,
311
+ written: true,
312
+ modelCount: document.models.length,
313
+ geminiModelCount: document.models.filter(model => model.slug.startsWith("gemini-cli/")).length,
314
+ reasoningModelCount: document.models.filter(model => model.slug.startsWith("gemini-cli/") && (model.supported_reasoning_levels?.length ?? 0) > 0).length,
315
+ reloadRequired: true,
316
+ warnings: discovered.warnings,
317
+ };
318
+ }
@@ -12,6 +12,7 @@ import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
12
12
  import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
13
13
  import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
14
14
  import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
15
+ import { buildCodexCatalogDocument, discoverCodexCatalogModels } from "../lib/codex-catalog";
15
16
  import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, type ProviderModels } from "./responses";
16
17
  import { handleCountTokens, handleMessages } from "./messages";
17
18
 
@@ -22,6 +23,7 @@ export interface ServerOptions {
22
23
  env?: NodeJS.ProcessEnv;
23
24
  clineDeps?: ClineTurnDeps;
24
25
  geminiDeps?: GeminiTurnDeps;
26
+ geminiPackageRoot?: string;
25
27
  }
26
28
 
27
29
  export interface RunningServer {
@@ -80,10 +82,11 @@ async function discoverModels(
80
82
  env: NodeJS.ProcessEnv,
81
83
  clineDeps: ClineTurnDeps | undefined,
82
84
  geminiDeps: GeminiTurnDeps | undefined,
85
+ geminiPackageRoot: string | undefined,
83
86
  ): Promise<ProviderModels> {
84
87
  const [cline, gemini] = await Promise.all([
85
88
  discoverClineLiveModels({ env, ...(clineDeps ? { deps: clineDeps } : {}) }),
86
- discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}) }),
89
+ discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}), ...(geminiPackageRoot ? { packageRoot: geminiPackageRoot } : {}) }),
87
90
  ]);
88
91
  if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") {
89
92
  console.warn(`[opencodex] Cline model discovery degraded [${cline.reason}]: ${cline.detail}`);
@@ -93,7 +96,9 @@ async function discoverModels(
93
96
  }
94
97
  return {
95
98
  "cline-cli": cline.ok ? ["default", ...cline.models.filter(id => id !== "default")] : ["default"],
96
- "gemini-cli": gemini.ok ? ["default", ...gemini.models.filter(id => id !== "default")] : ["default"],
99
+ "gemini-cli": gemini.ok
100
+ ? ["default", ...gemini.modelInfo.filter(model => model.id !== "default")]
101
+ : ["default"],
97
102
  };
98
103
  }
99
104
 
@@ -119,19 +124,28 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
119
124
  }
120
125
  try {
121
126
  if (req.method === "GET" && url.pathname === "/v1/models") {
122
- const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
127
+ const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
123
128
  return Response.json({ object: "list", data: modelList(known) });
124
129
  }
130
+ if (req.method === "GET" && url.pathname === "/v1/model-catalog") {
131
+ const discovered = await discoverCodexCatalogModels({
132
+ env,
133
+ ...(options.clineDeps ? { clineDeps: options.clineDeps } : {}),
134
+ ...(options.geminiDeps ? { geminiDeps: options.geminiDeps } : {}),
135
+ ...(options.geminiPackageRoot ? { geminiPackageRoot: options.geminiPackageRoot } : {}),
136
+ });
137
+ return Response.json(buildCodexCatalogDocument(discovered.models));
138
+ }
125
139
  if (req.method === "POST" && url.pathname === "/v1/responses") {
126
140
  const body = await boundedJson(req);
127
- const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
141
+ const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
128
142
  const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
129
143
  const incoming: IncomingMeta = { headers: req.headers, abortSignal: signal };
130
144
  return await handleResponses(body, incoming, known, turnDeps, config.defaultProvider);
131
145
  }
132
146
  if (req.method === "POST" && url.pathname === "/v1/messages") {
133
147
  const body = await boundedJson(req);
134
- const known = await discoverModels(env, options.clineDeps, options.geminiDeps);
148
+ const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
135
149
  const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
136
150
  return await handleMessages(body, { headers: req.headers, abortSignal: signal }, known, turnDeps, config.defaultProvider);
137
151
  }
@@ -6,6 +6,7 @@ import type {
6
6
  OcxTool,
7
7
  OcxToolChoice,
8
8
  OcxUsage,
9
+ OcxProviderModel,
9
10
  } from "../types";
10
11
  import { runClineTurn, type ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
11
12
  import { runGeminiTurn, type GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
@@ -275,7 +276,8 @@ export function resolveClineModel(requested: string, known: Iterable<string>): s
275
276
  return decodeRoutedModelId(suffix, known);
276
277
  }
277
278
 
278
- export type ProviderModels = Partial<Record<ProviderId, readonly string[]>>;
279
+ export type ProviderModelEntry = string | OcxProviderModel;
280
+ export type ProviderModels = Partial<Record<ProviderId, readonly ProviderModelEntry[]>>;
279
281
 
280
282
  export interface RoutedModel {
281
283
  providerId: ProviderId;
@@ -297,10 +299,16 @@ export function resolveRoutedModel(
297
299
  const suffix = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
298
300
  return {
299
301
  providerId,
300
- modelId: suffix === "default" ? "default" : decodeRoutedModelId(suffix, known[providerId] ?? []),
302
+ modelId: suffix === "default"
303
+ ? "default"
304
+ : decodeRoutedModelId(suffix, (known[providerId] ?? []).map(modelIdOf)),
301
305
  };
302
306
  }
303
307
 
308
+ function modelIdOf(model: ProviderModelEntry): string {
309
+ return typeof model === "string" ? model : model.id;
310
+ }
311
+
304
312
  const PROVIDER_LABELS: Record<ProviderId, string> = {
305
313
  [CLINE_PROVIDER_ID]: "Cline CLI",
306
314
  [GEMINI_PROVIDER_ID]: "Gemini CLI",
@@ -325,14 +333,30 @@ export function modelList(known: string[] | ProviderModels): Array<Record<string
325
333
  display_name: "Configured default provider",
326
334
  }];
327
335
  for (const providerId of [CLINE_PROVIDER_ID, GEMINI_PROVIDER_ID] as const) {
328
- const ids = ["default", ...(known[providerId] ?? []).filter(id => id !== "default")];
329
- for (const id of new Set(ids)) {
336
+ const entries = ["default", ...(known[providerId] ?? []).filter(entry => modelIdOf(entry) !== "default")];
337
+ const seen = new Set<string>();
338
+ for (const entry of entries) {
339
+ const id = modelIdOf(entry);
340
+ if (seen.has(id)) continue;
341
+ seen.add(id);
342
+ const info = typeof entry === "string" ? { id: entry } as OcxProviderModel : entry;
343
+ const reasoning = info.supportedReasoningLevels ?? [];
330
344
  rows.push({
331
345
  id: `${providerId}/${encodeRoutedModelId(id)}`,
332
346
  object: "model",
333
347
  created: 0,
334
348
  owned_by: providerId,
335
- display_name: id === "default" ? `${PROVIDER_LABELS[providerId]} default` : id,
349
+ display_name: info.name ?? (id === "default" ? `${PROVIDER_LABELS[providerId]} default` : id),
350
+ ...(info.description ? { description: info.description } : {}),
351
+ ...(reasoning.length > 0
352
+ ? {
353
+ supported_reasoning_levels: reasoning.map(level => ({
354
+ effort: level.effort,
355
+ description: level.description,
356
+ })),
357
+ default_reasoning_level: info.defaultReasoningLevel ?? reasoning[0]!.effort,
358
+ }
359
+ : { supported_reasoning_levels: [] }),
336
360
  });
337
361
  }
338
362
  }
package/src/types.ts CHANGED
@@ -96,6 +96,19 @@ export interface OcxUsage {
96
96
  cacheCreationInputTokens?: number;
97
97
  }
98
98
 
99
+ export interface OcxReasoningLevel {
100
+ effort: string;
101
+ description: string;
102
+ }
103
+
104
+ export interface OcxProviderModel {
105
+ id: string;
106
+ name?: string;
107
+ description?: string;
108
+ supportedReasoningLevels?: readonly OcxReasoningLevel[];
109
+ defaultReasoningLevel?: string;
110
+ }
111
+
99
112
  export type AdapterEvent =
100
113
  | { type: "heartbeat" }
101
114
  | { type: "text_delta"; text: string }