Skip to content

fix(cli): honour --api-key and stop the empty model catalog in setup-* commands - #18581

Closed
magnum8237 wants to merge 2 commits into
cheaper-inference:release/v3.8.53from
magnum8237:fix/cli-setup-api-key-globals
Closed

magnum8237 wants to merge 2 commits into
cheaper-inference:release/v3.8.53from
magnum8237:fix/cli-setup-api-key-globals

Conversation

@magnum8237

@magnum8237 magnum8237 commented Oct 30, 2026 •

Copy link
Copy Markdown

⚠️ base-red inherited: #17219

The base tip release/v3.8.53 is not green — 8 hard failures reported by
Release-Green (continuous), including all 8 unit shards, docs-sync-strict
(stale v3.8.52 strings in README.md vs package.json 3.8.53), and a
package-artifact timeout. None of it is related to this PR. Compare any CI
signature against the exact base before attributing a defect here.

Two independent bugs in the CLI setup-* commands, one commit each.

Bug 1 — --api-key is silently discarded (11 files)

Each setup-* command declares its own --api-key, whose long name collides with the
program-level option in bin/cli/program.mjs:

.addOption(new Option("--api-key <key>", t("program.api_key")).env("OMNIROUTE_API_KEY"))

With this project's Commander 15.0.0, the value binds to the program, so opts.apiKey
inside the subcommand action is undefined in every arrangement — including
omniroute setup-claude --api-key X, with the flag after the subcommand name. Verified
directly against the installed Commander:

argv opts.apiKey cmd.optsWithGlobals().apiKey
--api-key after subcommand undefined CLI
--api-key before subcommand undefined CLI
no flag, OMNIROUTE_API_KEY set undefined FROM-ENV
nothing undefined undefined

The commands read opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "",
so they only worked by accident when OMNIROUTE_API_KEY happened to be exported.
Without it they wrote a profile carrying no credential, with no warning.

Precedent: setup-opencode already carries this exact fix via #13246
("setup-opencode respects --api-key/OMNIROUTE_API_KEY over context token (#12783)").
This change applies the same pattern to the remaining 11 commands: cursor, aider, goose,
crush, qwen, cline, continue, roo, kilo, claude, codex.

Two deliberate deviations from a naive fix

1. setup-codex is included. It was initially scoped out on the belief that it
declares no --api-key of its own. It does — setup-codex.mjs:433 — and uses
.action(async (opts)) → runSetupCodexCommand(opts) with the same opts.apiKey read at
line 338. The collision and the failure mode are identical, so leaving it out would have
shipped a known-broken command.

2. Only apiKey is overridden, not the whole opts bag. Following the #13246 pattern:

const code = await runSetupXCommand({ ...opts, apiKey: cmd.optsWithGlobals().apiKey ?? opts.apiKey });

Spreading all of optsWithGlobals() instead would also start populating opts.context,
which 10 of these 11 commands already read (resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT))
while declaring no local --context. That would silently change context resolution across
the whole family — well beyond this bug. --api-key is the only option name that collides
with a program-level global in any of the 11 files (checked against all of --api-key,
--base-url, --context, --lang, --no-color, --output, --timeout).

Bug 2 — empty model catalog in setup-continue / setup-crush (2 files)

categoriseModel() is a hardcoded regex table over legacy prefixes (kmc/, glm/,
opencode-go/) and returns null for everything else. Both commands did:

const cfg = categoriseModel(id);
if (!cfg) continue;

so a catalog dominated by newer prefixes (aug/, dva/, tllm/, oc/ …) produced zero
models and printed "No matching curated models" with hundreds of models available.

The fix reuses the fallback the sibling commands already have — setup-codex.mjs:297
(?? fallbackCodexProfile) and setup-claude.mjs:212 (?? fallbackClaudeProfile).
continue and crush were the two stragglers.

fallbackCodexProfile is called as fallbackCodexProfile(id, { id }), not with the bare
id string, for two reasons:

  • ctx must be valid. crush writes cfg.ctx straight into context_window; the
    object form resolves it to a usable 128000 rather than undefined. (continue only
    reads cfg.effort, which the fallback legitimately omits — it just means no
    autocomplete role.)
  • The non-text filter stays active. The object form keeps isCodexCompatibleTextModel
    matching its modality patterns against the id, so image/audio/embedding/rerank
    ids stay out of a chat-tool config. Passing the bare string would short-circuit that
    gate (if (typeof model === "string") return true) and let image models into the config.
    Note that fallbackCodexProfile(id, undefined) would throw on model.context_length.

Two pre-existing assertions encoded the old skip-everything behavior
("buildContinueModels skips uncategorised models", and crush's models.length === 1 // unknown skipped) and are updated to the fallback contract, keeping their curated-value
and non-text coverage.

Validation (Hard Rule #18 — TDD)

TDD was possible, so no VPS route was used. Both regression tests failed before the fix and
pass after.

  • tests/unit/cli/setup-cli-api-key-globals.test.ts — drives the real
    registerSetupClaude action against a stub catalog server and asserts the key arrives as
    an Authorization: Bearer header, with OMNIROUTE_API_KEY deleted from the env. Covers
    the flag after and before the subcommand name, plus the no-key case.
    Red before: actual: undefined, expected: 'Bearer sk-cli-only'.
  • tests/unit/cli/setup-cli-catalog-fallback.test.ts — unknown-prefix ids survive with a
    positive context_window, non-text ids are still dropped, curated values preserved.
    Red before: actual: 0, expected: 4.

Verified locally:

Check Result
New regression tests green (7)
tests/unit/cli/setup-{continue,crush,codex,claude,opencode} + local-gate green (44)
npm run typecheck:core pass (exit 0)
eslint on changed files 0 errors
pre-commit gates (check-docs-sync, any-budget:t11, tracked-artifacts, gate-manifest, git-identity) pass
Base-green (release/v3.8.53) RED — inherited, see #17219 (8 hard failures on the base tip)

7 unrelated tests in tests/unit/cli/ fail identically on the pristine base tip
(origin/release/v3.8.53) and on this branch — POSIX mode-bit assertions
(actual: 438 = 0o666 vs expected: 384 = 0o600) that cannot hold on Windows. Confirmed
pre-existing by re-running the directory against unmodified base copies of every file this
branch touches; this change introduces no new failures.

The program-level `--api-key` option (bin/cli/program.mjs, bound to
OMNIROUTE_API_KEY) shares its long name with the one each `setup-*` command
declares. Commander 15 binds the value to the program, so `opts.apiKey` inside
the subcommand action is undefined in EVERY arrangement — including
`omniroute setup-claude --api-key X`, with the flag after the subcommand name.

The commands read `opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY`,
so they only worked by accident when OMNIROUTE_API_KEY happened to be exported.
Without it they silently wrote a profile carrying no credential.

Resolve the key via `cmd.optsWithGlobals().apiKey` while leaving the rest of the
local opts untouched — the same narrow fix setup-opencode already carries (cheaper-inference#13246).
Spreading the whole `optsWithGlobals()` bag instead would also start populating
`opts.context` (read by 10 of these commands, none of which declare a local
`--context`), changing context resolution well beyond this bug.

Covers the 11 commands that declare their own `--api-key`: cursor, aider, goose,
crush, qwen, cline, continue, roo, kilo, claude, codex.
`categoriseModel()` is a hardcoded regex table over legacy prefixes (kmc/, glm/,
opencode-go/) and returns null for everything else. Both commands did
`const cfg = categoriseModel(id); if (!cfg) continue;`, so a catalog dominated by
newer prefixes (aug/, dva/, tllm/, oc/ ...) produced ZERO models and the commands
printed "No matching curated models" with hundreds of models available.

Fall back to the derived profile the sibling commands already use — setup-codex
(`?? fallbackCodexProfile`) and setup-claude (`?? fallbackClaudeProfile`) have
carried this guard for a while; continue and crush were the two stragglers.

`fallbackCodexProfile` is called with `{ id }` rather than the bare id string:
the object form keeps its non-text modality filter active (image/audio/embedding
ids stay out of a chat-tool config), and `ctx` still resolves to a usable
128000 instead of undefined, which matters because crush writes `cfg.ctx`
straight into `context_window`.

Two existing assertions encoded the old skip-everything behavior and are updated
to the fallback contract, keeping their curated-value and non-text coverage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant