From d47ea7b147392b9fe81c804e2ebed58de81e1ff3 Mon Sep 17 00:00:00 2001 From: skyzea1 <161649495+skyzea1@users.noreply.github.com> Date: Wed, 1 Jul 2026 04:18:41 +0800 Subject: [PATCH] feat(codex): generate fallback profiles for compatible models (#5701) setup-codex now generates Codex profiles for compatible text models from the live /v1/models catalog when the model id doesn't match a hand-tuned pattern, skipping media/embedding models. Integrated into release/v3.8.43. --- bin/cli/commands/setup-codex.mjs | 84 +++++++++++++++++++++++++- docs/guides/CLI-INTEGRATIONS.md | 2 +- docs/guides/CODEX-CLI-CONFIGURATION.md | 2 +- tests/unit/cli/setup-codex.test.ts | 42 +++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 tests/unit/cli/setup-codex.test.ts diff --git a/bin/cli/commands/setup-codex.mjs b/bin/cli/commands/setup-codex.mjs index 76aee2fb61..13cff70223 100644 --- a/bin/cli/commands/setup-codex.mjs +++ b/bin/cli/commands/setup-codex.mjs @@ -80,6 +80,88 @@ export function categoriseModel(modelId) { return null; } +function firstPositiveNumber(...values) { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + } + return null; +} + +function shortHash(value) { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = ((hash << 5) + hash) ^ value.charCodeAt(i); + } + return (hash >>> 0).toString(36); +} + +function profileNameFromModelId(modelId) { + const normalized = String(modelId) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const base = normalized || "model"; + if (base.length <= 96) return base; + return `${base.slice(0, 84).replace(/-+$/g, "")}-${shortHash(base)}`; +} + +function hasAnyValue(values, patterns) { + return values.some((value) => patterns.some((pattern) => pattern.test(value))); +} + +export function isCodexCompatibleTextModel(model) { + if (typeof model === "string") return true; + + const id = String(model?.id ?? "").toLowerCase(); + const type = String(model?.type ?? "").toLowerCase(); + const outputModalities = Array.isArray(model?.output_modalities) + ? model.output_modalities.map((value) => String(value).toLowerCase()) + : []; + + if (type && !["chat", "text", "language", "llm", "model"].includes(type)) { + return false; + } + + const unsupportedPatterns = [ + /(^|[/_-])(image|img|video|veo|seedance|audio|speech|voice|tts|stt|whisper)([/_-]|$)/, + /(^|[/_-])(embedding|embeddings|embed|rerank|moderation|transcription)([/_-]|$)/, + ]; + if (hasAnyValue([id, type], unsupportedPatterns)) return false; + + const nonTextModalities = [/^(image|video|audio)$/]; + if (hasAnyValue(outputModalities, nonTextModalities)) return false; + + if (outputModalities.length > 0 && !outputModalities.includes("text")) { + return false; + } + + return true; +} + +export function fallbackCodexProfile(modelId, model) { + if (!isCodexCompatibleTextModel(model)) return null; + + const ctx = + typeof model === "string" + ? 128000 + : firstPositiveNumber(model.context_length, model.max_context_window_tokens, model.max_input_tokens) ?? 128000; + const maxOutput = + typeof model === "string" + ? null + : firstPositiveNumber(model.max_output_tokens, model.output_token_limit); + const toolLimit = Math.min(Math.max(maxOutput ?? 16384, 8192), 32768); + + return { + name: profileNameFromModelId(modelId), + ctx, + compact: Math.floor(ctx * 0.85), + summary: false, + toolLimit, + }; +} + /** Build the TOML content for a single profile. */ function buildProfileToml(modelId, cfg) { const lines = [ @@ -160,7 +242,7 @@ export async function runSetupCodexCommand(opts = {}) { if (!id) continue; if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue; - const cfg = categoriseModel(id); + const cfg = categoriseModel(id) ?? fallbackCodexProfile(id, m); if (!cfg) continue; const filePath = join(codexHome, `${cfg.name}.config.toml`); diff --git a/docs/guides/CLI-INTEGRATIONS.md b/docs/guides/CLI-INTEGRATIONS.md index d9b04f301f..ca4d90a363 100644 --- a/docs/guides/CLI-INTEGRATIONS.md +++ b/docs/guides/CLI-INTEGRATIONS.md @@ -37,7 +37,7 @@ server and writes the config locally. | Command | Tool | What it writes | Key flags | Local vs remote | |---------|------|----------------|-----------|-----------------| -| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/.config.toml` — one profile per matched model (`codex --profile `) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both | +| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/.config.toml` — one profile per compatible text model (`codex --profile `) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both | | `omniroute setup-claude` | Claude Code | `~/.claude/profiles//settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both | | `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json` — `omniroute` provider with every catalog model (`opencode -m omniroute/`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both | | `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both | diff --git a/docs/guides/CODEX-CLI-CONFIGURATION.md b/docs/guides/CODEX-CLI-CONFIGURATION.md index 873fba3dec..704b304c16 100644 --- a/docs/guides/CODEX-CLI-CONFIGURATION.md +++ b/docs/guides/CODEX-CLI-CONFIGURATION.md @@ -241,7 +241,7 @@ omniroute setup-codex --only glm,kimi omniroute setup-codex --codex-home /path/to/.codex ``` -The command fetches `/v1/models`, categorises each model (thinking / good / simple / fast) and writes `~/.codex/.config.toml` for each. Idempotent — safe to re-run. +The command fetches `/v1/models`, uses tuned profiles for known models, falls back to catalog metadata for other compatible text models, and writes `~/.codex/.config.toml` for each. Idempotent — safe to re-run. --- diff --git a/tests/unit/cli/setup-codex.test.ts b/tests/unit/cli/setup-codex.test.ts new file mode 100644 index 0000000000..a1e64d0964 --- /dev/null +++ b/tests/unit/cli/setup-codex.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + fallbackCodexProfile, + isCodexCompatibleTextModel, +} from "../../../bin/cli/commands/setup-codex.mjs"; + +test("fallbackCodexProfile creates profiles for compatible live catalog models", () => { + const cfg = fallbackCodexProfile("new-provider/future-chat-1", { + id: "new-provider/future-chat-1", + context_length: 250000, + max_output_tokens: 65536, + output_modalities: ["text"], + }); + + assert.deepEqual(cfg, { + name: "new-provider-future-chat-1", + ctx: 250000, + compact: 212500, + summary: false, + toolLimit: 32768, + }); +}); + +test("fallbackCodexProfile skips media and non-text models", () => { + assert.equal( + isCodexCompatibleTextModel({ + id: "antigravity/gemini-3.1-flash-image", + type: "image", + output_modalities: ["image"], + }), + false + ); + assert.equal( + fallbackCodexProfile("veo-free/seedance", { + id: "veo-free/seedance", + name: "Seedance", + context_length: 128000, + }), + null + ); +});