Some checks failed
CI/CD / lint (pull_request) Successful in 1m19s
CI/CD / typecheck (pull_request) Successful in 2m21s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m17s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / publish (pull_request) Has been skipped
The served model changes (glm-4.6-reap -> deepseek-v4-flash, and again after that). Pulumi already repoints mcpd's long-lived rows -- vllm-current/fast and vllm-think/heavy -- at whatever LiteLLM serves, so no model id is written down in mcpctl. But nothing resolved those rows: gate prompt-selection took MCPCTL_GATE_SELECTION_LLM or the project's llmProvider, and neither is set on any project or either deployment. Unpinned projects fell through to the local personal-token provider, whose heavy entry names a retired claude-opus-4 -- so every gated session has been running on priority-ordered prompts, not LLM ranking, announcing it only in a mcplocal log line nobody reads. Consumers now ask for a role. tier is already a field on the Llm resource and is set by the same Pulumi resource that sets model, so the two cannot drift. Explicit pins still win; this only changes what happens when nothing is pinned. Eligibility excludes inactive rows (selecting one defers the failure to the first inference call) and virtual rows -- those are backed by some user's mcplocal over SSE, and automatic resolution must not route a project's traffic through a laptop nobody chose. The heavy tier already holds two rows, so the tiebreak is load-bearing now rather than future-proofing: an ordered list of well-known names, carrying both the current names and the symmetric ones the rows might be renamed to, so a rename stays a Pulumi-only change and neither deploy order breaks the other. Names matching nothing are inert by design. The rows keep the names they have. name is immutable in mcpd (agents and projects reference it), so a rename costs a data migration plus a resource replace, and buys nothing once nothing reads the names. vllm-fast would also collide in meaning with LiteLLM's deepseek-v4-fast route, which is a different thing one word apart. Smoke covers the drift case the unit tests cannot: the resolved fast row has to answer real inference, which is exactly what fails when a row is left pointing at a suspended model. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5NAoE7VJA5TWvHsVfEmUr
118 lines
5.2 KiB
Markdown
118 lines
5.2 KiB
Markdown
# LLM tiers — tracking the served model without hardcoding it
|
|
|
|
The homelab's served model changes. `glm-4.6-reap` became `deepseek-v4-flash`;
|
|
something else will replace it. Every place mcpctl writes a model id down is a
|
|
place that silently rots when that happens — a row left requesting a suspended
|
|
model gets `HTTP 400 model not found` on the next call, and the failure surfaces
|
|
somewhere unhelpful (a gate that quietly stops ranking prompts, an agent that
|
|
500s).
|
|
|
|
mcpctl's answer is that **it never names a model, and prefers not to name a
|
|
row**. Consumers ask for a *role*; the registry says who currently fills it.
|
|
|
|
## The three layers
|
|
|
|
```
|
|
LiteLLM (llm.ad.itaz.eu) deepseek-v4-flash, -fast, -low, -think, -max
|
|
▲ served-model ids — change on every model swap
|
|
│ Pulumi owns this mapping
|
|
mcpd Llm rows vllm-current (tier: fast)
|
|
▲ vllm-think (tier: heavy)
|
|
│ stable identities; `model` follows the deployment
|
|
mcpctl consumers "give me the fast one"
|
|
no model id, no row name
|
|
```
|
|
|
|
**Layer 1 → 2 is Pulumi's job.** `deployments/mcpctl/llm-target.ts` in the
|
|
`kubernetes-deployment` repo declares both rows via the `@mcpctl/pulumi`
|
|
provider and feeds them the active served-model name. Swap the model, run a
|
|
targeted `pulumi up`, and the rows follow. See
|
|
[pulumi-provider-llm-autopoint](../src/pulumi/README.md).
|
|
|
|
**Layer 2 → 3 is `src/mcplocal/src/server-llm.ts`.** `tier` is already a
|
|
first-class field on the `Llm` resource and is set by the same Pulumi resource
|
|
that sets `model`, so the two cannot drift.
|
|
|
|
## Resolution rules
|
|
|
|
`resolveServerLlmByTier(client, tier)` returns the row filling a tier, or
|
|
`null`. A row is eligible when:
|
|
|
|
- its `tier` matches exactly;
|
|
- its `status` is `active` — selecting a row with no live backend just moves
|
|
the failure to the first inference call;
|
|
- its `kind` is **not** `virtual`. Virtual rows are backed by some user's
|
|
`mcplocal` over the SSE control channel, i.e. by a machine and personal
|
|
credentials nobody chose deliberately. An explicit pin may still name one;
|
|
automatic resolution must not route a project's traffic through a laptop.
|
|
|
|
When a tier holds several rows — `heavy` holds both `vllm-think` and the cloud
|
|
`anthropic-fallback` — an ordered **well-known name** list breaks the tie:
|
|
|
|
| tier | preference order |
|
|
|------|------------------|
|
|
| `fast` | `vllm-fast`, `vllm-current` |
|
|
| `heavy` | `vllm-think`, `vllm-thinking` |
|
|
|
|
Names that match nothing are inert. That is deliberate: the list carries both
|
|
the current names and the symmetric names the rows might be renamed to, so a
|
|
rename stays a Pulumi-only change and neither deploy order breaks the other.
|
|
Rows absent from the list are still eligible — they just sort last, then by
|
|
name, so the choice is stable across calls regardless of mcpd's list order.
|
|
|
|
Override the order without a release:
|
|
|
|
```bash
|
|
MCPCTL_LLM_PREFER_FAST=spare-row,vllm-current
|
|
MCPCTL_LLM_PREFER_HEAVY=vllm-think
|
|
```
|
|
|
|
Resolution never throws. mcpd being unreachable is a normal degraded state for
|
|
`mcplocal`, and every caller has a fallback path; returning `null` keeps that
|
|
fallback intact instead of failing session setup.
|
|
|
|
## Who uses it
|
|
|
|
**Gate prompt-selection** (`project-mcp-endpoint.ts`). Order:
|
|
|
|
1. `MCPCTL_GATE_SELECTION_LLM` — a global pin, so selection can sit on a fast
|
|
no-think Llm while chat keeps a thinking model;
|
|
2. the project's own `llmProvider` (`none` disables);
|
|
3. whichever row currently fills the `fast` tier.
|
|
|
|
Step 3 is what makes an unpinned project work. Without it, a project with no
|
|
`llmProvider` fell through to the local personal-token provider — and on a
|
|
machine whose personal key is stale, that means every gated session silently
|
|
ran on priority-ordered prompts instead of LLM ranking:
|
|
|
|
```
|
|
[gate] LLM prompt-selection failed: Anthropic HTTP 404: model: claude-opus-4-20250514
|
|
— falling back to priority-ordered prompts
|
|
```
|
|
|
|
The gate prints a `⚠ Smart prompt-selection unavailable` banner in the
|
|
`begin_session` response whenever it degrades. Absence of that banner on a
|
|
project with no pin is what the smoke test asserts.
|
|
|
|
Explicit pins still win everywhere — this only changes what happens when
|
|
nothing is pinned.
|
|
|
|
## Why not rename `vllm-current` to `vllm-fast`?
|
|
|
|
`name` is immutable in mcpd (agents and projects reference it), so a rename is
|
|
a data migration plus a Pulumi resource replace, not an edit. Once resolution
|
|
is tier-based nothing in mcpctl reads the names, so the migration buys nothing.
|
|
`vllm-fast` would also be actively confusing: the fast row points at
|
|
`deepseek-v4-flash` (the base route, which carries no forced `extra_body`),
|
|
while LiteLLM separately serves a `deepseek-v4-fast` route that pins
|
|
`thinking: false`. Two different things, one name apart.
|
|
|
|
## Testing
|
|
|
|
- `src/mcplocal/tests/server-llm.test.ts` — selection rules, tiebreak, env
|
|
override, degraded paths.
|
|
- `src/mcplocal/tests/smoke/llm-tier.smoke.test.ts` — against the live stack:
|
|
both tiers resolve, the resolved fast row answers real inference (the drift
|
|
check — a row pointing at a suspended model fails here), and an unpinned
|
|
gated project gets LLM-ranked selection.
|