Skip to content

[Bug][2.36.0] /api/models drops entitled GPT-5.6 rows after the 5-minute entitlement cache expires #3023

Closed

Description

@kaicot

Client or integration

OpenCodex dashboard

Area

Catalog / models

Summary

On OpenCodex 2.36.0, the dashboard/management model list loses the entitled native OpenAI GPT-5.6 rows when the five-minute account-model roster cache expires.

Immediately after a successful entitlement discovery, GET /api/models contains seven native openai rows:

  • gpt-5.5
  • gpt-5.4
  • gpt-5.4-mini
  • gpt-5.3-codex-spark
  • gpt-5.6-sol
  • gpt-5.6-terra
  • gpt-5.6-luna

After five minutes, with no account or configuration change, the same endpoint returns only the first four. Calling GET /v1/models immediately restores all seven rows in /api/models for another five minutes.

The account is entitled to the three GPT-5.6 models: /v1/models returns them, the generated Codex catalog contains them with visibility: "list", and they remain usable. This appears to be a management/catalog projection bug rather than a loss of model access.

Expected: management readers should trigger or receive a current entitlement discovery before projecting account-gated native rows. The dashboard and exports should not silently change from 7 to 4 solely because the in-memory evidence cache expired.

Reproduction

  1. Start OpenCodex 2.36.0 with an authenticated ChatGPT/Codex account entitled to gpt-5.6-sol, gpt-5.6-terra, and gpt-5.6-luna.

  2. Set ADMIN_TOKEN to a valid local management API token. Do not print it.

  3. Warm entitlement discovery once:

    curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
      http://127.0.0.1:10100/v1/models > /dev/null
  4. Count the native OpenAI rows returned by the management endpoint every 30 seconds:

    curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
      http://127.0.0.1:10100/api/models \
      | grep -o '"provider":"openai"' | wc -l
  5. Observed timeline:

    02:48:32  openai_rows=7
    02:49:03  openai_rows=7
    02:49:33  openai_rows=7
    02:50:03  openai_rows=7
    02:50:33  openai_rows=4  # roster cache expired
    
    02:50:50  GET /v1/models
    02:51:04  openai_rows=7  # restored immediately
    02:51:34  openai_rows=7
    ...
    02:55:38  openai_rows=7
    
  6. A separate cold/warm check produced the same result:

    BEFORE /v1/models
    count=4
    gpt-5.3-codex-spark,gpt-5.4,gpt-5.4-mini,gpt-5.5
    
    GET /v1/models returned
    gpt-5.6-luna,gpt-5.6-sol,gpt-5.6-terra
    
    AFTER /v1/models
    count=7
    gpt-5.3-codex-spark,gpt-5.4,gpt-5.4-mini,gpt-5.5,gpt-5.6-luna,gpt-5.6-sol,gpt-5.6-terra
    

Suspected root cause

  • src/codex/catalog/native-models.ts: Sol/Terra/Luna are members of ACCOUNT_GATED_NATIVE_OPENAI_MODELS.
  • src/codex/catalog/metadata.ts: nativeModelRows() filters gated slugs through cachedAvailableAccountGatedNativeModels().
  • src/codex/model-entitlements.ts: the roster evidence is stored in the process-local accountModelsCache; MODEL_ROSTER_TTL_MS = 5 * 60_000. The cache projection requires entry.expiresAt > now.
  • src/server/management/model-routes.ts: GET /api/models calls only listManagementModelRows(config).
  • src/server/management/model-rows.ts: listManagementModelRows() calls fetchAllModels(config) and then nativeModelRows(config), but it does not call resolveCodexModelEntitlements(config) first.
  • In contrast, src/server/index.ts runs resolveCodexModelEntitlements(config, ...) on the GET /v1/models path. The resolver reuses a live cache entry and performs a new upstream roster fetch on a cache miss/expiry.

This also affects /api/client-config because loadExportModels() delegates to listManagementModelRows(). The live-service path used by ocx export reads /api/models, so an export performed while the cache is cold can silently omit the three GPT-5.6 entries.

A narrow fix may be to refresh entitlement evidence in the shared listManagementModelRows() path (or pass an explicit entitlement snapshot into the native-row projection) so /api/models, /api/client-config, and CLI exports stay aligned. The existing five-minute resolver cache would still prevent a remote request on every dashboard refresh.

Relationship to existing issues

Version

2.36.0

Operating system

Microsoft Windows 11 Pro, version 10.0.26200, build 26200, x64

Provider and model

Canonical openai provider using ChatGPT/Codex account authentication:

  • gpt-5.6-sol
  • gpt-5.6-terra
  • gpt-5.6-luna

Logs or error output

GET /api/models while entitlement cache is cold: openai_rows=4
GET /v1/models: GPT-5.6 Sol/Terra/Luna returned
GET /api/models immediately afterwards: openai_rows=7

Screenshots and supporting files

The complete redacted timeline and source trace are included above. No local patch has been installed.

Redacted configuration

{
  "providers": {
    "openai": {
      "adapter": "openai-responses",
      "authMode": "forward"
    }
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. added
    bugSomething isn't working
    catalogModel catalog, slugs, visibility, routed entries
    proxyHTTP proxy, routing, reverse-proxy / management auth
    on Aug 30, 2026
  2. lidge-jun commented on Aug 30, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 71 / 80

    이 이슈가 말하는 구멍은 지금 dev HEAD 870a2adb6 에도 그대로 있다. 패키지 버전은 2.37.0 이다. 작성자가 본 설치본은 v2.36.0 이고, 그 사이 올라간 것은 버전 줄과 릴리스 워크플로(#3013)다. 계정 게이트 네이티브 목록의 5분 캐시 만료 뒤 관리 API가 자격 증명을 다시 안 읽는 구멍은 수리되지 않았다.

    증상은 단순하다. ChatGPT/Codex 계정에 gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna 자격이 있어도, 한 번 성공한 뒤 5분이 지나면 GET /api/models 의 네이티브 openai 줄이 7개에서 4개로 줄어든다. 같은 시각에 GET /v1/models 를 한 번 부르면 다시 7개가 된다. 모델 자체는 살아 있다. 대시보드와 export 쪽 투영만 캐시가 식으면 게이트된 세 줄을 숨긴다.

    코드 길은 이렇게 갈라진다. src/codex/catalog/native-models.ts 에서 Sol/Terra/Luna 는 ACCOUNT_GATED_NATIVE_OPENAI_MODELS 멤버다. 관리 목록의 nativeModelRows() 는 cachedAvailableAccountGatedNativeModels() 가 그 슬러그를 줄 때만 행을 남긴다. 그 함수는 프로세스 안 accountModelsCache 를 읽기만 한다. expiresAt > now 이고 confirmed 인 칸만 센다. 캐시가 비었거나 만료되면 빈 집합을 돌려주고, 업스트림을 다시 치지 않는다. MODEL_ROSTER_TTL_MS 는 5 * 60_000 이다. 작성자 타임라인의 5분 경계와 맞다.

    반대로 데이터면 GET /v1/models 는 resolveCodexModelEntitlements(config, { clientVersion }) 를 fetchAllModels 와 같이 부른다. 캐시가 살아 있으면 재사용하고, 없거나 만료되면 계정 로스터를 다시 가져온다. 그래서 /v1/models 한 번이 /api/models 를 다시 채운다. 관리 쪽 listManagementModelRows() 는 fetchAllModels 와 nativeModelRows 만 부르고, 그 리졸버를 먼저 부르지 않는다. GET /api/models 는 그 함수만 감싼다. loadExportModels() 도 같은 함수를 쓰므로 /api/client-config 와 ocx export 가 찬 캐시에서 GPT-5.6 세 줄을 빠뜨릴 수 있다.

    이건 #2886 / #2891 의 남은 구멍이다. 그 수리는 client_version 플레이스홀더(0.0.0) 때문에 자격 있는 계정이 빈 로스터를 받던 거짓 음수였다. 이 이슈는 이미 성공한 디스커버리 뒤, 관리 리더만 만료된 증거를 다시 안 채우는 쪽이다. 닫힌 형제 #3022 는 Plus 2.36.0 업그레이드 회귀 보고이고 OS 누락으로 quality-bot 이 닫았다. 증상도 다르다. 중복으로 묶지 마라. 열린 PR 중에서 이 관리면 갱신을 겨냥한 수리는 없다. types.ts/config.ts 분할과도 안 겹친다. 미리보기 배포는 계획에 없다.

    라인 src/server/management/model-rows.ts 50 - listManagementModelRows 가 fetchAllModels 와 nativeModelRows 만 부른다. resolveCodexModelEntitlements 를 먼저 부르지 않는다.
    라인 src/codex/catalog/metadata.ts 424 - nativeModelRows 가 cachedAvailableAccountGatedNativeModels(...) 동기 읽기만 한다. 캐시가 식으면 게이트된 Sol/Terra/Luna 가 필터로 빠진다.
    라인 src/codex/model-entitlements.ts 617 - cachedAvailableAccountGatedNativeModels 가 entry.expiresAt > now 를 요구한다. 만료된 확인 칸은 자격이 있어도 없는 것과 같다.
    라인 src/codex/model-entitlements.ts 137 - MODEL_ROSTER_TTL_MS = 5 * 60_000. 작성자가 잰 5분 소실과 같은 시계다.
    라인 src/server/management/model-routes.ts 352 - GET /api/models 가 listManagementModelRows(config) 만 호출한다. 자격 갱신 입구가 없다.
    라인 src/server/index.ts 1163 - GET /v1/models 는 resolveCodexModelEntitlements 를 같이 부른다. 캐시 미스에서 업스트림을 다시 친다. 관리면과 데이터면이 여기서 갈라진다.
    라인 src/server/management/model-rows.ts 164 - loadExportModels 가 같은 listManagementModelRows 를 탄다. 찬 캐시 export 가 GPT-5.6 을 조용히 뺄 수 있다.
    경로 src/codex/catalog/native-models.ts ACCOUNT_GATED_NATIVE_OPENAI_MODELS - gpt-5.6-sol / gpt-5.6-terra / gpt-5.6-luna 멤버십이 맞다. 게이트 집합 자체는 버그가 아니다.

    메인테이너의 판단이 필요한 지점

    너의 추천
    열어 둔다. 라벨을 바꾸지 마라. 닫지 마라. 중복(#3022)으로 닫지 마라. 고치는 자리는 listManagementModelRows 다. 그 함수 앞에서 await resolveCodexModelEntitlements(config) 를 한 번 부르면 /api/models, /api/client-config, export 가 같이 맞춰진다. 핫 캐시에서는 원격 호출이 나가지 않는다. fail-closed 게이트는 유지하라. #2548 을 풀지 마라. types/config 분할에 깔리지 않는 좁은 PR 로 dev 에 올려라.

    이 댓글은 grok-bot이 작성했습니다

  3. kaicot commented on Aug 30, 2026

    @kaicot
    ContributorAuthor

    The review lists /api/client-config and ocx export as surfaces that may silently omit the gated rows. I measured it, so that can be upgraded from "may" to confirmed.

    Same install, OpenCodex 2.36.0. The two captures below are 3 seconds apart, with a single GET /v1/models in between and nothing else changed:

    Surface Cold entitlement cache After one GET /v1/models
    GET /api/models (native openai rows) 4 7
    GET /api/client-config?client=opencode 3268 bytes, 0 gpt-5.6-* refs 4112 bytes, 12 refs
    ocx export --client opencode --json 1791 bytes, 0 gpt-5.6-* refs 2328 bytes, 6 refs
    [04:09:25] PHASE 1 - COLD entitlement cache
      /api/models native openai rows = 4
        gpt-5.3-codex-spark, gpt-5.4, gpt-5.4-mini, gpt-5.5
      /api/client-config?client=opencode   bytes=3268  gpt-5.6 refs=0
      ocx export --client opencode --json  bytes=1791  gpt-5.6 refs=0
    
    [04:09:27] warming: GET /v1/models
    
    [04:09:28] PHASE 2 - WARM entitlement cache
      /api/models native openai rows = 7
        gpt-5.3-codex-spark, gpt-5.4, gpt-5.4-mini, gpt-5.5,
        gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra
      /api/client-config?client=opencode   bytes=4112  gpt-5.6 refs=12
      ocx export --client opencode --json  bytes=2328  gpt-5.6 refs=6
    

    So ocx export emits a 537-byte-smaller client config, silently missing all three entitled GPT-5.6 models, purely as a function of whether the 5-minute cache happens to be warm. There is no error, no warning on the export path, and GET /api/providers still reports discovery: { "status": "ok" }. A user who runs ocx export during a cold window and merges the result gets a config that quietly lacks Sol/Terra/Luna.

    I think this is the more damaging half of the bug: a stale dashboard corrects itself on the next refresh, but an exported config is copied out of the process and persists.

    The TTL clock also lines up exactly. In this run the cache was warmed at 04:04:12 and /api/models dropped back to 4 rows at 04:09:12 — 5:00.000 later, matching MODEL_ROSTER_TTL_MS = 5 * 60_000.

    This also supports the recommendation to fix at listManagementModelRows rather than at the route: all three surfaces above move together in both directions, which is what you would expect if they share that one entry point, and it means a single await resolveCodexModelEntitlements(config) there fixes the dashboard, /api/client-config, and ocx export in one change.

    Reproduction, if useful — no config or service changes required:

    # 1. wait out the TTL (or just poll until the row count drops)
    #    GET /api/models  -> native openai rows == 4
    ocx export --client opencode --json | grep -c 'gpt-5\.6'   # -> 0
    
    # 2. warm the entitlement cache
    curl -s localhost:<port>/v1/models -H 'Authorization: Bearer <admin token>' > /dev/null
    
    # 3. same command, cache now warm
    ocx export --client opencode --json | grep -c 'gpt-5\.6'   # -> 6
  4. lidge-jun commented on Aug 31, 2026

    @lidge-jun
    Owner

    Confirmed, and thank you for measuring the two surfaces the review left as "may". Upgrading them in the record:

    • GET /api/client-config?client=opencode — confirmed to omit the entitled rows on a cold cache
    • ocx export --client opencode --json — confirmed, and this is the damaging one

    Your framing of why is the part I want to keep: a stale dashboard corrects itself on the next refresh, but an exported config leaves the process and persists. A user who exports during a cold window merges a file that silently lacks Sol/Terra/Luna, with no error and discovery: { "status": "ok" } still reported. That moves this from a display bug to a config-corruption bug, and it is why the priority stands at 71/80.

    Your TTL arithmetic also matches the source: MODEL_ROSTER_TTL_MS = 5 * 60_000 at src/codex/model-entitlements.ts:137, and your 04:04:12 → 04:09:12 drop is exactly that interval.

    On the fix location — agreed, and your evidence is what settles it. All three surfaces moving together in both directions is the signature of a shared entry point, so the repair belongs at listManagementModelRows rather than at each route. Fixing it at the route would leave ocx export broken, which is the surface that actually persists the damage.

    One thing I want to get right before writing it: the cache exists so the management API does not make a network call on every dashboard poll, so the fix cannot be an unconditional await resolveCodexModelEntitlements(config) on a hot path shared by polling. The shape I am aiming for is that an expired roster is refreshed rather than treated as empty, and that a refresh failure is surfaced instead of silently yielding a short list — discovery: { "status": "ok" } alongside missing rows is arguably a separate defect in the same area, and I will treat it as part of this one.

    No action needed from you. Your reproduction is precise enough to work from directly, and the cold/warm 3-second capture is what I will use as the regression shape.

  5. lidge-jun commented on Aug 31, 2026

    @lidge-jun
    Owner

    Fixed on dev in 0844dc9 (PR #3054).

    What was wrong. /api/models, /api/client-config and ocx export share listManagementModelRows, and the entitlement snapshot behind it carried an expiry but no binding to the credentials that produced it. Two failure modes followed from that single gap:

    • Once MODEL_ROSTER_TTL_MS elapsed, the surfaces kept answering from the expired snapshot instead of re-deriving. That is the row loss you reported.
    • When auth.json was replaced out of band, the stale snapshot stayed authoritative. A newly entitled account saw the old roster; a logged-out one still saw models it no longer had.

    What landed. A new src/codex/credential-mutation-epoch.ts holds a monotonic counter bumped at every credential mutation point. A snapshot records the epoch it was derived under, and a read is a hit only when both the epoch and the expiry still match. Otherwise it re-derives. The logged-out result is memoized deliberately, because the dashboard polls this path around 24 times a minute.

    Cost contract, since this sits on a polling path. Steady state with nothing mutated and the snapshot live: 0 additional accountCredentialSnapshot calls, 0 token refreshes, 0 network requests.

    Verification. Nine regressions written red-first, including three added in review specifically because the first versions of the race tests were vacuous — they returned usable credentials, so they never reached the negative-memo publication fence, and deleting that fence left them green. The corrected versions fail when the epoch fence, the identity fence, or the absence-observation-time rule is removed. Rejection-path tests are labeled characterization rather than claimed as red.

    Full suite on a Linux host at the merged head: 16696 pass / 0 fail / 16 skip, privacy scan and typecheck clean.

    Residual. The epoch is process-local, so two proxies sharing one OPENCODEX_HOME still observe each other only through the expiry. Not reachable in the supported single-proxy topology.

    Two follow-ups are already implemented and queued behind this: a tri-state entitlement authority so a roster fetched below a model's known minimum yields unknown rather than a decided denial, and an honest entitlement status field on /api/providers so discovery: ok beside missing rows stops being the only signal available. The second one is the part of this report I owed you an answer for.

    Closing now that it is on dev; GitHub only auto-closes on merges into main.

  6. added a commit that references this issue on Aug 31, 2026
    b1f55b8
  7. added a commit that references this issue on Aug 31, 2026
    b4303bb
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 entriesproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions