Skip to content

fix(catalog): discover a custom relay model list - #7013

Merged
Hmbown merged 9 commits into
codewhale-hq:mainfrom
SparkofSpike:fix/custom-relay-model-envelope
Oct 17, 2026
Merged

Hmbown merged 9 commits into
codewhale-hq:mainfrom
SparkofSpike:fix/custom-relay-model-envelope

Conversation

@SparkofSpike

Copy link
Copy Markdown
Contributor

What and why

A custom OpenAI-compatible relay's model list never reached /model. Five
separate 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.

  1. The roster envelope rejected its own payload. ModelsPage declared its
    row array as #[serde(borrow, alias = "models")] data. A serde alias is a
    second name for the same key, so a body carrying both data and models
    failed with duplicate field data and the probe reported
    invalid_response — while data held a complete roster. A relay that
    echoes the wider spelling as a stub (one entry, empty id) is common.
  2. Only the active route was probed. The provider panel lists every
    configured route, but startup probed just the one the session was on, and
    opening /model triggers no probe at all.
  3. The probe gate could not see the endpoint. provider_owns_live_catalog
    took only a ProviderKind, so a builtin name pointed at a private relay
    ([providers.openai] against an Aliyun MaaS endpoint) read as official
    OpenAI and was never probed.
  4. /v1/models inherited the Messages auth convention. x-api-key is
    Anthropic's own service convention, not a property of the Messages wire
    format. A custom table declaring wire = "anthropic" is still reached
    through the OpenAI-compatible surface, and such a relay can accept
    /v1/messages on either header while rejecting x-api-key on /v1/models
    with 401 missing_api_key.
  5. The picker's default view hid the roster. /model opens on
    Configured, 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: ModelsPage enumerates data and models as two
    separate optional RawValue fields, with a rows() accessor resolving
    data first.
  • crates/tui/src/client.rs: build_default_headers and
    http_client_builder_with_auth_mode take an AuthHeaderSurface. Inference
    keeps the wire-derived choice, so the Messages x-api-key default stays for
    Anthropic and the dual-wire vendors; Listing — the provider's own /models
    endpoint — uses Bearer for a custom host.
  • crates/tui/src/provider_catalog_live.rs: provider_owns_live_catalog takes
    the route's base_url and consults
    provider_preserves_custom_base_url_model.
  • crates/tui/src/client.rs: spawn_configured_provider_catalog_refresh sweeps
    the remaining configured routes at startup, each through the same
    update_provider_catalog the explicit models --update uses — 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 status
    weighs 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 the
    default view, bounded per route
    (CUSTOM_ROSTER_ROWS_IN_DEFAULT_VIEW) so a gateway fronting a thousand
    models cannot take the view over.
  • crates/tui/CHANGELOG.md: one entry for the whole fix.

models is not an alias — that was the bug — but it is load-bearing as a
fallback: the ChatGPT/Codex roster serves models as its only key and is
parsed downstream by parse_models_response_for_provider. Removing the
fallback breaks that route, which
chatgpt_models_http_uses_visible_roster_and_rejects_secret_labels catches.

Errors stay typed: with neither key present, or "data": null beside a
populated models, the probe still ends in InvalidResponse rather than a
silently empty roster. Vec<T> rejects a present null with 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_HOME pointed at an
    empty directory, provider = "deepseek" active). One startup probed every
    configured relay — no models --update, no provider switch:

    scope before after
    custom:intern failed / 0 fresh / 11
    custom:agnes fresh / 10 fresh / 10
    custom:opendesign failed: unauthorized / 0 fresh / 12
    openai:openai (a relay endpoint) never probed fresh / 176

    deepseek itself is an official endpoint, so it correctly owns no roster.
    One configured host, custom:weixin, still fails: GET /openai/v1/models
    answers 400 missing required parameter: model on every path while
    POST /openai/v1/chat/completions answers 200, so that server publishes no
    listing at all.

  • The auth fix measured against the failing relay. GET /v1/models: 200
    with Authorization: Bearer, 401 missing_api_key with x-api-key.
    POST /v1/messages: accepts either.

  • Regressions fail without their fix. The two envelope tests report
    InvalidResponse against the unmodified ModelsPage
    (test result: FAILED. 0 passed; 2 failed) and pass with it. The default-view
    test 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 the
    parallel run), and the two plugins::install::dsh::tests::raw_… fail inside
    the Windows Native isolation probe (extension_host/windows.rs).

  • cargo check --profile test -p codewhale-tui --lib after rebasing onto
    72c1327be — clean.

  • rustfmt --edition 2024 --check on every touched file — clean.

Checklist

  • One focused change, rebased on current main (on 72c1327be)
  • cargo fmt --all passes, and tests cover new or changed behavior
  • docs/features.toml — unchanged: this repairs the existing providers
    feature rather than adding a command, [features] flag or provider

Attribution

🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)

@SparkofSpike
SparkofSpike requested a review from Hmbown as a code owner October 17, 2026 14:10

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

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>
@SparkofSpike
SparkofSpike force-pushed the fix/custom-relay-model-envelope branch from 4e82de5 to e0ca753 Compare October 17, 2026 14:11
CodeWhale Bot and others added 2 commits October 17, 2026 10:23
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
@Hmbown
Hmbown merged commit 1c99609 into codewhale-hq:main Oct 17, 2026
27 checks passed
@Hmbown

Hmbown commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 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 /model, and you found five separate reasons for that. The duplicate data/models key is the kind of bug that looks like a bad relay response until someone reads the serde attribute. Moving /models from x-api-key to Bearer auth on custom hosts also fixes a 401 that users would otherwise have blamed on their own key.

I resolved one CHANGELOG.md conflict on your branch (0c743f3) after main moved. Your code is unchanged. All 19 required checks passed on that head.

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 main. It isn't in the 0.10.2 candidate that is frozen now, and the release that ships it hasn't been decided yet.

— Written by Claude Opus 5.5 (AI agent) on behalf of the maintainers.

Hmbown pushed a commit that referenced this pull request Oct 17, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants