@bitkyc08/opencodex 1.9.0 → 1.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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",
@@ -7,6 +7,7 @@ import { contentPartsToText } from "./image";
7
7
  function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
8
8
  const out: unknown[] = [];
9
9
  const { context, options } = parsed;
10
+ let pendingToolCallIds = new Set<string>();
10
11
 
11
12
  if (context.systemPrompt && context.systemPrompt.length > 0) {
12
13
  // Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
@@ -39,6 +40,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
39
40
  out.push({ role: "user", content: chatParts });
40
41
  }
41
42
  }
43
+ pendingToolCallIds = new Set();
42
44
  break;
43
45
  }
44
46
  case "assistant": {
@@ -61,14 +63,33 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
61
63
  // like DeepSeek reject an assistant message with neither content nor tool_calls.
62
64
  if (chatMsg.content === undefined && chatMsg.tool_calls === undefined) break;
63
65
  out.push(chatMsg);
66
+ pendingToolCallIds = new Set(toolCalls.map(tc => tc.id).filter(Boolean));
64
67
  break;
65
68
  }
66
69
  case "toolResult": {
70
+ let toolCallId = msg.toolCallId;
71
+ if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
72
+ if (!pendingToolCallIds.has(toolCallId)) {
73
+ // WS turns can arrive with only tool outputs; chat-completions providers reject a bare
74
+ // role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
75
+ const name = safeToolName(msg.toolName);
76
+ out.push({
77
+ role: "assistant",
78
+ content: null,
79
+ tool_calls: [{
80
+ id: toolCallId,
81
+ type: "function",
82
+ function: { name, arguments: "{}" },
83
+ }],
84
+ });
85
+ pendingToolCallIds = new Set([toolCallId]);
86
+ }
67
87
  out.push({
68
88
  role: "tool",
69
- tool_call_id: msg.toolCallId,
89
+ tool_call_id: toolCallId,
70
90
  content: contentPartsToText(msg.content),
71
91
  });
92
+ pendingToolCallIds.delete(toolCallId);
72
93
  break;
73
94
  }
74
95
  }
@@ -77,6 +98,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
77
98
  return out;
78
99
  }
79
100
 
101
+ function safeToolName(name: string | undefined): string {
102
+ const raw = name && name.trim().length > 0 ? name : "tool_result";
103
+ const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
104
+ return sanitized.length > 0 ? sanitized : "tool_result";
105
+ }
106
+
80
107
  function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
81
108
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
82
109
  return parsed.context.tools.map(t => ({
@@ -57,6 +57,12 @@ function readCatalog(path: string): { models?: RawEntry[]; [k: string]: unknown
57
57
  } catch { return null; }
58
58
  }
59
59
 
60
+ function findNativeTemplate(catalog: { models?: RawEntry[] } | null): RawEntry | null {
61
+ return catalog?.models?.find(
62
+ m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
63
+ ) ?? null;
64
+ }
65
+
60
66
  function normalizeServiceTiers(entry: RawEntry): RawEntry {
61
67
  // Codex stores the user-facing config spelling as "fast", but the catalog/request
62
68
  // service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
@@ -83,6 +89,28 @@ function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry {
83
89
  return entry;
84
90
  }
85
91
 
92
+ function ensureStrictCatalogFields(entry: RawEntry): RawEntry {
93
+ if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = true;
94
+ if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
95
+ if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true;
96
+ if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low";
97
+ if (typeof entry.apply_patch_tool_type !== "string") entry.apply_patch_tool_type = "freeform";
98
+ if (!entry.truncation_policy || typeof entry.truncation_policy !== "object" || Array.isArray(entry.truncation_policy)) {
99
+ entry.truncation_policy = { mode: "tokens", limit: 10000 };
100
+ }
101
+ if (typeof entry.supports_parallel_tool_calls !== "boolean") entry.supports_parallel_tool_calls = true;
102
+ if (typeof entry.supports_image_detail_original !== "boolean") entry.supports_image_detail_original = false;
103
+ if (!Array.isArray(entry.experimental_supported_tools)) entry.experimental_supported_tools = [];
104
+ if (!Array.isArray(entry.input_modalities)) entry.input_modalities = ["text"];
105
+ if (typeof entry.context_window !== "number" || entry.context_window <= 0) entry.context_window = 128000;
106
+ if (typeof entry.max_context_window !== "number" || entry.max_context_window <= 0) {
107
+ entry.max_context_window = entry.context_window;
108
+ }
109
+ if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95;
110
+ if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex";
111
+ return ensureAutoCompactTokenLimit(entry);
112
+ }
113
+
86
114
  export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
87
115
  delete entry.model_messages;
88
116
  delete entry.tool_mode;
@@ -97,7 +125,7 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
97
125
  // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only models.
98
126
  entry.web_search_tool_type = "text_and_image";
99
127
  entry.supports_search_tool = true;
100
- return ensureAutoCompactTokenLimit(entry);
128
+ return ensureStrictCatalogFields(entry);
101
129
  }
102
130
 
103
131
  function applyJawcodeCatalogMetadata(entry: RawEntry, slug: string): void {
@@ -122,8 +150,8 @@ function applyJawcodeCatalogMetadata(entry: RawEntry, slug: string): void {
122
150
 
123
151
  function loadCatalogForSync(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
124
152
  const catalog = readCatalog(path);
125
- if (catalog) return catalog;
126
- return readCatalog(CODEX_MODELS_CACHE_PATH);
153
+ if (catalog && findNativeTemplate(catalog)) return catalog;
154
+ return readCatalog(CATALOG_BACKUP_PATH) ?? readCatalog(CODEX_MODELS_CACHE_PATH) ?? catalog;
127
155
  }
128
156
 
129
157
  function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknown } | null {
@@ -136,10 +164,9 @@ function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknow
136
164
  * Returns a deep copy, or null if no catalog/native entry exists.
137
165
  */
138
166
  export function loadCatalogTemplate(): RawEntry | null {
139
- const cat = readCurrentCatalogOrCache();
140
- const native = cat?.models?.find(
141
- m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
142
- );
167
+ const native = findNativeTemplate(readCatalog(readCodexCatalogPath()))
168
+ ?? findNativeTemplate(readCatalog(CATALOG_BACKUP_PATH))
169
+ ?? findNativeTemplate(readCatalog(CODEX_MODELS_CACHE_PATH));
143
170
  return native ? JSON.parse(JSON.stringify(native)) : null;
144
171
  }
145
172
 
@@ -186,7 +213,7 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
186
213
  normalizeRoutedCatalogEntry(e);
187
214
  applyJawcodeCatalogMetadata(e, slug);
188
215
  }
189
- return ensureAutoCompactTokenLimit(normalizeServiceTiers(e));
216
+ return ensureStrictCatalogFields(normalizeServiceTiers(e));
190
217
  }
191
218
  // Fallback when no template is available (best-effort; strict parser may need more).
192
219
  const entry: RawEntry = {
@@ -198,7 +225,7 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
198
225
  ...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
199
226
  };
200
227
  applyJawcodeCatalogMetadata(entry, slug);
201
- return ensureAutoCompactTokenLimit(normalizeServiceTiers(entry));
228
+ return ensureStrictCatalogFields(normalizeServiceTiers(entry));
202
229
  }
203
230
 
204
231
  /**
@@ -351,9 +378,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
351
378
  const catalog = loadCatalogForSync(catalogPath);
352
379
  if (!catalog) return { added: 0, path: catalogPath };
353
380
 
354
- const template = (catalog.models ?? []).find(
355
- m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
356
- ) ?? null;
381
+ const template = findNativeTemplate(catalog);
357
382
 
358
383
  const goModels = await gatherRoutedModels(config);
359
384
  if (goModels.length === 0) return { added: 0, path: catalogPath };
@@ -383,7 +408,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
383
408
  // native template can never leak supports_websockets while the flag is off.
384
409
  const wsEnabled = websocketsEnabled(config);
385
410
  catalog.models = [...native, ...goEntries].map(m => {
386
- const e = ensureAutoCompactTokenLimit(normalizeServiceTiers(m));
411
+ const e = ensureStrictCatalogFields(normalizeServiceTiers(m));
387
412
  if (wsEnabled) e.supports_websockets = true;
388
413
  else delete e.supports_websockets;
389
414
  return e;
@@ -143,12 +143,16 @@ function ensureFastModeFeature(content: string): string {
143
143
  return lines.join("\n");
144
144
  }
145
145
 
146
- function stripDefaultCatalogPath(content: string): string {
146
+ function isOpencodexCatalogPath(path: string): boolean {
147
+ return path.replace(/\\/g, "/").split("/").pop() === "opencodex-catalog.json";
148
+ }
149
+
150
+ function stripOpencodexCatalogPath(content: string): string {
147
151
  return content
148
152
  .split("\n")
149
153
  .filter(line => {
150
154
  const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
151
- return !m || parseTomlString(m[1]) !== DEFAULT_CATALOG_PATH;
155
+ return !m || !isOpencodexCatalogPath(parseTomlString(m[1]));
152
156
  })
153
157
  .join("\n");
154
158
  }
@@ -240,7 +244,7 @@ export function stripOpencodexConfig(content: string): string {
240
244
  // must match the detection regex above, or a detected line could survive un-removed.
241
245
  out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
242
246
  out = stripRootContextWindowOverrides(out);
243
- out = stripDefaultCatalogPath(out);
247
+ out = stripOpencodexCatalogPath(out);
244
248
  return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
245
249
  }
246
250
 
package/src/types.ts CHANGED
@@ -175,7 +175,7 @@ export interface OcxConfig {
175
175
  subagentModels?: string[];
176
176
  /** Routed model ids ("<provider>/<model>") hidden from Codex (excluded from the catalog + /v1/models). */
177
177
  disabledModels?: string[];
178
- /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt into WS. */
178
+ /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
179
179
  websockets?: boolean;
180
180
  /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */
181
181
  modelCacheTtlMs?: number;