Skip to content

[Bug][2.36.0] Live discovery drops a callable configured model: isDatedVariantId only matches YYYYMMDD and only folds base->dated #3024

Description

@kaicot

Client or integration

Direct HTTP/API client (also visible in the OpenCodex dashboard and ocx models live)

Area

Catalog / models

Summary

mergeConfiguredModelsIntoLiveCatalog() has a dated-alias folding path whose job is to keep a configured model id in the catalog when live discovery returns the same model under a different, date-suffixed id. That fold is implemented by isDatedVariantId(), which is wrong in two independent ways:

  1. It only accepts an 8-digit YYYYMMDD suffix. Providers that use a 4-digit MMDD suffix (Alibaba Token Plan / DeepSeek: deepseek-v4-pro-0813, deepseek-v4-flash-0731) never match.
  2. It only folds configured = base -> live = dated. The reverse case (configured = dated, live = base) is not handled at all.

The result: a model that is on the account's plan, present in providers.<name>.models, and verifiably callable is silently removed from the authoritative live catalog, so it cannot be selected in the dashboard, in ocx models live, or in the Codex model picker. GET /api/providers still reports discovery: { "status": "ok" }, so nothing on the API surface indicates a model was dropped.

I expected the configured id to survive, because upstream does return this model — just under its base id (deepseek-v4-pro) — which is exactly the situation the dated-alias fold exists to handle.

Note this is not the same request as #1690. #1690 asks for a new opt-in retainModels field to retain models that upstream genuinely does not return, and the maintainer review there correctly notes that retaining unconditionally would resurrect dead rows. This report is narrower: the existing fold already requires a matching live row to be present before it retains anything, so fixing it cannot resurrect a model the upstream does not advertise.

Reproduction

  1. Configure alibaba-token-plan-intl (international Token Plan, ap-southeast-1) with liveModels: true and a models list that matches the plan's text/reasoning models, including deepseek-v4-pro-0813.

  2. Confirm the configured list is intact:

    GET /api/providers  ->  models: [ ..., "deepseek-v4-pro", "deepseek-v4-pro-0813", ... ]   (9 ids)
    
  3. Run live discovery:

    ocx models live --provider alibaba-token-plan-intl
    
  4. Observe that deepseek-v4-pro-0813 is gone, while deepseek-v4-pro remains. GET /api/models shows the same 10 rows.

  5. Confirm the dropped model is actually callable, both directly upstream and through the proxy (see logs below). Both return HTTP 200.

Version

2.36.0

Operating system

Windows 11 Pro 26200

Provider and model

alibaba-token-plan-intl / deepseek-v4-pro-0813

Logs or error output

# 1) Configured models via management API -- 9 ids, includes the dated one
GET /api/providers  ->  alibaba-token-plan-intl
  liveModels = true
  models = qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash,
           deepseek-v4-pro, deepseek-v4-pro-0813, glm-5.2,
           deepseek-v4-flash-0731, qwen3.8-flash
  discovery = { "status": "ok" }        # no indication anything was dropped

# 2) Upstream GET /models (public Token Plan intl endpoint) -- 12 ids
#    NOTE: it advertises the BASE id only.
deepseek-v4-flash-0731
deepseek-v4-pro                 <-- base id present
glm-5.2
qwen-audio-3.0-realtime-plus
qwen-audio-3.0-tts-plus
qwen3.6-flash
qwen3.7-max
qwen3.7-plus
qwen3.8-flash
qwen3.8-max
wan2.7-image                    (filtered out by OpenCodex: image-generation)
wan2.7-image-pro                (filtered out by OpenCodex: image-generation)
# deepseek-v4-pro-0813 is NOT advertised upstream

# 3) ocx models live --provider alibaba-token-plan-intl   -- 10 rows, dated id dropped
alibaba-token-plan-intl/deepseek-v4-flash-0731  [routed, disabled]
alibaba-token-plan-intl/deepseek-v4-pro  [routed, disabled]
alibaba-token-plan-intl/glm-5.2  [routed, disabled]
alibaba-token-plan-intl/qwen-audio-3.0-realtime-plus  [routed, disabled]
alibaba-token-plan-intl/qwen-audio-3.0-tts-plus  [routed, disabled]
alibaba-token-plan-intl/qwen3.6-flash  [routed, disabled]
alibaba-token-plan-intl/qwen3.7-max  [routed, disabled]
alibaba-token-plan-intl/qwen3.7-plus  [routed, disabled]
alibaba-token-plan-intl/qwen3.8-flash  [routed, disabled]
alibaba-token-plan-intl/qwen3.8-max  [routed, enabled]
# deepseek-v4-pro-0813 absent

# 4) The dropped model is callable -- DIRECT to upstream
POST <token-plan intl baseUrl>/chat/completions
Authorization: Bearer <redacted>
{"model":"deepseek-v4-pro-0813","messages":[{"role":"user","content":"Reply with exactly: OK"}],"max_tokens":32}
-> HTTP 200   model="deepseek-v4-pro-0813"   content="OK"

# 5) The dropped model is callable -- THROUGH the OpenCodex proxy
POST /v1/chat/completions
{"model":"alibaba-token-plan-intl/deepseek-v4-pro-0813","messages":[{"role":"user","content":"Reply with exactly: OK"}],"max_tokens":32}
-> HTTP 200   model="deepseek-v4-pro-0813"   content="OK"

# So routing by explicit namespace still works; only catalog/picker visibility is lost.

Root cause

src/codex/catalog/provider-fetch.ts:938 (identical on main as of this report):

export function isDatedVariantId(liveId: string, configuredId: string): boolean {
  if (!liveId.startsWith(`${configuredId}-`)) return false;
  return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
}

Used by the fold in mergeConfiguredModelsIntoLiveCatalog() at src/codex/catalog/provider-fetch.ts:1668-1686:

for (const candidate of configured) {
  if (present.has(candidate.id)) continue;
  const dated = out.find(live => isDatedVariantId(live.id, candidate.id));
  if (dated) { /* retain candidate.id, with provider config hints applied */ }
  ...
  droppedConfiguredIds.push(candidate.id);
}

For configured = "deepseek-v4-pro-0813", live = "deepseek-v4-pro":

  • liveId.startsWith("deepseek-v4-pro-0813-") is false -> no match (direction).
  • Even with the arguments swapped, the slice yields "0813", and /^\d{8}$/ rejects 4 digits (format).

Both guards fail, so the id falls through to droppedConfiguredIds. The only remaining retention path is shouldRetainConfiguredProviderModel() at src/codex/catalog/provider-fetch.ts:1631, which is hardcoded to kimi, xai, and opencode-free — so there is no escape hatch for this provider.

alibaba-token-plan-intl is not in QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, so warnDroppedConfiguredIdsOnce() does emit a one-shot console warning, but GET /api/providers still reports discovery.status = "ok", which is what makes this look like "the model does not exist" from the dashboard.

Suggested fix

Make the fold symmetric and accept MMDD, while keeping it strict enough not to swallow non-date numeric suffixes (e.g. a -4096 context-size suffix):

const DATE_SUFFIX = /^(\d{8}|(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01]))$/;

function hasDatedSuffix(longer: string, shorter: string): boolean {
  return longer.startsWith(`${shorter}-`)
    && DATE_SUFFIX.test(longer.slice(shorter.length + 1));
}

export function isDatedVariantId(liveId: string, configuredId: string): boolean {
  return hasDatedSuffix(liveId, configuredId) || hasDatedSuffix(configuredId, liveId);
}

This stays safe with respect to the concern raised in the #1690 review: the fold still only retains a configured id when a corresponding live row is actually present, and the retained row already goes through applyProviderConfigHints(), so per-model context/reasoning hints from config still win.

Happy to open a PR with unit tests covering both directions and both suffix widths if that is useful.

Redacted configuration

{
  "providers": {
    "alibaba-token-plan-intl": {
      "adapter": "openai-chat",
      "baseUrl": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
      "apiKey": "<redacted>",
      "defaultModel": "qwen3.8-max",
      "liveModels": true,
      "models": [
        "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash",
        "deepseek-v4-pro", "deepseek-v4-pro-0813", "glm-5.2",
        "deepseek-v4-flash-0731", "qwen3.8-flash"
      ]
    }
  }
}

The plan console lists deepseek-v4-pro-0813, deepseek-v4-pro, and deepseek-v4-flash-0731 as three separate entitled Text Generation / Reasoning models, which is why all three are in models.

Related: #1690 (feature request for an opt-in retainModels allow-list — complementary, not the same defect), #1671 (earlier user-facing symptom of a configured model being dropped by live discovery).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcatalogModel catalog, slugs, visibility, routed entries

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions