@koreanhole/opencodex 2.95.10 → 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/src/config.ts CHANGED
@@ -3,10 +3,13 @@ import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  export const CLINE_PROVIDER_ID = "cline-cli";
6
+ export const GEMINI_PROVIDER_ID = "gemini-cli";
6
7
  export const CLINE_BASE_URL = "https://api.cline.bot";
7
8
  export const DEFAULT_PORT = 10_100;
8
9
  export const DEFAULT_HOST = "127.0.0.1";
9
10
 
11
+ export type ProviderId = typeof CLINE_PROVIDER_ID | typeof GEMINI_PROVIDER_ID;
12
+
10
13
  export interface ClineProviderConfig {
11
14
  adapter: "cline-cli";
12
15
  baseUrl: typeof CLINE_BASE_URL;
@@ -14,11 +17,19 @@ export interface ClineProviderConfig {
14
17
  models: string[];
15
18
  }
16
19
 
20
+ export interface GeminiProviderConfig {
21
+ adapter: "gemini-cli";
22
+ defaultModel: "default";
23
+ models: string[];
24
+ }
25
+
26
+ export type ProviderConfig = ClineProviderConfig | GeminiProviderConfig;
27
+
17
28
  export interface RuntimeConfig {
18
29
  port: number;
19
30
  host: string;
20
- defaultProvider: typeof CLINE_PROVIDER_ID;
21
- providers: Record<typeof CLINE_PROVIDER_ID, ClineProviderConfig>;
31
+ defaultProvider: ProviderId;
32
+ providers: Record<ProviderId, ProviderConfig>;
22
33
  apiAuthToken?: string;
23
34
  }
24
35
 
@@ -67,6 +78,11 @@ export function defaultConfig(): RuntimeConfig {
67
78
  defaultModel: "default",
68
79
  models: ["default"],
69
80
  },
81
+ [GEMINI_PROVIDER_ID]: {
82
+ adapter: GEMINI_PROVIDER_ID,
83
+ defaultModel: "default",
84
+ models: ["default"],
85
+ },
70
86
  },
71
87
  };
72
88
  }
@@ -85,7 +101,7 @@ function validPort(value: unknown): number {
85
101
  return value;
86
102
  }
87
103
 
88
- function normalizeProvider(value: unknown): ClineProviderConfig {
104
+ function normalizeClineProvider(value: unknown): ClineProviderConfig {
89
105
  const row = asRecord(value);
90
106
  if (!row) throw new ConfigError("config.providers.cline-cli must be an object.");
91
107
  if (row.adapter !== undefined && row.adapter !== CLINE_PROVIDER_ID) {
@@ -106,17 +122,42 @@ function normalizeProvider(value: unknown): ClineProviderConfig {
106
122
  };
107
123
  }
108
124
 
125
+ function normalizeGeminiProvider(value: unknown): GeminiProviderConfig {
126
+ const row = asRecord(value);
127
+ if (!row) throw new ConfigError("config.providers.gemini-cli must be an object.");
128
+ if (row.adapter !== undefined && row.adapter !== GEMINI_PROVIDER_ID) {
129
+ throw new ConfigError("config.providers.gemini-cli.adapter must be gemini-cli.");
130
+ }
131
+ const models = Array.isArray(row.models)
132
+ ? [...new Set(row.models.filter((item): item is string => typeof item === "string" && item.length > 0))]
133
+ : ["default"];
134
+ if (!models.includes("default")) models.unshift("default");
135
+ return {
136
+ adapter: GEMINI_PROVIDER_ID,
137
+ defaultModel: "default",
138
+ models,
139
+ };
140
+ }
141
+
142
+ function providerId(value: unknown): ProviderId | undefined {
143
+ return value === CLINE_PROVIDER_ID || value === GEMINI_PROVIDER_ID ? value : undefined;
144
+ }
145
+
109
146
  export function normalizeConfig(input: unknown): RuntimeConfig {
110
147
  const row = asRecord(input);
111
148
  if (!row) throw new ConfigError("config.json must contain a JSON object.");
112
- if (row.defaultProvider !== undefined && row.defaultProvider !== CLINE_PROVIDER_ID) {
149
+ let defaultProvider = providerId(row.defaultProvider);
150
+ if (row.defaultProvider !== undefined && !defaultProvider) {
113
151
  console.warn(`[opencodex] ignoring unsupported defaultProvider ${JSON.stringify(row.defaultProvider)}; using cline-cli.`);
152
+ defaultProvider = CLINE_PROVIDER_ID;
114
153
  }
115
154
  const providers = asRecord(row.providers);
116
155
  for (const key of Object.keys(providers ?? {})) {
117
- if (key !== CLINE_PROVIDER_ID) console.warn(`[opencodex] ignoring unsupported provider ${JSON.stringify(key)}.`);
156
+ if (!providerId(key)) console.warn(`[opencodex] ignoring unsupported provider ${JSON.stringify(key)}.`);
118
157
  }
119
- const provider = normalizeProvider(providers?.[CLINE_PROVIDER_ID] ?? defaultConfig().providers[CLINE_PROVIDER_ID]);
158
+ const defaults = defaultConfig().providers;
159
+ const cline = normalizeClineProvider(providers?.[CLINE_PROVIDER_ID] ?? defaults[CLINE_PROVIDER_ID]);
160
+ const gemini = normalizeGeminiProvider(providers?.[GEMINI_PROVIDER_ID] ?? defaults[GEMINI_PROVIDER_ID]);
120
161
  const host = typeof row.host === "string" && row.host.trim() ? row.host.trim() : DEFAULT_HOST;
121
162
  const apiAuthToken = typeof row.apiAuthToken === "string" && row.apiAuthToken.trim()
122
163
  ? row.apiAuthToken.trim()
@@ -124,8 +165,8 @@ export function normalizeConfig(input: unknown): RuntimeConfig {
124
165
  return {
125
166
  port: row.port === undefined ? DEFAULT_PORT : validPort(row.port),
126
167
  host,
127
- defaultProvider: CLINE_PROVIDER_ID,
128
- providers: { [CLINE_PROVIDER_ID]: provider },
168
+ defaultProvider: defaultProvider ?? CLINE_PROVIDER_ID,
169
+ providers: { [CLINE_PROVIDER_ID]: cline, [GEMINI_PROVIDER_ID]: gemini },
129
170
  ...(apiAuthToken ? { apiAuthToken } : {}),
130
171
  };
131
172
  }
@@ -143,7 +184,7 @@ export async function loadConfig(env: NodeJS.ProcessEnv = process.env): Promise<
143
184
  try {
144
185
  parsed = JSON.parse(text);
145
186
  } catch {
146
- throw new ConfigError(`${path} is not valid JSON. Repair it or remove it to recreate the Cline-only default.`);
187
+ throw new ConfigError(`${path} is not valid JSON. Repair it or remove it to recreate the default config.`);
147
188
  }
148
189
  return normalizeConfig(parsed);
149
190
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { startServer, type RunningServer, type ServerOptions } from "./server";
2
2
  export { loadConfig, saveConfig, defaultConfig } from "./config";
3
3
  export { runClineTurn } from "./adapters/cli-agent/cline/adapter";
4
+ export { runGeminiTurn } from "./adapters/cli-agent/gemini/adapter";
4
5
  export type {
5
6
  AdapterEvent,
6
7
  IncomingMeta,
@@ -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
+ }
package/src/lib/redact.ts CHANGED
@@ -2,7 +2,10 @@
2
2
  export function redactSecretString(value: string): string {
3
3
  return value
4
4
  .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")
5
- .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]")
5
+ .replace(/\bAIza[0-9A-Za-z_-]{20,}\b/g, "[redacted]")
6
+ .replace(/\bya29\.[0-9A-Za-z._-]{20,}\b/g, "[redacted]")
7
+ .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, "$1[redacted]")
6
8
  .replace(/(x-api-key\s*[:=]\s*)\S+/gi, "$1[redacted]")
9
+ .replace(/((?:api[_-]?key|token)\s*[:=]\s*)\S+/gi, "$1[redacted]")
7
10
  .slice(0, 4_096);
8
11
  }
@@ -9,8 +9,11 @@ import {
9
9
  } from "../config";
10
10
  import type { IncomingMeta } from "../types";
11
11
  import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
12
+ import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
12
13
  import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
13
- import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError } from "./responses";
14
+ import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
15
+ import { buildCodexCatalogDocument, discoverCodexCatalogModels } from "../lib/codex-catalog";
16
+ import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, type ProviderModels } from "./responses";
14
17
  import { handleCountTokens, handleMessages } from "./messages";
15
18
 
16
19
  const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
@@ -19,6 +22,8 @@ const REQUEST_TIMEOUT_MS = 300_000;
19
22
  export interface ServerOptions {
20
23
  env?: NodeJS.ProcessEnv;
21
24
  clineDeps?: ClineTurnDeps;
25
+ geminiDeps?: GeminiTurnDeps;
26
+ geminiPackageRoot?: string;
22
27
  }
23
28
 
24
29
  export interface RunningServer {
@@ -73,11 +78,28 @@ function errorResponse(error: unknown): Response {
73
78
  return Response.json({ error: { message: "Proxy request failed.", type: "proxy_error", code: "proxy_error" } }, { status: 500 });
74
79
  }
75
80
 
76
- async function discoverModels(env: NodeJS.ProcessEnv, deps: ClineTurnDeps | undefined): Promise<string[]> {
77
- const live = await discoverClineLiveModels({ env, ...(deps ? { deps } : {}) });
78
- if (live.ok) return ["default", ...live.models.filter(id => id !== "default")];
79
- if (live.reason !== "not_found") console.warn(`[opencodex] Cline model discovery degraded [${live.reason}]: ${live.detail}`);
80
- return ["default"];
81
+ async function discoverModels(
82
+ env: NodeJS.ProcessEnv,
83
+ clineDeps: ClineTurnDeps | undefined,
84
+ geminiDeps: GeminiTurnDeps | undefined,
85
+ geminiPackageRoot: string | undefined,
86
+ ): Promise<ProviderModels> {
87
+ const [cline, gemini] = await Promise.all([
88
+ discoverClineLiveModels({ env, ...(clineDeps ? { deps: clineDeps } : {}) }),
89
+ discoverGeminiLiveModels({ env, ...(geminiDeps ? { deps: geminiDeps } : {}), ...(geminiPackageRoot ? { packageRoot: geminiPackageRoot } : {}) }),
90
+ ]);
91
+ if (!cline.ok && cline.reason !== "not_found" && env.OPENCODEX_CLINE_ACP?.trim() !== "0") {
92
+ console.warn(`[opencodex] Cline model discovery degraded [${cline.reason}]: ${cline.detail}`);
93
+ }
94
+ if (!gemini.ok && gemini.reason !== "not_found" && env.OPENCODEX_GEMINI_ACP?.trim() !== "0") {
95
+ console.warn(`[opencodex] Gemini model discovery degraded [${gemini.reason}]: ${gemini.detail}`);
96
+ }
97
+ return {
98
+ "cline-cli": cline.ok ? ["default", ...cline.models.filter(id => id !== "default")] : ["default"],
99
+ "gemini-cli": gemini.ok
100
+ ? ["default", ...gemini.modelInfo.filter(model => model.id !== "default")]
101
+ : ["default"],
102
+ };
81
103
  }
82
104
 
83
105
  export function startServer(config: RuntimeConfig | number, options: ServerOptions = {}): RunningServer {
@@ -86,6 +108,8 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
86
108
  : normalizeConfig(config);
87
109
  const env = options.env ?? process.env;
88
110
  const clineDeps = { ...options.clineDeps, env: options.clineDeps?.env ?? env };
111
+ const geminiDeps = { ...options.geminiDeps, env: options.geminiDeps?.env ?? env };
112
+ const turnDeps = { cline: clineDeps, gemini: geminiDeps };
89
113
  assertSafeListener(config, env);
90
114
  const token = apiToken(config, env);
91
115
  let server: ReturnType<typeof Bun.serve>;
@@ -93,28 +117,37 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
93
117
  const fetch = async (req: Request): Promise<Response> => {
94
118
  const url = new URL(req.url);
95
119
  if (url.pathname === "/healthz") {
96
- return Response.json({ ok: true, service: "opencodex-cline", version: packageInfo.version });
120
+ return Response.json({ ok: true, service: "opencodex", version: packageInfo.version });
97
121
  }
98
122
  if (!authorized(req, token)) {
99
123
  return Response.json({ error: { message: "Unauthorized", type: "authentication_error", code: "unauthorized" } }, { status: 401 });
100
124
  }
101
125
  try {
102
126
  if (req.method === "GET" && url.pathname === "/v1/models") {
103
- const known = await discoverModels(env, options.clineDeps);
127
+ const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
104
128
  return Response.json({ object: "list", data: modelList(known) });
105
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
+ }
106
139
  if (req.method === "POST" && url.pathname === "/v1/responses") {
107
140
  const body = await boundedJson(req);
108
- const known = await discoverModels(env, options.clineDeps);
141
+ const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
109
142
  const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
110
143
  const incoming: IncomingMeta = { headers: req.headers, abortSignal: signal };
111
- return await handleResponses(body, incoming, known, clineDeps);
144
+ return await handleResponses(body, incoming, known, turnDeps, config.defaultProvider);
112
145
  }
113
146
  if (req.method === "POST" && url.pathname === "/v1/messages") {
114
147
  const body = await boundedJson(req);
115
- const known = await discoverModels(env, options.clineDeps);
148
+ const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
116
149
  const signal = AbortSignal.any([req.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
117
- return await handleMessages(body, { headers: req.headers, abortSignal: signal }, known, clineDeps);
150
+ return await handleMessages(body, { headers: req.headers, abortSignal: signal }, known, turnDeps, config.defaultProvider);
118
151
  }
119
152
  if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
120
153
  return handleCountTokens(await boundedJson(req));
@@ -125,7 +158,7 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
125
158
  }
126
159
  };
127
160
 
128
- server = Bun.serve({ hostname: config.host, port: config.port, fetch });
161
+ server = Bun.serve({ hostname: config.host, port: config.port, idleTimeout: 255, fetch });
129
162
  return {
130
163
  port: server.port ?? config.port,
131
164
  host: config.host,
@@ -1,12 +1,15 @@
1
1
  import type { AdapterEvent, IncomingMeta, OcxMessage, OcxParsedRequest, OcxTool, OcxToolChoice } from "../types";
2
- import { runClineTurn, type ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
2
+ import type { ClineTurnDeps } from "../adapters/cli-agent/cline/adapter";
3
+ import { CLINE_PROVIDER_ID, type ProviderId } from "../config";
3
4
  import {
4
5
  collectResponsesResult,
5
6
  RequestValidationError,
6
- resolveClineModel,
7
+ routedTurn,
7
8
  UnsupportedMediaError,
8
9
  type DeclaredTool,
9
10
  type ParsedResponsesRequest,
11
+ type ProviderModels,
12
+ type ProviderTurnDeps,
10
13
  } from "./responses";
11
14
 
12
15
  function record(value: unknown): Record<string, unknown> | undefined {
@@ -210,14 +213,12 @@ function anthropicUsage(value: unknown): { input_tokens: number; output_tokens:
210
213
  export async function handleMessages(
211
214
  body: unknown,
212
215
  incoming: IncomingMeta,
213
- knownModels: string[],
214
- deps: ClineTurnDeps = {},
216
+ knownModels: string[] | ProviderModels,
217
+ deps: ClineTurnDeps | ProviderTurnDeps = {},
218
+ defaultProvider: ProviderId = CLINE_PROVIDER_ID,
215
219
  ): Promise<Response> {
216
220
  const parsed = parseMessagesRequest(body);
217
- parsed.parsed.modelId = parsed.requestedModel.startsWith("cline-cli/")
218
- ? resolveClineModel(parsed.requestedModel, knownModels)
219
- : "default";
220
- const run = (emit: (event: AdapterEvent) => void) => runClineTurn(parsed.parsed, incoming, emit, deps);
221
+ const { run } = routedTurn(parsed.parsed, parsed.requestedModel, incoming, knownModels, deps, defaultProvider);
221
222
  const result = await collectResponsesResult(parsed, run);
222
223
  if (result.failure) {
223
224
  return Response.json({