Repository navigation
fix(catalog): read a model list that carries both data and models - #13
Conversation
A relay that returns the standard OpenAI `data` roster and also echoes a `models` array failed the whole page parse: `models` was declared as a serde alias of `data`, so a body carrying both keys was rejected as a duplicate field, and the probe reported InvalidResponse while the listing was sitting right there in the response. Enumerate `data` and `models` as separate optional fields and resolve the rows from `data` first: it is the roster an OpenAI-compatible host actually serves. `models` stays as the fallback for hosts that only use the wider spelling.
|
PASS Reviewed 1. Correctness —
|
|
FAIL The envelope fix itself is correct and does what the description says (I verified the mechanism, not a run — see "Not verified"). The failure is a regression the same 16 lines introduce elsewhere in the same file. 1. Falsification of "prefer
|
| input | new behaviour |
|---|---|
"data": null |
None → models (regression above; was InvalidResponse) |
"data": [] |
Some([]) → Some wins → BoundedModelsRows → empty rows → body returned unchanged → EmptyList downstream. Correct; "present key wins" preserved. |
data not an array |
Some(raw) → deserialize_seq fails → InvalidResponse (crates/tui/src/client.rs:1116). Correct, and the RawValue borrow still defers the shape check. |
| both keys missing | None → None → Err(InvalidResponse) (:1032-1036). Correct. |
3. Same-class trap in this file: none
grep -n '#\[serde(' crates/tui/src/client.rs returns one attribute per field with no alias anywhere except the unrelated doc-comment hits at :381 and :1442; grep -n 'alias' crates/tui/src/client.rs finds no remaining #[serde(alias = …)]. Structurally the only other "one key matched twice" candidates are ModelsListResponse (:4442), OpenRouterModelsResponse (:4452) and BasetenModelsResponse (:4538), each with a single data field and no alias. No finding.
Not verified
- I did not build or run anything: no
cargoinvocation, no test run. Every claim above is read from source at the cited lines, not from a passing test. - I did not check whether a real provider in the wild sends
"data": nullnext tomodels; the finding is that the code no longer distinguishes the two, which is a property of the diff, not of any provider. - I read only
crates/tui/src/client.rsand the cited lines inprovider_catalog_live.rs/config/src/catalog.rs/model_picker.rs.
Attribution
🤖 Generated by SpikeBot 001(CodeWhale-HK)
Correction: the
|
Nothing in catalog_tests.rs fed a roster body carrying both keys, so the duplicate-field reject that 0041005 removes could return unnoticed. Both tests were run against the unfixed envelope first: they fail there with `InvalidResponse` — the same receipt the reported relay produced — and pass with the fix. The second also pins that a present-but-empty `data` stays an empty listing rather than falling back, while a host serving only `models` still resolves through it. That fallback is load-bearing: the ChatGPT roster uses `models` as its only key.
The picker lists every configured route, but only the active route was ever probed -- and the probe predicate could not see the endpoint at all. A builtin name pointed at a private relay (`[providers.openai]` against an Aliyun MaaS endpoint) read as official OpenAI, so it was never probed, and its picker row could only ever show what a previous explicit `models --update` had left behind. - `provider_owns_live_catalog` takes the route's `base_url` and reuses `provider_preserves_custom_base_url_model`, so a vendor-named table on a private endpoint owns its roster the same way a `[providers.<name>]` table does. An official endpoint keeps the snapshot row it always had, which the predicate test pins on both sides. - `spawn_configured_provider_catalog_refresh` sweeps the remaining configured routes at startup. Each goes through the same `update_provider_catalog` the explicit `models --update` command uses, so the TTL, the typed receipt, the credential-change guard and the superseded-refresh ticket are shared rather than reimplemented. A route whose roster is already fresh is skipped: this fills the relays a session has never listed, it does not re-fetch on every launch.
`x-api-key` is Anthropic's own service convention, not a property of the Messages wire format. A custom table that declares `wire = "anthropic"` is still reached through the OpenAI-compatible surface, and a relay can accept `/v1/messages` on either header while rejecting `x-api-key` on `/v1/models` with `401 missing_api_key`. Its listing therefore reported `unauthorized` while the same endpoint answered `Authorization: Bearer` normally. `build_default_headers` now takes the surface it is building for. Inference keeps the wire-derived choice, so the Messages `x-api-key` default stays for Anthropic and the dual-wire vendors; Listing is the provider's own `/models` endpoint, where a custom host uses Bearer. The models client and the guided-setup key probe (`verify_provider_api_key`) build a Listing surface; the chat/Messages/Responses client, its HTTP/1.1 fallback twin, the TypeSafe decision client and the test header helpers build Inference. Measured against the reported relay: `GET /v1/models` answers 200 with `Authorization: Bearer` and 401 `missing_api_key` with `x-api-key`, while `POST /v1/messages` accepts either.
The provider panel derived the row's catalog status from the provider-level snapshot count alone. A custom provider ships no bundled seeds, so that count is always zero for one, and every custom relay rendered "catalog snapshot missing; using provider default" directly above the live listing it had just fetched -- the panel contradicted itself on screen. The provider-level snapshot and the exact route's own roster are two different sources, and the row already computes both. The status now weighs them together, so the copy appears only when neither has anything beyond the configured default. The written count is unchanged: it is the route-level one the row already reported.
`/model` opens on `Configured`, which keeps only the rows placed in a current/pinned/recent/provider-default section. A custom relay's roster lives only in the listing that route itself answered, and none of those rows carried a section -- so `/model` showed the configured model alone while the provider panel had just listed the whole roster two keystrokes away. The rows were there; the default view was not. A custom route's live rows now reach the default view, bounded per route so a gateway fronting a thousand models cannot take the view over. Builtin providers keep the existing rule: their rows come from a bundled snapshot the Catalog view already browses. The regression asserts the bound and the presence, and fails with an empty row list when the rule is disabled.
Summary
Five defects kept a custom relay's model list from appearing. They had to be
fixed together: a listing could not be parsed, most routes were never
probed, a Messages-wire relay was authenticated wrongly, the provider panel
reported the resulting catalog as missing while displaying it, and
/modelthen hid the roster behind its default view.
1. The envelope rejected a body carrying both
dataandmodels.A relay returning the standard OpenAI
dataroster and echoing amodelsarray failed every probe. The listing was right there; the probe reported
invalid_response:{"object":"list","data":[ ...11 rows... ],"models":[ ...one stub... ],"total_count":11}2. Only the active route was probed, and the gate could not see the endpoint.
The picker lists every configured route, but only the active one was ever
probed — at startup and on a provider switch. Opening
/modeltriggers noprobe at all. Worse,
provider_owns_live_catalogtook only aProviderKind,so a builtin name pointed at a private relay (
[providers.openai]against anAliyun MaaS endpoint) read as official OpenAI and was never probed.
3. A custom Messages-wire relay's listing was authenticated with
x-api-key.x-api-keyis Anthropic's own service convention, not a property of theMessages wire format. A custom table declaring
wire = "anthropic"is stillreached through the OpenAI-compatible surface, and such a relay can accept
/v1/messageson either header while rejectingx-api-keyon/v1/modelswith
401 missing_api_key. Its listing reportedunauthorizedwhile the sameendpoint answered
Authorization: Bearernormally.4. The provider panel called a relay's catalog missing while listing it.
A custom provider ships no bundled catalog seeds, so the provider-level
snapshot count is always zero for one. The panel derived its catalog status
from that count alone and printed
catalog snapshot missing; using provider defaultdirectly above the live roster it had just fetched -- the rowcontradicted itself on screen.
5.
/modelhid the roster it had just collected./modelopens onConfigured, which shows only the rows that landed in acurrent / pinned / recent / provider-default section. A custom relay's roster
exists solely in the listing that route answered, and none of its rows carried
a section -- so
/modelshowed the single configured model while the providerpanel listed the whole roster. The rows were present the entire time.
Root cause
Envelope.
ModelsPagedeclared its row array as#[serde(borrow, alias = "models")] data. A serde alias is a second name forthe same key, so a body carrying both keys tried to deserialize that field
twice:
That maps to
CatalogRefreshError::InvalidResponse, failing the whole probeeven though
dataheld a complete roster and the echoedmodelsarray was astub with no usable rows.
Probe scope.
provider_owns_live_catalog(provider: ProviderKind)could notdistinguish
openaionapi.openai.com(a reviewed snapshot row) fromopenaion a relay (a private roster no snapshot can describe). The repositoryalready had that predicate —
codewhale_config::provider_preserves_custom_base_url_model(provider, base_url)— but this gate was not using it.
Authentication.
build_default_headerschose the auth header fromwire_formatalone. Its own comment already noted two hosts (OpenModel, theCodewhale API) that speak Messages but authenticate with Bearer; a custom table
needed the same treatment and had no way to get it, because
http_headersentries naming an auth header are filtered out.
Changes
crates/tui/src/client.rs:ModelsPageenumeratesdataandmodelsas two separate optional
RawValuefields instead of one aliased field, witha
rows()accessor resolvingdatafirst.crates/tui/src/client.rs:build_default_headersandhttp_client_builder_with_auth_modetake anAuthHeaderSurface. Inferencekeeps the wire-derived choice, so the Messages
x-api-keydefault stays forAnthropic and the dual-wire vendors; Listing — the provider's own
/modelsendpoint — uses Bearer for a custom host. The models client and the
guided-setup key probe (
verify_provider_api_key) build Listing; thechat/Messages/Responses client, its HTTP/1.1 fallback twin, the TypeSafe
decision client and the test header helpers build Inference.
crates/tui/src/provider_catalog_live.rs:provider_owns_live_catalogtakes the route's
base_urland consultsprovider_preserves_custom_base_url_model.crates/tui/src/client.rs:spawn_configured_provider_catalog_refreshsweeps the remaining configured routes at startup, each through the same
update_provider_catalogthe explicitmodels --updateuses — so the TTL,typed receipt, credential-change guard and superseded-refresh ticket are
shared, not reimplemented. A route already fresh is skipped.
crates/tui/src/lib.rs,crates/tui/src/tui/model_picker.rs: thestartup call and the picker's freshness receipt follow the widened predicate.
crates/tui/src/tui/provider_picker.rs: the panel row's catalog statusweighs the provider-level snapshot and the route's own roster, so the
"snapshot missing" copy appears only when neither source has anything beyond
the configured default. The count it prints is unchanged.-
crates/tui/src/tui/model_picker.rs: a custom route's live rows reachthe default view, bounded per route
(
CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW) so a gateway fronting a thousandmodels cannot take the view over. Builtin providers keep the existing rule:
their rows come from a bundled snapshot the
Catalogview already browses.datawins when present.modelsis not an alias — that was the bug — butit is load-bearing as a fallback: the ChatGPT/Codex roster serves
modelsasits only key, parsed downstream by
parse_models_response_for_provider.Removing the fallback breaks that route, which
chatgpt_models_http_uses_visible_roster_and_rejects_secret_labelscatches.Errors stay typed: neither key present, or
"data": nullbeside a populatedmodels, still ends inInvalidResponserather than a silently empty roster.Type of Change
Testing
End-to-end, in a fresh isolated home. With an empty
CODEWHALE_HOMEand
provider = "deepseek"active, one startup probed every configuredrelay — no manual
models --update, no provider switch:The auth fix measured directly against the reported relay.
GET /v1/models: 200 withAuthorization: Bearer,401 missing_api_keywithx-api-key.POST /v1/messages: accepts either.Regressions fail without their fix. The two envelope tests report
InvalidResponseagainst the unmodifiedModelsPage(
test result: FAILED. 0 passed; 2 failed) and pass with it.cargo test -p codewhale-tui --lib -- catalog custom_messages_wire_listing roster_envelope codewhale_authenticates_every_protocol openrouter_routes_carry_app_attribution→ 290 passed; 3 failed; 3ignored, against a baseline of 285 passed; 3 failed; 3 ignored on
the unmodified base
b5e270b69. The failures are the same pre-existingset in both runs (
fleet::scout::tests::unpinned_…, flaky under theparallel run and passing in isolation, and two
plugins::install::dsh::tests::raw_…that fail inside the Windows Nativeisolation probe).
codewhale_authenticates_every_protocol_with_bearer_never_x_api_keyand
openrouter_routes_carry_app_attribution_headersboth still pass, sothe surface split did not disturb the existing Bearer/attribution rules.
cargo test -p codewhale-tui --lib -- provider_picker-> 152 passed;0 failed, so the status change disturbed no existing provider-panel
expectation.- [x]
cargo test -p codewhale-tui --lib -- model_picker-> 49 passed;0 failed.
The default-view regression fails without its rule. Setting the
per-route bound to zero leaves the test's visible row list empty
(
the first listed row is offered: []), so the rule is what puts theroster there, not an incidental section.
rustfmt --edition 2024 --check crates/tui/src/client.rs— cleanRelated Issues
No-Issue: reported from a local custom-gateway session; no upstream issue was
opened for these defects.
Attribution
🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)