Repository navigation
fix(catalog): discover a custom relay model list - #7013
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. Signed-off-by: Shizuku <2163018547@qq.com>
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. Signed-off-by: Shizuku <2163018547@qq.com>
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. Signed-off-by: Shizuku <2163018547@qq.com>
`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. Signed-off-by: Shizuku <2163018547@qq.com>
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. Signed-off-by: Shizuku <2163018547@qq.com>
`/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. Signed-off-by: Shizuku <2163018547@qq.com>
One entry for the five defects that kept a relay's roster out of `/model`. Signed-off-by: Shizuku <2163018547@qq.com>
4e82de5 to
e0ca753
Compare
crates/tui/CHANGELOG.md is generated from the root CHANGELOG.md by scripts/sync-changelog.sh, so the entry added only to the crate copy failed the Version drift check. The entry now lives in the root [Unreleased] section and the crate copy is regenerated (sync-changelog.sh --check: up to date). Merges current main (clean merge-tree) so the branch carries the latest [Unreleased] section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
main gained new [Unreleased] entries after the previous merge, which conflicted with this entry in both CHANGELOG copies. Resolved by taking main's CHANGELOG.md, re-adding this PR's entry at the end of [Unreleased], and regenerating crates/tui/CHANGELOG.md (sync-changelog.sh --check: up to date). No source files conflicted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
|
🤖 This reply is from Claude Opus 5.5, an AI agent working on behalf of the Codewhale maintainers. It is posted through @Hmbown's account, but Hunter did not write it. Merged as 1c99609. Thank you, @SparkofSpike. Before this, a custom OpenAI-compatible relay's model list never showed up in I resolved one What this covers: hosted CI, plus the end-to-end relay run you describe above. I haven't run it against a live third-party relay myself. The fix is on — Written by Claude Opus 5.5 (AI agent) on behalf of the maintainers. |
The engine's cw-land still offered worktrees and integration branches as landing shapes, and said not to push without explicit authorization. That contradicts AGENTS.md since 2026-10-08: one canonical checkout on main, no maintainer branches or worktrees, and verified slices are pushed directly. codewhale-ops/skills/cw-land defers to this file for Engine specifics, so the stale copy was the one agents read. - Landing shape: the canonical checkout's main, stage only your paths, push verified main. - The four cheap checks that have turned main red most often (fmt, sync-changelog --check, release/check-versions.sh, runtime-contract budget), plus the path-specific ones. New CHANGELOG entries go at the top of [Unreleased]: our appends at the end conflicted with open contributor PRs (#7013 went DIRTY twice that way). - A contributor branch that conflicts only because main moved is resolved on their branch with merge-tree, commit-tree and a refspec push, which needs no local branch. This replaces the integration-branch fallback, with the zsh `${sha}:refs` pitfall noted. - Before a merge, list PRs based on the source branch (root AGENTS.md). - The worktree cleanup step is dropped. The red flag now limits only tag, release, publish and deploy. Docs only; every script path named was checked to exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
What and why
A custom OpenAI-compatible relay's model list never reached
/model. Fiveseparate defects were in the way, and each one alone was enough to keep the
list empty, so they are fixed together here — the commits are split so each
stands on its own, but no single one makes the feature work.
ModelsPagedeclared itsrow array as
#[serde(borrow, alias = "models")] data. A serde alias is asecond name for the same key, so a body carrying both
dataandmodelsfailed with
duplicate field dataand the probe reportedinvalid_response— whiledataheld a complete roster. A relay thatechoes the wider spelling as a stub (one entry, empty
id) is common.configured route, but startup probed just the one the session was on, and
opening
/modeltriggers no probe at all.provider_owns_live_catalogtook only a
ProviderKind, so a builtin name pointed at a private relay(
[providers.openai]against an Aliyun MaaS endpoint) read as officialOpenAI and was never probed.
/v1/modelsinherited the Messages auth convention.x-api-keyisAnthropic's own service convention, not a property of the Messages wire
format. A custom table declaring
wire = "anthropic"is still reachedthrough the OpenAI-compatible surface, and such a relay can accept
/v1/messageson either header while rejectingx-api-keyon/v1/modelswith
401 missing_api_key./modelopens onConfigured, which shows only rows placed in a current / pinned / recent /provider-default section. A relay's rows exist only in the listing that
route answered, and none of them carried a section.
Changes
crates/tui/src/client.rs:ModelsPageenumeratesdataandmodelsas twoseparate optional
RawValuefields, with arows()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.
crates/tui/src/provider_catalog_live.rs:provider_owns_live_catalogtakesthe route's
base_urland consultsprovider_preserves_custom_base_url_model.crates/tui/src/client.rs:spawn_configured_provider_catalog_refreshsweepsthe 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/tui/provider_picker.rs: the panel row's catalog statusweighs the provider-level snapshot and the route's roster, so
"catalog snapshot missing" appears only when neither has anything beyond the
configured default.
crates/tui/src/tui/model_picker.rs: a custom route's live rows reach thedefault view, bounded per route
(
CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW) so a gateway fronting a thousandmodels cannot take the view over.
crates/tui/CHANGELOG.md: one entry for the whole fix.modelsis not an alias — that was the bug — but it is load-bearing as afallback: the ChatGPT/Codex roster serves
modelsas its only key and isparsed downstream by
parse_models_response_for_provider. Removing thefallback breaks that route, which
chatgpt_models_http_uses_visible_roster_and_rejects_secret_labelscatches.Errors stay typed: with neither key present, or
"data": nullbeside apopulated
models, the probe still ends inInvalidResponserather than asilently empty roster.
Vec<T>rejects a presentnullwith or without#[serde(default)], verified directly.Issue
No-Issue: a custom gateway's rosters were unusable from a local session; no
upstream issue was filed for these defects.
How I tested it
End to end, in a fresh isolated home (
CODEWHALE_HOMEpointed at anempty directory,
provider = "deepseek"active). One startup probed everyconfigured relay — no
models --update, no provider switch:custom:interncustom:agnescustom:opendesignopenai:openai(a relay endpoint)deepseekitself is an official endpoint, so it correctly owns no roster.One configured host,
custom:weixin, still fails:GET /openai/v1/modelsanswers
400 missing required parameter: modelon every path whilePOST /openai/v1/chat/completionsanswers 200, so that server publishes nolisting at all.
The auth fix measured against the failing relay.
GET /v1/models: 200with
Authorization: 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. The default-viewtest leaves an empty visible-row list when the per-route bound is zero.
cargo test -p codewhale-tui --lib -- catalog model_picker provider_picker→ 469 passed; 3 failed; 3 ignored. The three are pre-existing on this
Windows host and none of them touches a file in this PR:
fleet::scout::tests::unpinned_…passes when run alone (it flakes only in theparallel run), and the two
plugins::install::dsh::tests::raw_…fail insidethe Windows Native isolation probe (
extension_host/windows.rs).cargo check --profile test -p codewhale-tui --libafter rebasing onto72c1327be— clean.rustfmt --edition 2024 --checkon every touched file — clean.Checklist
main(on72c1327be)cargo fmt --allpasses, and tests cover new or changed behaviordocs/features.toml— unchanged: this repairs the existingprovidersfeature rather than adding a command,
[features]flag or providerAttribution
🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)