Skip to content

feat: serve custom endpoints their declared models intersected with the live catalog - #72

Open
sindriii wants to merge 11 commits into
apro-deployfrom
feat/model-availability-filter
Open

feat: serve custom endpoints their declared models intersected with the live catalog#72
sindriii wants to merge 11 commits into
apro-deployfrom
feat/model-availability-filter

Conversation

@sindriii

@sindriii sindriii commented Aug 21, 2026

Copy link
Copy Markdown

Why

We front LiteLLM with several custom endpoints — one per provider dialect, because customParams.defaultParamsEndpoint is what selects the Anthropic/Google request builder, and Claude models are measurably worse on the OpenAI builder. All of those endpoints share one baseURL, apiKey and header set.

Today they cannot have different model lists:

  • With fetch: true, a successful fetch replaces models.default wholesale, so every endpoint advertises LiteLLM's entire catalog — including embeddings, rerankers and OCR models, each on the wrong dialect.
  • With fetch: false and curated lists, the lists come apart but nothing tracks reality any more: a model retired in LiteLLM lingers in the picker, and the per-user catalog our OIDC endpoints already return collapses to one static list for everyone.

There is no third option in config, which is what this PR adds.

The same split leaves a second gap. Once the model list comes from the gateway, every row in the picker is a raw model id — claude-opus-4-8, gpt-5.4-nano. Agents and assistants get a name to show instead; no other endpoint does. Commits 5 and 6 give an endpoint somewhere to put a human label.

What

Six commits, reviewable in order.

1. models.filter — declared ∩ fetched

models.default becomes an allowlist rather than a fallback: the endpoint serves the declared names the gateway actually returned, in declared order. Declaring a model a deployment does not have is inert, so one shared list covers a fleet where each deployment holds a subset, and rolling a model out becomes a gateway-side change with no config edit.

Supporting changes:

  • models.default no longer requires one entry — an empty declared list is now meaningful (a template a deployment fills in).
  • Both prefilters deciding whether an endpoint has any model source tested models.default for truthiness, and [] is truthy. They now test length, and share one predicate instead of holding two copies of it.
  • A fetch that never answered is kept distinct from a fetch that answered with nothing.

2. One models-config resolution per request

getModelsConfig is reached from seven places per page (models route, startup-config spec pruning, validateModel, token config, agent initialization, both agent response controllers) and each re-ran the full resolution including a live /models call per gateway. MODEL_QUERIES cannot absorb it — fetchModels skips that cache whenever an endpoint forwards user-bound headers, and must, since the response is identity-scoped. Memoized on the request object instead; a failure evicts so a later caller can retry.

3. Withhold endpoints with nothing to serve

An endpoint's existence is decided by declaration, never content, so an empty endpoint still renders — useEndpoints computes hasModels but drops only agents, and the agent builder offers it as a provider with no model to pick. Already reachable today: an endpoint sending an authorization header whose fetch returns nothing gets [] rather than the declared fallback. Now withheld from the endpoints config, which is the single object the selector, the agent builder and spec pruning all derive from.

Custom endpoints only. Fails open — only an explicit empty list withholds an endpoint.

4. No violation for a request to an endpoint with nothing to offer

validateModel treats any model absent from an endpoint's list as an ILLEGAL_MODEL_REQUEST, which carries a violation score and contributes toward a ban. That reading only holds when the endpoint has models and the requested one is not among them.

An endpoint whose list is empty is unavailable, and the requests that arrive are stored conversations and agents naming an endpoint that has stopped serving them — commit 3 makes that an ordinary state. Their owners would collect violations for a configuration change they had no part in. Those are now rejected as "Endpoint unavailable" without logging. An unlisted model on an endpoint that does have models still logs, unchanged.

5. modelLabels — display labels on the endpoint

An endpoint-level Record<modelId, label>, alongside modelDisplayLabel and following tokenConfig, passed through to the client.

Display-only. The id stays what is declared, fetched, intersected, selected, stored on the conversation and sent upstream, so a label is safe to change at any time and a model with no entry renders its id.

Not routed through models.default: modelItemSchema has no label, and both the declared and the fetched path resolve to bare id strings, so modelsConfig is a Record<string, string[]> and stays that way. Keeping the map on the endpoint also means a label for a model that endpoint does not serve is simply never rendered — one map covers a fleet where each deployment serves a subset, which is the same property commit 1 gives the declared list.

6. Render the label

One helper, getModelName, returns the agent name, the assistant name or the declared label for a model, and undefined when there is none so each caller keeps its own fallback. An empty string counts as no name — that is what agentNames stores for an unnamed agent.

Five sites read it: the endpoint's model list, a search result row, the selector's closed trigger, the announcement made on selecting a model, and the Agent Builder's model combobox. The last matters most on deployments setting modelSpecs.addedEndpoints: [agents], where provider endpoints are kept out of the selector entirely and the Agent Builder is the only place a user meets a model id.

Search covers the label and the id, across all three filters — which endpoints survive a query, the open endpoint's list, and the search results' own predicate. A label is additive there: both strings match. An agent or assistant name still replaces the id, unchanged.

Selection is untouched: handleSelectModel and ControlCombobox both keep the id as the value they hand back.

Behaviour change to review deliberately

A rejected fetch now falls back to the declared list for every endpoint, including those sending an authorization header. Previously a rejected promise was flattened to [] before the header check, so those endpoints went empty during an outage.

This is intentional and paired with commit 3: once an empty list can remove an endpoint, treating an unreachable gateway as an authoritative empty would delete endpoints mid-blip and raise INVALID_AGENT_PROVIDER for stored agents naming them. The gateway, not this list, is the enforcement point — it rejects a call for a model the caller lacks regardless.

An answer of [] still yields nothing. For filtered endpoints that now falls out of the intersection without inspecting headers, so hasAuthorizationHeader should become redundant on the fulfilled path once every endpoint carries filter: true — left in place until that is confirmed on a live deployment.

Blast radius

  • Commits 1–4 need no client changes. useEndpoints and AgentPanel both derive from endpointsConfig / modelsConfig. Commits 5–6 touch the client, but only the string rendered for a model.
  • filterModelSpecsByAvailability untouched — it starts pruning against real availability again as a consequence of getting a better list. Note it fails open on a missing key and closed on [], which is why commit 1 keeps the key present.
  • endpoints.agents.allowedProviders unaffectedinitialize.ts rejects a provider missing from the list, so extra entries stay harmless.
  • Existing endpoints without filter behave as before on the fulfilled path, OIDC empty-answer handling included.

Tests

  • packages/api/src/endpoints/config/availability.spec.ts — 16 new: intersection and declared order, two endpoints sharing one coalesced fetch, empty answer, rejected fetch with and without an authorization header, filter with no fetch, the empty-declaration prefilter, and three pinning unfiltered behaviour.

  • packages/api/src/endpoints/config/endpoints.spec.ts — 7 new: withholding, built-ins never withheld, fail-open on error / on a missing key / with no resolver supplied.

  • api/server/services/Config/__tests__/getModelsConfig.spec.js — 4 new: merge, one resolution per request, no sharing between requests, retry after failure.

  • api/server/middleware/__tests__/validateModel.spec.js — 2 new: no violation on an empty list, violation still logged for an unlisted model.

  • packages/data-provider/specs/config-schemas.spec.ts — 4 new: modelLabels survives parsing rather than being stripped as an unknown key, accepts labels for undeclared models, stays optional, rejects a non-string label.

  • packages/api/src/endpoints/custom/config.spec.ts — 2 new: passed through to the client, absent when undeclared.

  • client/.../Endpoints/__tests__/utils.test.ts — 9 new: getModelName precedence and its empty-name and null handling, label-and-id search through filterItems and filterModels, getDisplayValue labelled and unlabelled.

  • client/.../components/__tests__/EndpointModelItem.test.tsx — 3 new: label rendered instead of the id, unlabelled model falls back, selection still keys on the id.

  • client/.../components/__tests__/SearchResults.test.tsx — 3 new: found by label, found by id, selected by id.

Green: packages/api 6576 tests (the 20 remaining failures are the Redis *_integration suites plus one timing-sensitive flow/manager spec that passes in isolation — no local Redis), packages/data-provider 1254, and the client's Endpoints / SidePanel/Agents / hooks/Endpoint suites, 168.

The api suite is flaky on this baseline — three consecutive runs gave 2, 4 and 3 failures out of 2976, all in strategies/openIdJwtStrategy, the OpenID cookie suites, requestPasswordReset and the CloudFront cookie suite. None sits in a path this branch touches. The two stable ones were reproduced at 650e62089 with this branch's changes reverted and both packages rebuilt. The subset covering everything changed here — server/services/Config, server/middleware, server/controllers, server/routes, 1349 tests — passes.

Not yet exercised against a live gateway; that is the next step on apro-sandbox.

One thing this does not fix

RemoteAgents / Remote-style endpoints — fetch: true, no filter, authenticating as a shared key — still replace their declared list with the gateway's whole catalog. That is what filter is for; adopting it there is a config change, not a code one.

sindriii and others added 3 commits August 21, 2026 12:42
…fetched catalog

`models.default` has only ever been a fallback: with `fetch: true` a
successful fetch replaces it wholesale, so every endpoint pointed at one
gateway advertises that gateway's entire catalog. Deployments that put
several endpoints over a single OpenAI-compatible gateway — one per
provider dialect, which is the only way to send each provider the
parameters it needs — therefore cannot give those endpoints different
model lists. Curating `models.default` with `fetch: false` gets the lists
apart, but then nothing reflects what the gateway actually serves: a model
retired upstream lingers in the picker, and a per-user catalog collapses
to one static list for everyone.

Add `models.filter`. With it, `models.default` is an allowlist rather than
a fallback and the endpoint serves `declared ∩ fetched`, in declared
order. Declaring a model the gateway does not have is inert, so one shared
list can cover a fleet of deployments that each hold a subset, and an
endpoint's contents follow the gateway without a config edit.

An empty declared list is now meaningful — an endpoint template that a
deployment fills in — so `models.default` no longer requires one entry,
and the two prefilters that decide whether an endpoint has any model
source at all now test that list's length. They were testing it for
truthiness, and `[]` is truthy, which would have admitted an endpoint that
can never produce a model. Both prefilters were separate copies of that
predicate; they now share one, next to the intersection it guards.

A fetch that never answered is kept distinct from a fetch that answered
with nothing. Transport failure falls back to the declared list, for every
endpoint: an empty list is about to mean "no endpoint", and treating an
unreachable gateway as an authoritative empty would take endpoints away
mid-outage. This is the one behaviour change for endpoints that send an
`authorization` header — a rejected fetch used to collapse to `[]` for
them, because it was flattened into an empty answer before that check ran.
An answer of `[]` still yields nothing, which is what the header check
already did for a successful empty response, now reached for filtered
endpoints without inspecting headers at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getModelsConfig` is the accessor for a request's available models and is
already reached from seven places — the models route, the startup-config
route pruning model specs, model validation on submit, token config, agent
initialization and both agent response controllers — each of which
re-runs the whole resolution, including a live `/models` call per gateway.

The shared `MODEL_QUERIES` cache cannot absorb that. `fetchModels` skips
it whenever an endpoint forwards user-bound headers, and it has to: the
response is scoped to the caller's identity, so one user's list must never
be served to another.

Memoize on the request object instead, which is the exact scope that makes
the result reusable — same identity, same moment — and lets the entry be
collected with the request. A failure evicts, so a later caller in the
same request retries rather than inheriting a settled rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An endpoint's existence is decided by its declaration, never by whether it
can serve anything, so an endpoint with an empty model list still renders.
`useEndpoints` computes `hasModels` but drops only `agents`, and the agent
builder offers the endpoint as a provider with no model to pick.

That is already reachable: for an endpoint sending an `authorization`
header, a fetch returning nothing yields an empty list rather than falling
back to declared models, so a user whose token grants no models gets an
endpoint that cannot answer. `models.filter` makes it ordinary — an
endpoint whose declared list intersects the catalog to nothing is an
endpoint this deployment does not have.

Withhold those from the endpoints config, which is the one object the
model selector, the agent builder and model-spec pruning all derive from,
so a single decision covers all three instead of three that can drift.

Custom endpoints only; built-in model lists are not per-request. Fails
open — only an explicit empty list withholds an endpoint, while a models
config that cannot be resolved, or that has no entry for an endpoint,
leaves every declared endpoint in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 38c0a052-6bd9-4a2a-86e0-9c8facb60936

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

sindriii and others added 8 commits August 21, 2026 13:05
`validateModel` treats any model absent from an endpoint's list as an
`ILLEGAL_MODEL_REQUEST`, which carries a configurable violation score and
contributes toward a ban. That reading only holds when the endpoint has
models and the requested one is not among them.

An endpoint whose list is empty is unavailable — the gateway serves none of
what it declares, or none of it for this user's grants. Every model named
against it is unserveable rather than illegitimate, and the requests that
arrive are stored conversations and agents pointing at an endpoint that has
since stopped serving them. Their owners would collect violations, and
eventually a ban, for a change in configuration they had no part in.

Reject those without logging a violation, and say the endpoint is
unavailable rather than the request illegal. An unlisted model on an
endpoint that does have models still logs, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An endpoint's model list renders raw ids — `claude-opus-4-8`, `gpt-5.4-nano`.
Only agents and assistants carry a name to show instead; every other endpoint
shows the id it was configured with, which is the wrong string to put in front
of a non-technical user.

Adds `modelLabels`, an endpoint-level `Record<modelId, label>` alongside
`modelDisplayLabel` and `tokenConfig`, and passes it through to the client.

It is display-only. The id stays what is declared, fetched, intersected,
selected, stored on the conversation and sent upstream, so a label is safe to
change at any time and a model with no entry renders its id. Keeping the map at
endpoint level rather than in `models.default` also keeps `modelsConfig` a
`Record<string, string[]>`: `modelItemSchema` has no `label`, and both the
declared and the fetched path resolve to bare id strings.

Because a label for a model the endpoint does not serve is simply never
rendered, one map can cover a fleet of deployments that each serve a subset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves the name to show through one helper, `getModelName`, which returns the
agent name, the assistant name or the endpoint's declared label for the model —
and `undefined` when there is none, so each caller keeps its own fallback. An
empty string counts as no name, which is what `agentNames` stores for an unnamed
agent.

Five sites read it: the endpoint's model list, a search result row, the
selector's closed trigger, the announcement made on selecting a model, and the
Agent Builder's model combobox. The last one matters most on deployments that
set `modelSpecs.addedEndpoints: [agents]`, since that keeps provider endpoints
out of the selector entirely and the Agent Builder becomes the only place their
users meet a model id.

Search covers the label and the id, across all three filters — which endpoints
survive a query, the open endpoint's list, and the search results' own
predicate. A label is additive there: both strings match. An agent or assistant
name still replaces the id, unchanged.

Selection is untouched: `handleSelectModel` and `ControlCombobox` both keep the
id as the value they hand back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`withholdEmptyCustomEndpoints` ran on every `getEndpointsConfig` call, and that
function has seven callers — most of which want the endpoint's *configuration*,
not a view of what a user may be offered. Two of them make this actively wrong,
not merely slow:

- `buildEndpointOption` reads `defaultParamsEndpoint` off the merged config to
  pick a request builder. Withhold the endpoint and it reads `undefined`, and the
  conversation falls back to the OpenAI schema — the exact dialect degradation
  the per-provider split exists to prevent, reachable on the message path
  whenever a user's catalog comes back empty.
- `validateModel` reads `userProvide` to let a user-keyed endpoint through. A
  withheld endpoint loses that and takes the wrong branch.

And `checkCapability` shares the same function, so a capability check — three of
which sit on the file-upload path — waited on a model catalog it never reads.

Withholding is now opt-in, and `/api/endpoints` is the only caller that opts in:
it is the one answering "what may this user be offered", and the selector, the
Agent Builder and spec pruning all derive from its response.

Cache the user-scoped catalog instead of skipping the cache. Skipping it was
right about the hazard — keyed by baseURL+apiKey alone, one user's filtered list
is served to the next request over that gateway — and wrong about the remedy: it
made every request pay a live fetch, and a page load issues three. The key now
carries the requesting user and the header templates in play, with a
thirty-second TTL rather than the catalog's two minutes, so a page load costs one
round trip and a revoked grant surfaces within half a minute. The gateway remains
the enforcement point either way. With no identity to key on, nothing is cached.

Cut this path's timeout from 5s to 2s. Every caller of the config loader degrades
to the declared list, so the only thing a longer wait buys is a more accurate
list — paid for with a blank picker in front of a waiting user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirty seconds was picked to cover a page load and nothing more, on the
assumption that a short window was the safe choice. It buys less safety than it
looks: a stale entry cannot grant anything, because the gateway rejects a call
for a model the user lacks regardless. What a long window actually costs is a
list that lags — a revoked grant, or a model just rolled out gateway-side.

Five minutes trades a little of that lag for far fewer round trips, which is the
right way round while the cache has no shared store: without Redis it is an
in-memory map per task, wiped on restart, so entries have to survive longer than
one page load to be worth anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 5s ceiling was cut to 2s on the reasoning that a caller which degrades
gracefully should not make a user wait — but the premise was wrong. `fetchModels`
catches its own transport errors and returns an empty list, so a timeout is not a
failure this code sees: it is an empty catalog, which intersects to nothing and
withholds the endpoint. A shorter timeout therefore produces *more* empty
pickers, which is the symptom it was meant to relieve.

Removes the parameter as well as the value: nothing else wanted it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An empty model list on a user-provided endpoint reflects the user's stored
key — missing, expired, or granted nothing — and the picker entry is the
only way to set or fix that key. Withholding it would lock the user out,
so it stays visible even when empty, mirroring validateModel's exemption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant