Skip to content

[Bug] Routing profile optimize.latency has no latency score term and only becomes the residual priorityWeight #1837

Description

@trungtaottn

Client or integration

Codex CLI (ocx) — routing policy profiles, and the config set / config import commands.

Area

CLI

Summary

Two independent config-shells prompts reported separately, now combined. Both share the theme "an option I set is not honored / silently loses my data," but they are two distinct defects:

  1. Routing optimize.latency does not optimize latency. Setting optimize.latency on a routing profile does not make the router prefer faster providers. On the live policy evaluator (evaluatePolicyProfile), optimize.latency produces no measured-latency score term; with optimize: { latency: 1, health: 0, cost: 0, quota: 0 } selection is a pure argmax over declaration order — reversing the provider list flips the winner to the 40 s-latency provider. Measured latency only ever rides inside the health score (scaled by optimize.health), never optimize.latency. The reference docs call latency a "residual priority split," so this is a design/docs gap rather than a crash: the option name misleads operators, and the residual 1 - health - quota - cost formula is not honored when quota/cost evidence is absent.

  2. ocx config import / set silently deletes hand edits. A hand-edit committed to ~/.opencodex/config.json is destroyed, deterministically, by ocx config import file.json --yes when the import file lacks the edited field — import reads only the file passed (src/cli/config-command.ts:178, loadInput at :67), never the current config, and writes the whole thing. ocx config set/unset additionally have a lost-update race (snapshot read at config-command.ts:133, whole-object write at :145); reproduced 34/51 stale reverts under a concurrent writer.

I'm filing both in one issue as the parent asked. I'm not asserting a confirmed root cause from logic alone; the Evidence section lists what was actually run.

Reproduction

Routing latency (run the real evaluator)

Config: profile "lat" with candidates [a/m1, b/m1], optimize { latency:1, health:0, cost:0, quota:0 }, evidence a/m1 recentLatencyMs 800 (fast), b/m1 40_000 (slow).

Order [fast, slow]  -> wins a (fast):  a total=1.000 comps={"configuredPriority":1}  b total=0.500
Order [slow, fast]  -> wins b (slow):  b total=1.000 comps={"configuredPriority":1}  a total=0.500

Reversing the candidate list flips the winner to the 40 s provider; the only score component is configuredPriority. No latency term ever appears.

Config import clobber (deterministic, no concurrency)

ocx config set emptyCompletionRetry true        # writes ~/.opencodex/config.json
# hand-edit: add  "streamMode":"eager-relay", "customFlag":"x" ; set emptyCompletionRetry=false
ocx config import ./fresh.json --yes            # fresh.json omits those two fields
ocx config get streamMode    # -> error: config path not found: streamMode   (edit destroyed)
ocx config get customFlag    # -> error: config path not found: customFlag
ocx config get emptyCompletionRetry             # -> true   (hand-toggle reverted)

Config set race (with concurrent writer)

ocx config set port 10100 in a loop while a separate process rewrites an unrelated field; live result: 34 of 51 rounds the concurrent counter reverted to a stale lower value.

Version

2.22.0 (main d9de895, @bitkyc08/opencodex)

Operating system

Linux

Provider and model

OpenAI Codex Responses API (routing case); config case is provider-independent.

Logs or error output

No crash/error is raised — both defects are silent (wrong selection; field disappears). The routing case is visible in the evaluator output above; the config case has no explicit error.

Redacted configuration

// routing profile
{ "routingProfiles": { "profiles": [{ "name": "lat", "optimize": { "latency": 1 } }] } }
// config edit before import (the part that is destroyed)
{ "streamMode": "eager-relay", "customFlag": "x", "emptyCompletionRetry": true }

Evidence

  • Routing: live bun test tests/latency-scope-live.test.ts against real evaluatePolicyProfile → 5 pass / 0 fail (fast-vs-slow order flip, no latency component ever emitted). grep -c "latency" src/routing/evaluator.ts → 0; optimize.latency has no consumer outside profile.ts (src/routing/evaluator.ts:398-418). Measured p50 latency folds only into healthScore (src/routing/health.ts:386-389), scaled by optimize.health: (evaluator.ts:408), not optimize.latency.
  • Config: live CLI in throwaway copy (Bun 1.3.14, source v2.22.0 d9de895): hand-edit → config import fresh.json --yes (schema-valid, omitting the fields) → exit 0, edits gone; config set port 10100 under concurrent writer → 34/51 counter reverts. Source: src/cli/config-command.ts:133,145,178; src/config.ts:2766 (saveConfig does no general disk rebase), :3185 (saveConfigPreservingClaudeCode — the rebasing writer, used by server routes, not these CLI commands); src/server/index.ts:562 (baseline armed at server start).
  • import is not a blind overwrite: it validates the file against the schema and aborts on invalid input (a file missing providers wrote nothing). Data loss occurs with a schema-valid-but-partial file, which is the common case.

Expected behavior

  • optimize.latency (if intended as a latency knob) should consult a real latency signal and prefer the lower-p50 provider regardless of order; otherwise the docs/edit schema should make clear it is a residual priority split and the residual should match the documented 1 - health - quota - cost even when evidence is missing.
  • ocx config set/unset should merge onto the current on-disk config before writing (like the server-side rebasing writer), and ocx config import should diff its candidate against the current on-disk config and warn about keys it is about to remove.

Checks

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcliCLI, config inject, packaging flagsproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions