@bitkyc08/opencodex 1.9.5 → 2.0.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.
@@ -4,10 +4,20 @@
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" type="image/png" href="/favicon.png" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <meta name="color-scheme" content="dark" />
7
+ <meta name="color-scheme" content="light dark" />
8
8
  <title>opencodex · proxy dashboard</title>
9
- <script type="module" crossorigin src="/assets/index-CDhJ0DI7.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-C1wlp1SM.css">
9
+ <script>
10
+ // FOWT guard: apply an explicit light/dark choice before first paint. "system" leaves the
11
+ // attribute unset so color-scheme:light-dark follows the OS. Mirrors App.tsx.
12
+ (function () {
13
+ try {
14
+ var t = localStorage.getItem("ocx-theme");
15
+ if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t);
16
+ } catch (e) {}
17
+ })();
18
+ </script>
19
+ <script type="module" crossorigin src="/assets/index-LGqpEmI5.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-DRr-3yL3.css">
11
21
  </head>
12
22
  <body>
13
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "1.9.5",
3
+ "version": "2.0.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,6 @@
10
10
  },
11
11
  "files": [
12
12
  "src",
13
- "scripts/postinstall.mjs",
14
13
  "gui/dist",
15
14
  "README.md",
16
15
  "LICENSE"
@@ -25,7 +24,6 @@
25
24
  "typecheck": "bun x tsc --noEmit",
26
25
  "generate:jawcode-metadata": "bun scripts/generate-jawcode-metadata.ts",
27
26
  "build:gui": "cd gui && bun install && bun run build",
28
- "postinstall": "node scripts/postinstall.mjs",
29
27
  "prepublishOnly": "bun run typecheck && bun run build:gui",
30
28
  "release": "bun scripts/release.ts",
31
29
  "release:watch": "bun scripts/release.ts watch"
@@ -1,10 +1,11 @@
1
1
  import type { ProviderAdapter } from "./base";
2
2
  import { debugDroppedFrame } from "../debug";
3
- import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxToolCall, OcxUsage } from "../types";
4
- import { namespacedToolName } from "../types";
3
+ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
4
+ import { modelInList, namespacedToolName } from "../types";
5
+ import { mapReasoningEffort } from "../reasoning-effort";
5
6
  import { contentPartsToText } from "./image";
6
7
 
7
- function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
8
+ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
8
9
  const out: unknown[] = [];
9
10
  const { context, options } = parsed;
10
11
  let pendingToolCallIds = new Set<string>();
@@ -46,11 +47,16 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
46
47
  case "assistant": {
47
48
  const aMsg = msg as OcxAssistantMessage;
48
49
  const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[];
50
+ const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
49
51
  const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
50
52
  const chatMsg: Record<string, unknown> = { role: "assistant" };
51
53
  if (textParts.length > 0) {
52
54
  chatMsg.content = textParts.map(p => p.text).join("");
53
55
  }
56
+ const reasoningContent = thinkingParts.map(p => p.thinking).join("");
57
+ if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
58
+ chatMsg.reasoning_content = reasoningContent;
59
+ }
54
60
  if (toolCalls.length > 0) {
55
61
  chatMsg.tool_calls = toolCalls.map(tc => ({
56
62
  id: tc.id,
@@ -59,9 +65,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
59
65
  }));
60
66
  if (!chatMsg.content) chatMsg.content = null;
61
67
  }
62
- // Skip empty assistant messages (e.g. reasoning-only history items): chat APIs
63
- // like DeepSeek reject an assistant message with neither content nor tool_calls.
64
- if (chatMsg.content === undefined && chatMsg.tool_calls === undefined) break;
68
+ if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
69
+ chatMsg.content = "";
70
+ }
71
+ // Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message
72
+ // with neither content, tool calls, nor a provider-supported reasoning_content field.
73
+ if (chatMsg.content === undefined && chatMsg.tool_calls === undefined && chatMsg.reasoning_content === undefined) break;
65
74
  out.push(chatMsg);
66
75
  pendingToolCallIds = new Set(toolCalls.map(tc => tc.id).filter(Boolean));
67
76
  break;
@@ -141,7 +150,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
141
150
  name: "openai-chat",
142
151
 
143
152
  buildRequest(parsed: OcxParsedRequest) {
144
- const messages = messagesToChatFormat(parsed);
153
+ const messages = messagesToChatFormat(parsed, provider);
145
154
  const tools = toolsToChatFormat(parsed);
146
155
  const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice);
147
156
 
@@ -151,22 +160,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
151
160
  stream: parsed.stream,
152
161
  };
153
162
  if (tools) body.tools = tools;
154
- if (toolChoice !== undefined) body.tool_choice = toolChoice;
163
+ if (toolChoice !== undefined) {
164
+ body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)
165
+ ? (toolChoice === "none" ? "none" : "auto")
166
+ : toolChoice;
167
+ }
155
168
  if (parsed.options.maxOutputTokens !== undefined) body.max_tokens = parsed.options.maxOutputTokens;
156
- if (parsed.options.temperature !== undefined) body.temperature = parsed.options.temperature;
157
- if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP;
169
+ if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) {
170
+ body.temperature = parsed.options.temperature;
171
+ }
172
+ if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) {
173
+ body.top_p = parsed.options.topP;
174
+ }
158
175
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
159
- // Some models reject a reasoning/thinking param entirely (e.g. xAI grok-build-0.1,
160
- // grok-composer-2.5-fast). Drop reasoning_effort for them even if Codex selected an effort.
161
- if (parsed.options.reasoning !== undefined && !provider.noReasoningModels?.includes(parsed.modelId)) {
162
- // Forward the reasoning ladder (low/medium/high/xhigh) as-is. "minimal" (Codex-native lowest,
163
- // widely unsupported downstream) maps to "low"; "max" isn't a real tier (no longer advertised)
164
- // so it folds to "xhigh".
165
- const r = parsed.options.reasoning;
166
- body.reasoning_effort = r === "minimal" ? "low" : r === "max" ? "xhigh" : r;
176
+ const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
177
+ if (reasoningEffort !== undefined) body.reasoning_effort = reasoningEffort;
178
+ if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
179
+ body.presence_penalty = parsed.options.presencePenalty;
180
+ }
181
+ if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
182
+ body.frequency_penalty = parsed.options.frequencyPenalty;
167
183
  }
168
- if (parsed.options.presencePenalty !== undefined) body.presence_penalty = parsed.options.presencePenalty;
169
- if (parsed.options.frequencyPenalty !== undefined) body.frequency_penalty = parsed.options.frequencyPenalty;
170
184
 
171
185
  if (parsed.stream) {
172
186
  body.stream_options = { include_usage: true };
package/src/bridge.ts CHANGED
@@ -103,9 +103,18 @@ export function bridgeToResponsesSSE(
103
103
  // Re-arm Codex's idle timer during silence with a parser-ignored heartbeat (RC3). Skips a tick
104
104
  // whenever a real event was emitted since the last tick, so it only fires on a genuine stall.
105
105
  const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
106
+ let stallTicks = 0;
107
+ const maxStallTicks = 150; // 5 min at default 2 s interval
106
108
  beat = setInterval(() => {
107
109
  if (closed) return;
108
- if (activity) { activity = false; return; }
110
+ if (activity) { activity = false; stallTicks = 0; return; }
111
+ if (++stallTicks >= maxStallTicks) {
112
+ closed = true;
113
+ clearInterval(beat!);
114
+ beat = undefined;
115
+ onCancel?.();
116
+ return;
117
+ }
109
118
  try { controller.enqueue(heartbeatFrame); } catch { closed = true; }
110
119
  }, heartbeatMs);
111
120
 
@@ -351,15 +360,14 @@ export function bridgeToResponsesSSE(
351
360
  if (beat) clearInterval(beat);
352
361
 
353
362
  if (!terminated) {
354
- // The adapter generator ended without a done/error event (e.g. an upstream that closes
355
- // after message_stop, or a routed provider that drops the connection cleanly). Close any
356
- // open items and synthesize a clean completion so the stream is never terminal-less.
363
+ // The adapter generator ended without an explicit done/error event. Mark as incomplete
364
+ // rather than completed so Codex can distinguish a clean finish from a truncated stream.
357
365
  if (currentMsg) closeCurrentMessage();
358
366
  if (currentReasoning) closeCurrentReasoning();
359
367
  if (currentRawReasoning) closeCurrentRawReasoning();
360
368
  if (currentToolCall) closeCurrentToolCall();
361
369
  emit("response.completed", {
362
- response: { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(undefined) },
370
+ response: { ...responseSnapshot("incomplete", finishedItems), usage: responsesUsage(undefined) },
363
371
  });
364
372
  }
365
373
 
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { restoreNativeCodex } from "./codex-inject";
5
5
  import { loadConfig, readPid, removePid, writePid } from "./config";
6
- import { serviceCommand } from "./service";
6
+ import { serviceCommand, stopServiceIfInstalled } from "./service";
7
7
  import { startServer } from "./server";
8
8
  import { maybeShowStarPrompt } from "./star-prompt";
9
9
 
@@ -56,7 +56,7 @@ async function syncModelsToCodex(port?: number) {
56
56
  return result;
57
57
  }
58
58
 
59
- function handleStart() {
59
+ async function handleStart() {
60
60
  const existingPid = readPid();
61
61
  if (existingPid) {
62
62
  console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
@@ -76,9 +76,6 @@ function handleStart() {
76
76
  const server = startServer(port);
77
77
  writePid(process.pid);
78
78
 
79
- void maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
80
- syncModelsToCodex(port).catch(() => {});
81
-
82
79
  const shutdown = () => {
83
80
  console.log("\n🛑 Shutting down opencodex proxy...");
84
81
  server.stop(true);
@@ -91,6 +88,9 @@ function handleStart() {
91
88
 
92
89
  process.on("SIGINT", shutdown);
93
90
  process.on("SIGTERM", shutdown);
91
+
92
+ await maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
93
+ await syncModelsToCodex(port).catch(() => {});
94
94
  }
95
95
 
96
96
  function killProxy(pid: number): void {
@@ -129,6 +129,9 @@ function waitForExit(pid: number, timeoutMs: number): boolean {
129
129
  }
130
130
 
131
131
  function handleStop() {
132
+ const stoppedService = stopServiceIfInstalled();
133
+ if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
134
+
132
135
  const pid = readPid();
133
136
  let stopFailed = false;
134
137
  if (pid) {
@@ -140,10 +143,9 @@ function handleStop() {
140
143
  stopFailed = true;
141
144
  console.error(`❌ Failed to stop proxy (PID ${pid}).`);
142
145
  }
143
- } else {
146
+ } else if (!stoppedService) {
144
147
  console.log("No running proxy found.");
145
148
  }
146
- // Recover native Codex so plain `codex` keeps working while the proxy is down.
147
149
  const r = restoreNativeCodex();
148
150
  console.log(`↩️ ${r.message}`);
149
151
  if (stopFailed) process.exit(1);
@@ -165,7 +167,7 @@ switch (command) {
165
167
  break;
166
168
  }
167
169
  case "start":
168
- handleStart();
170
+ await handleStart();
169
171
  break;
170
172
  case "stop":
171
173
  handleStop();
@@ -202,7 +204,7 @@ switch (command) {
202
204
  const guiUrl = `http://localhost:${config.port}`;
203
205
  if (!cfg.readPid()) {
204
206
  console.log("Proxy not running. Starting...");
205
- handleStart();
207
+ await handleStart();
206
208
  await new Promise(r => setTimeout(r, 1000));
207
209
  }
208
210
  console.log(`Opening ${guiUrl}`);
@@ -6,6 +6,7 @@ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readR
6
6
  import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
7
7
  import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
8
8
  import type { OcxConfig, OcxProviderConfig } from "./types";
9
+ import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, sanitizeCodexReasoningEfforts } from "./reasoning-effort";
9
10
  import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
10
11
  import { shouldCaseFoldMetadataModelId } from "./providers/derive";
11
12
 
@@ -33,10 +34,34 @@ export function nativeOpenAiSlugs(): string[] {
33
34
  return live.length > 0 ? live : NATIVE_OPENAI_MODELS;
34
35
  }
35
36
 
36
- export interface CatalogModel { id: string; provider: string; owned_by?: string; }
37
+ export interface CatalogModel { id: string; provider: string; owned_by?: string; reasoningEfforts?: string[]; contextWindow?: number; inputModalities?: string[]; }
37
38
  type RawEntry = Record<string, unknown>;
38
39
  const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
39
40
 
41
+ /**
42
+ * Image/video GENERATION model families. opencodex routes chat/coding models into Codex; media-
43
+ * generation models (Grok image/video, DALL·E, Imagen, Sora, Veo, …) are useless to a coding agent
44
+ * and must never surface in the dashboard, /v1/models, or the routed catalog. The metadata has no
45
+ * output-modality field, so we classify by id. Extend this list as providers add media models.
46
+ */
47
+ const MEDIA_GEN_FAMILIES = [
48
+ "dall-e", "dalle", "imagen", "sora", "veo", "flux", "kling",
49
+ "seedance", "hailuo", "stable-diffusion", "sdxl", "midjourney",
50
+ ];
51
+ const MEDIA_GEN_ID_RE = new RegExp(
52
+ `(?:^|[/_-])(?:image|video)(?:[/_-]|$)|(?:^|[/_-])(?:${MEDIA_GEN_FAMILIES.join("|")})(?:[/_-]|$|\\d)`,
53
+ "i",
54
+ );
55
+
56
+ /**
57
+ * True when a model id denotes image/video GENERATION (so it should be hidden everywhere). Vision
58
+ * *input* chat models — `grok-2-vision`, `qwen3-vl-*`, `gpt-4o`, `gemini-3-pro-preview` — are
59
+ * intentionally NOT matched: they carry no `image`/`video` id segment and no generation-family token.
60
+ */
61
+ export function isMediaGenerationModelId(id: string): boolean {
62
+ return MEDIA_GEN_ID_RE.test(id);
63
+ }
64
+
40
65
  /** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
41
66
  export function readCodexCatalogPath(): string {
42
67
  try {
@@ -171,19 +196,42 @@ export function loadCatalogTemplate(): RawEntry | null {
171
196
  }
172
197
 
173
198
  /**
174
- * The reasoning ladder advertised for routed models in Codex's picker: low medium high → xhigh.
175
- * This matches Codex's NATIVE catalog exactly Codex's strict parser rejects an unknown effort like
176
- * `max`, so it must not be advertised here. (Previously routed models were clamped down to
177
- * low/medium/high, which dropped the `xhigh` that Codex does support.)
199
+ * Codex only accepts its native labels in the catalog. Provider-specific wire values (e.g. Z.AI
200
+ * `max`) are mapped at request time by src/reasoning-effort.ts, never advertised directly here.
178
201
  */
179
- const ROUTED_REASONING_LEVELS: { effort: string; description: string }[] = [
180
- { effort: "low", description: "Fast responses with lighter reasoning" },
181
- { effort: "medium", description: "Balances speed and reasoning depth" },
182
- { effort: "high", description: "Greater reasoning depth for complex problems" },
183
- { effort: "xhigh", description: "Extended reasoning for the hardest problems" },
184
- ];
202
+ const ROUTED_REASONING_LEVELS = CODEX_REASONING_LEVELS;
203
+
204
+ function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void {
205
+ if (!model) return;
206
+ if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
207
+ entry.context_window = model.contextWindow;
208
+ entry.max_context_window = model.contextWindow;
209
+ entry.auto_compact_token_limit = Math.floor(model.contextWindow * 0.9);
210
+ }
211
+ if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
212
+ entry.input_modalities = model.inputModalities;
213
+ }
214
+ }
215
+
216
+ function applyReasoningLevels(entry: RawEntry, effortsOverride?: string[]): void {
217
+ const efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort);
218
+ const byEffort = new Map(
219
+ (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
220
+ .map((l: { effort?: string }) => [l.effort, l]),
221
+ );
222
+ entry.supported_reasoning_levels = efforts.map(effort => {
223
+ const native = byEffort.get(effort);
224
+ if (native) return native;
225
+ return ROUTED_REASONING_LEVELS.find(l => l.effort === effort) ?? { effort, description: `${effort} reasoning` };
226
+ });
227
+ if (efforts.length === 0) {
228
+ delete entry.default_reasoning_level;
229
+ return;
230
+ }
231
+ entry.default_reasoning_level = efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
232
+ }
185
233
 
186
- function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number): RawEntry {
234
+ function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number, model?: CatalogModel): RawEntry {
187
235
  if (template) {
188
236
  const e = JSON.parse(JSON.stringify(template)) as RawEntry;
189
237
  e.slug = slug;
@@ -203,28 +251,24 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
203
251
  `You are a coding agent powered by the ${modelName} model, served through the opencodex proxy. Do not claim to be GPT-5 or made by OpenAI.`,
204
252
  );
205
253
  }
206
- // Reuse the template's level objects where they exist (correct shape/fields), synthesize the rest.
207
- const byEffort = new Map(
208
- (Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels : [])
209
- .map((l: { effort?: string }) => [l.effort, l]),
210
- );
211
- e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l });
212
- e.default_reasoning_level = "medium";
254
+ applyReasoningLevels(e, model?.reasoningEfforts);
213
255
  normalizeRoutedCatalogEntry(e);
214
256
  applyJawcodeCatalogMetadata(e, slug);
257
+ applyCatalogModelMetadata(e, model);
215
258
  }
216
259
  return ensureStrictCatalogFields(normalizeServiceTiers(e));
217
260
  }
218
261
  // Fallback when no template is available (best-effort; strict parser may need more).
219
262
  const entry: RawEntry = {
220
263
  slug, display_name: slug, description: desc,
221
- default_reasoning_level: "medium",
222
- supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })),
223
264
  shell_type: "shell_command", visibility: "list", supported_in_api: true,
224
265
  priority, base_instructions: "You are a helpful coding assistant.",
225
266
  ...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
226
267
  };
268
+ if (slug.includes("/")) applyReasoningLevels(entry, model?.reasoningEfforts);
269
+ else applyReasoningLevels(entry);
227
270
  applyJawcodeCatalogMetadata(entry, slug);
271
+ applyCatalogModelMetadata(entry, model);
228
272
  return ensureStrictCatalogFields(normalizeServiceTiers(entry));
229
273
  }
230
274
 
@@ -247,7 +291,7 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
247
291
  }
248
292
  for (const m of goModels) {
249
293
  const slug = `${m.provider}/${m.id}`;
250
- const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5);
294
+ const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5, m);
251
295
  if (rank.has(slug)) e.priority = rank.get(slug)!;
252
296
  out.push(e);
253
297
  }
@@ -285,6 +329,61 @@ function readNativeBaseline(): Map<string, number> {
285
329
  return out;
286
330
  }
287
331
 
332
+
333
+ type ProviderModelsApiItem = {
334
+ id: string;
335
+ owned_by?: string;
336
+ max_model_len?: number;
337
+ metadata?: {
338
+ capabilities?: Record<string, unknown>;
339
+ limits?: Record<string, unknown>;
340
+ };
341
+ };
342
+
343
+ function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string): Partial<CatalogModel> {
344
+ void name;
345
+ const reasoningEfforts = configuredReasoningEfforts(prov, id);
346
+ return {
347
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
348
+ };
349
+ }
350
+
351
+ function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[]): CatalogModel[] {
352
+ return models.map(model => ({
353
+ ...catalogHintsFromProviderConfig(name, prov, model.id),
354
+ ...model,
355
+ }));
356
+ }
357
+
358
+ function isGlm52ModelId(id: string): boolean {
359
+ const normalized = id.toLowerCase();
360
+ return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
361
+ }
362
+
363
+ function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
364
+ const capabilities = item.metadata?.capabilities;
365
+ const limits = item.metadata?.limits;
366
+ const contextWindow =
367
+ typeof limits?.max_context_length === "number" ? limits.max_context_length
368
+ : typeof item.max_model_len === "number" ? item.max_model_len
369
+ : undefined;
370
+ const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean"
371
+ ? (capabilities.reasoning_effort
372
+ ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
373
+ ? ["low", "medium", "high", "xhigh"]
374
+ : ["low", "medium", "high"])
375
+ : [])
376
+ : undefined;
377
+ const inputModalities = capabilities && typeof capabilities.vision === "boolean"
378
+ ? (capabilities.vision ? ["text", "image"] : ["text"])
379
+ : undefined;
380
+ return {
381
+ ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
382
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
383
+ ...(inputModalities ? { inputModalities } : {}),
384
+ };
385
+ }
386
+
288
387
  /**
289
388
  * Fetch a provider's `/models` (openai-chat style) with a TTL cache + stale fallback. Skips
290
389
  * forward-auth providers. Fresh cache → no network; live fetch → cache the merged result;
@@ -296,21 +395,35 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
296
395
  const apiKey = await resolveModelsAuthToken(name, prov);
297
396
  if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
298
397
  const fresh = getFreshCached(name, ttlMs);
299
- if (fresh) return fresh; // dedups Codex's frequent /v1/models polling within the TTL
300
- const configured: CatalogModel[] = (prov.models ?? []).map(id => ({ id, provider: name }));
398
+ if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh); // dedups Codex's frequent /v1/models polling within the TTL
399
+ const configured: CatalogModel[] = (prov.models ?? []).map(id => ({
400
+ id,
401
+ provider: name,
402
+ ...catalogHintsFromProviderConfig(name, prov, id),
403
+ }));
301
404
  const { url, headers } = buildModelsRequest(prov, apiKey);
302
405
  try {
303
406
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
304
- if (!res.ok) return getStaleCached(name) ?? configured;
305
- const json = await res.json() as { data?: { id: string; owned_by?: string }[] };
306
- const live = (json.data ?? []).map(m => ({ id: m.id, provider: name, owned_by: m.owned_by }));
407
+ if (!res.ok) {
408
+ const stale = getStaleCached(name);
409
+ return stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured;
410
+ }
411
+ const json = await res.json() as { data?: ProviderModelsApiItem[] };
412
+ const live = (json.data ?? []).map(m => ({
413
+ id: m.id,
414
+ provider: name,
415
+ owned_by: m.owned_by,
416
+ ...catalogHintsFromProviderConfig(name, prov, m.id),
417
+ ...catalogHintsFromModelsApiItem(name, m),
418
+ }));
307
419
  const liveIds = new Set(live.map(m => m.id));
308
420
  // Merge explicit config additions (e.g. a model not in the provider's /models, like a new endpoint).
309
421
  const merged = [...live, ...configured.filter(m => !liveIds.has(m.id))];
310
422
  setCached(name, merged);
311
423
  return merged;
312
424
  } catch {
313
- return getStaleCached(name) ?? configured;
425
+ const stale = getStaleCached(name);
426
+ return stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured;
314
427
  }
315
428
  }
316
429
 
@@ -325,12 +438,15 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
325
438
  const lists = await Promise.all(
326
439
  Object.entries(config.providers).map(([name, prov]) => fetchProviderModels(name, prov, ttlMs)),
327
440
  );
328
- const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers));
441
+ const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers), config.providers)
442
+ // Drop image/video generation models (e.g. Grok image/video) — they are not usable by Codex and
443
+ // must not surface in the dashboard, /v1/models, or the routed catalog. Single choke point.
444
+ .filter(m => !isMediaGenerationModelId(m.id));
329
445
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
330
446
  return all;
331
447
  }
332
448
 
333
- export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[]): CatalogModel[] {
449
+ export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[], providers?: Record<string, OcxProviderConfig>): CatalogModel[] {
334
450
  const out = [...models];
335
451
  const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
336
452
  for (const provider of providerNames) {
@@ -341,7 +457,7 @@ export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], p
341
457
  const key = `${provider}/${meta.id}`;
342
458
  if (seen.has(key)) continue;
343
459
  seen.add(key);
344
- out.push({ provider, id: meta.id, owned_by: provider });
460
+ out.push({ provider, id: meta.id, owned_by: provider, ...(providers?.[provider] ? catalogHintsFromProviderConfig(provider, providers[provider], meta.id) : {}) });
345
461
  }
346
462
  }
347
463
  return out;
package/src/config.ts CHANGED
@@ -3,12 +3,13 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { OcxConfig } from "./types";
5
5
 
6
+ let _atomicSeq = 0;
6
7
  /**
7
8
  * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
8
9
  * proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
9
10
  */
10
11
  export function atomicWriteFile(path: string, content: string): void {
11
- const tmp = `${path}.ocx.tmp`;
12
+ const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
12
13
  writeFileSync(tmp, content, "utf-8");
13
14
  renameSync(tmp, path);
14
15
  }
@@ -121,24 +121,40 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und
121
121
  * Only touches providers that are registry-managed AND still `authMode: "oauth"`, and only the
122
122
  * preset fields (never apiKey/baseUrl/user toggles). Persists + returns true when anything changed.
123
123
  */
124
+ function cloneProviderField(value: unknown): unknown {
125
+ if (Array.isArray(value)) return [...value];
126
+ if (value && typeof value === "object") return JSON.parse(JSON.stringify(value));
127
+ return value;
128
+ }
129
+
130
+ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
131
+ "models",
132
+ "noReasoningModels",
133
+ "noVisionModels",
134
+ "reasoningEfforts",
135
+ "modelReasoningEfforts",
136
+ "reasoningEffortMap",
137
+ "modelReasoningEffortMap",
138
+ "noTemperatureModels",
139
+ "noTopPModels",
140
+ "noPenaltyModels",
141
+ "autoToolChoiceOnlyModels",
142
+ "preserveReasoningContentModels",
143
+ ];
144
+
124
145
  export function reconcileOAuthProviders(config: OcxConfig): boolean {
125
146
  let changed = false;
126
147
  for (const [name, prov] of Object.entries(config.providers)) {
127
148
  const def = OAUTH_PROVIDERS[name];
128
149
  if (!def || prov.authMode !== "oauth") continue;
129
150
  const preset = def.providerConfig;
130
- if (preset.models && JSON.stringify(prov.models) !== JSON.stringify(preset.models)) {
131
- prov.models = [...preset.models];
132
- changed = true;
133
- }
134
- if (JSON.stringify(prov.noReasoningModels) !== JSON.stringify(preset.noReasoningModels)) {
135
- if (preset.noReasoningModels) prov.noReasoningModels = [...preset.noReasoningModels];
136
- else delete prov.noReasoningModels;
137
- changed = true;
138
- }
139
- if (JSON.stringify(prov.noVisionModels) !== JSON.stringify(preset.noVisionModels)) {
140
- if (preset.noVisionModels) prov.noVisionModels = [...preset.noVisionModels];
141
- else delete prov.noVisionModels;
151
+ for (const field of OAUTH_RECONCILE_FIELDS) {
152
+ if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
153
+ if (preset[field] !== undefined) {
154
+ prov[field] = cloneProviderField(preset[field]) as never;
155
+ } else {
156
+ delete prov[field];
157
+ }
142
158
  changed = true;
143
159
  }
144
160
  // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
@@ -20,8 +20,17 @@ export interface KeyLoginProvider {
20
20
  * accept a reasoning param. Copied into the created provider config by `enrichProviderFromCatalog`,
21
21
  * so the classification actually gates the sidecars (matching is tolerant of an Ollama ":size" tag).
22
22
  */
23
+ reasoningEfforts?: string[];
24
+ modelReasoningEfforts?: Record<string, string[]>;
25
+ reasoningEffortMap?: Record<string, string>;
26
+ modelReasoningEffortMap?: Record<string, Record<string, string>>;
23
27
  noVisionModels?: string[];
24
28
  noReasoningModels?: string[];
29
+ noTemperatureModels?: string[];
30
+ noTopPModels?: string[];
31
+ noPenaltyModels?: string[];
32
+ autoToolChoiceOnlyModels?: string[];
33
+ preserveReasoningContentModels?: string[];
25
34
  }
26
35
 
27
36
  export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLoginMap();
@@ -37,8 +46,26 @@ export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig)
37
46
  if (!e) return;
38
47
  if (!prov.models && e.models) prov.models = [...e.models];
39
48
  if (!prov.defaultModel && e.defaultModel) prov.defaultModel = e.defaultModel;
49
+ if (!prov.reasoningEfforts && e.reasoningEfforts) prov.reasoningEfforts = [...e.reasoningEfforts];
50
+ if (!prov.modelReasoningEfforts && e.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(e.modelReasoningEfforts);
51
+ if (!prov.reasoningEffortMap && e.reasoningEffortMap) prov.reasoningEffortMap = { ...e.reasoningEffortMap };
52
+ if (!prov.modelReasoningEffortMap && e.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(e.modelReasoningEffortMap);
40
53
  if (!prov.noVisionModels && e.noVisionModels) prov.noVisionModels = [...e.noVisionModels];
41
54
  if (!prov.noReasoningModels && e.noReasoningModels) prov.noReasoningModels = [...e.noReasoningModels];
55
+ if (!prov.noTemperatureModels && e.noTemperatureModels) prov.noTemperatureModels = [...e.noTemperatureModels];
56
+ if (!prov.noTopPModels && e.noTopPModels) prov.noTopPModels = [...e.noTopPModels];
57
+ if (!prov.noPenaltyModels && e.noPenaltyModels) prov.noPenaltyModels = [...e.noPenaltyModels];
58
+ if (!prov.autoToolChoiceOnlyModels && e.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...e.autoToolChoiceOnlyModels];
59
+ if (!prov.preserveReasoningContentModels && e.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...e.preserveReasoningContentModels];
60
+ }
61
+
62
+
63
+ function cloneRecordOfArrays(input: Record<string, string[]>): Record<string, string[]> {
64
+ return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, [...value]]));
65
+ }
66
+
67
+ function cloneNestedRecord(input: Record<string, Record<string, string>>): Record<string, Record<string, string>> {
68
+ return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { ...value }]));
42
69
  }
43
70
 
44
71
  export function isKeyLoginProvider(name: string): boolean {