@bitkyc08/opencodex 1.9.4 → 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.
- package/README.ko.md +95 -71
- package/README.md +93 -46
- package/README.zh-CN.md +101 -70
- package/gui/dist/assets/index-DRr-3yL3.css +1 -0
- package/gui/dist/assets/index-LGqpEmI5.js +9 -0
- package/gui/dist/index.html +13 -3
- package/package.json +1 -3
- package/src/adapters/openai-chat.ts +34 -20
- package/src/bridge.ts +13 -5
- package/src/cli.ts +18 -11
- package/src/codex-catalog.ts +147 -31
- package/src/codex-inject.ts +46 -13
- package/src/config.ts +2 -1
- package/src/oauth/index.ts +28 -12
- package/src/oauth/key-providers.ts +27 -0
- package/src/providers/derive.ts +35 -0
- package/src/providers/registry.ts +130 -7
- package/src/reasoning-effort.ts +102 -0
- package/src/responses/parser.ts +1 -1
- package/src/server.ts +19 -2
- package/src/service.ts +26 -2
- package/src/star-prompt.ts +5 -4
- package/src/types.ts +22 -0
- package/src/ws-bridge.ts +5 -2
- package/gui/dist/assets/index-C1wlp1SM.css +0 -1
- package/gui/dist/assets/index-CDhJ0DI7.js +0 -9
- package/scripts/postinstall.mjs +0 -57
package/gui/dist/index.html
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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": "
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
if (parsed.options.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
355
|
-
//
|
|
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("
|
|
370
|
+
response: { ...responseSnapshot("incomplete", finishedItems), usage: responsesUsage(undefined) },
|
|
363
371
|
});
|
|
364
372
|
}
|
|
365
373
|
|
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { restoreNativeCodex } from "./codex-inject";
|
|
4
5
|
import { loadConfig, readPid, removePid, writePid } from "./config";
|
|
5
|
-
import { serviceCommand } from "./service";
|
|
6
|
+
import { serviceCommand, stopServiceIfInstalled } from "./service";
|
|
6
7
|
import { startServer } from "./server";
|
|
7
8
|
import { maybeShowStarPrompt } from "./star-prompt";
|
|
8
9
|
|
|
@@ -35,23 +36,27 @@ Examples:
|
|
|
35
36
|
async function syncModelsToCodex(port?: number) {
|
|
36
37
|
const config = loadConfig();
|
|
37
38
|
const p = port ?? config.port ?? 10100;
|
|
38
|
-
|
|
39
|
-
const result = await injectCodexConfig(p, config);
|
|
39
|
+
let catalogPath: string | null | undefined;
|
|
40
40
|
try {
|
|
41
41
|
const { invalidateCodexModelsCache, syncCatalogModels } = await import("./codex-catalog");
|
|
42
42
|
const cat = await syncCatalogModels(config);
|
|
43
|
+
catalogPath = existsSync(cat.path) ? cat.path : null;
|
|
43
44
|
if (cat.added > 0) {
|
|
44
45
|
invalidateCodexModelsCache();
|
|
45
46
|
console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
|
|
47
|
+
} else if (catalogPath === null) {
|
|
48
|
+
console.error("catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.");
|
|
46
49
|
}
|
|
47
50
|
} catch (e) {
|
|
48
51
|
console.error("catalog sync skipped:", e instanceof Error ? e.message : String(e));
|
|
49
52
|
}
|
|
53
|
+
const { injectCodexConfig } = await import("./codex-inject");
|
|
54
|
+
const result = await injectCodexConfig(p, config, { catalogPath });
|
|
50
55
|
console.log(result.message);
|
|
51
56
|
return result;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
function handleStart() {
|
|
59
|
+
async function handleStart() {
|
|
55
60
|
const existingPid = readPid();
|
|
56
61
|
if (existingPid) {
|
|
57
62
|
console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
|
|
@@ -71,9 +76,6 @@ function handleStart() {
|
|
|
71
76
|
const server = startServer(port);
|
|
72
77
|
writePid(process.pid);
|
|
73
78
|
|
|
74
|
-
void maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
|
|
75
|
-
syncModelsToCodex(port).catch(() => {});
|
|
76
|
-
|
|
77
79
|
const shutdown = () => {
|
|
78
80
|
console.log("\n🛑 Shutting down opencodex proxy...");
|
|
79
81
|
server.stop(true);
|
|
@@ -86,6 +88,9 @@ function handleStart() {
|
|
|
86
88
|
|
|
87
89
|
process.on("SIGINT", shutdown);
|
|
88
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(() => {});
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
function killProxy(pid: number): void {
|
|
@@ -124,6 +129,9 @@ function waitForExit(pid: number, timeoutMs: number): boolean {
|
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
function handleStop() {
|
|
132
|
+
const stoppedService = stopServiceIfInstalled();
|
|
133
|
+
if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
|
|
134
|
+
|
|
127
135
|
const pid = readPid();
|
|
128
136
|
let stopFailed = false;
|
|
129
137
|
if (pid) {
|
|
@@ -135,10 +143,9 @@ function handleStop() {
|
|
|
135
143
|
stopFailed = true;
|
|
136
144
|
console.error(`❌ Failed to stop proxy (PID ${pid}).`);
|
|
137
145
|
}
|
|
138
|
-
} else {
|
|
146
|
+
} else if (!stoppedService) {
|
|
139
147
|
console.log("No running proxy found.");
|
|
140
148
|
}
|
|
141
|
-
// Recover native Codex so plain `codex` keeps working while the proxy is down.
|
|
142
149
|
const r = restoreNativeCodex();
|
|
143
150
|
console.log(`↩️ ${r.message}`);
|
|
144
151
|
if (stopFailed) process.exit(1);
|
|
@@ -160,7 +167,7 @@ switch (command) {
|
|
|
160
167
|
break;
|
|
161
168
|
}
|
|
162
169
|
case "start":
|
|
163
|
-
handleStart();
|
|
170
|
+
await handleStart();
|
|
164
171
|
break;
|
|
165
172
|
case "stop":
|
|
166
173
|
handleStop();
|
|
@@ -197,7 +204,7 @@ switch (command) {
|
|
|
197
204
|
const guiUrl = `http://localhost:${config.port}`;
|
|
198
205
|
if (!cfg.readPid()) {
|
|
199
206
|
console.log("Proxy not running. Starting...");
|
|
200
|
-
handleStart();
|
|
207
|
+
await handleStart();
|
|
201
208
|
await new Promise(r => setTimeout(r, 1000));
|
|
202
209
|
}
|
|
203
210
|
console.log(`Opening ${guiUrl}`);
|
package/src/codex-catalog.ts
CHANGED
|
@@ -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
|
-
*
|
|
175
|
-
*
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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 => ({
|
|
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)
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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;
|