feat: keep every Bot's provider defaults in one spec file - #649
Conversation
shared/model-providers.ts becomes the one list of provider facts: key variable, base URL variable and default model per provider. The three TypeScript Bots read it instead of their own copies: the Mastra Bot now defaults to gpt-5.5 like the others and refuses a BOT_PROVIDER it does not recognize. Compose passes the Google pair to the picked harness, .env.example gains the Claude Code OAuth pair, and the configuration docs point BOT_PROVIDER at the shared list.
shared/model-providers.json holds the provider facts (key variable, endpoint
variable, default model) and one row per Bot: the provider and model it runs
when its environment names neither. TypeScript reads it through
shared/model-providers.ts, Python through shared/model_providers.py, any other
language reads the file directly. Validation runs at startup: a missing field,
or a bots entry naming a provider the file does not know, stops the process
with the path of the offending key rather than at the first model call. API
keys never live in the file; they arrive in the environment under the
key_variable the provider row names.
BOT_PROVIDER and BOT_MODEL still win over the file, exactly as
docs/configuration.md describes. The defaults written down are the ones each
Bot hard-coded before, so a deployment that changes nothing answers on the
same models it did: the three TypeScript Bots on gpt-5.5, the Python Bots on
gpt-4o-mini or gpt-5.5 as each one was. A missing bots entry throws rather
than defaults, meeting the developer at startup instead of silently answering
on somebody else's model.
Compose no longer substitutes gpt-5.5 for agent-langgraph when BOT_MODEL is
unset: it passes the value through so the Bot's row, or the moved provider's
default row, answers. An OpenAI deployment keeps gpt-5.5 either way; a Google
or Anthropic one stops being handed a model its vendor has never heard of.
The picked harness keeps its own default, pinned by tests/compose.test.ts.
Wired: three TypeScript Bots through botSettings(), ten Python Bots through
bot_settings(), ten new agent-*/tests/test_model_spec.py pinning each wiring
and its source-level binding, and the Dockerfiles copy the loader and the file
into the images (the TypeScript Bots already copy the whole shared directory).
Verified: format:check, lint, typecheck, test:ci (5209 pass, 0 fail, 27
skip), build; pytest test_model_spec.py across the ten Python harnesses (40
pass); docker build with runtime probes reading the defaults off the file and
the environment beating them; compose.test.ts (17 pass) after the compose
change; and a live stack on BOT_PROVIDER=google with a real key, where
agent-langgraph served {"provider":"google","model":"gemini-3.1-flash-lite"}
straight from the file with no BOT_MODEL set, and answered through the
managed coworker on the first run.
Hotragn
left a comment
There was a problem hiding this comment.
Nobody has reviewed this yet and it is the kind of change where the risk is not in any one file, so here is a pass over the parts that a 13-runtime consolidation usually gets wrong. Two of the three came out clean, and I want to say so rather than only list problems.
Checked and clean
Packaging. My first worry was an image that ships the reader and not the data. All eleven changed Dockerfiles copy both (shared/model_providers.py + shared/model-providers.json, and shared/model-providers.ts + the JSON for Mastra), and agent-bot and agent-langgraph need no change because they already COPY shared ./shared wholesale. shared/model-providers.ts reaches the data through a static import specJson from "./model-providers.json", so it has to be on disk in the image, and it is.
The defaults match main, bot for bot. I extracted each Bot's current default and compared against the bots table: all thirteen agree, including the split between the gpt-4o-mini Bots and the gpt-5.5 ones. The one change is agent-mastra, and you disclosed it in both the changelog and the PR body, so that is a decision rather than a slip. I will only note that it is the single behaviour change in a PR framed as consolidation — every deployment running the Mastra Bot with no BOT_MODEL moves model and cost on upgrade — and it would be easy for a maintainer to approve the refactor and not register that. It might deserve its own line at the top of the entry rather than a parenthesis.
providers.*.default_model is reachable, which I checked because a brand-new contract file with a dead field ages badly. modelFor falls back to it when the environment moves the provider off the Bot's own row (model-providers.ts:266). Live and load-bearing.
The two loaders do not enforce the same contract
This is the one I would want fixed before it lands, because it is invisible until the file changes and the file is now the thing everybody edits.
The module doc says the JSON "is the contract every language in the box reads" and that the loaders add "types and validation". They add different validation, because they answer to different authorities:
TypeScript (model-providers.ts) |
Python (model_providers.py) |
|
|---|---|---|
| Which providers must exist | all of PROVIDER_IDS — openai, anthropic, google |
openai only (:70) |
| A row not in that list | refuses: "providers has a … row this module has never heard of" (:117-121) |
accepted, validated, ignored |
bots.*.provider checked against |
the hardcoded PROVIDER_IDS |
the file's own providers map (:82) |
Two consequences, both reachable by the workflow this PR is for:
Adding a provider breaks the TypeScript Bots. The _readme says "providers = facts per provider" and the changelog says a new provider is a row in one file. Add a mistral row and the ten Python Bots take it happily, while all three TypeScript Bots refuse to start. The TS module docblock does say adding one means "one entry to PROVIDER_IDS and one row to the JSON file" — so this is deliberate on that side — but a contributor working in Python, following the _readme, never reads that module. Whatever the rule is, the JSON's own _readme should state it, because that string is what the next person reads.
Removing a provider is silent on the Python side. Drop the anthropic row and the TS Bots stop at startup naming the missing key, which is precisely the behaviour the docblock argues for: "stops the process at startup with the path of the offending key rather than at the first model call." The Python Bots start clean, and a deployment on BOT_PROVIDER=anthropic then fails at its first model call — the exact failure this validation exists to prevent, on ten of the thirteen Bots.
The comment at model_providers.py:70 reads "The same fallback the TypeScript loader makes", which is true of the fallback and not of the validation around it, and I think that sentence is how the gap survived review by its own author.
Either direction fixes it. Give Python the same explicit tuple and check both ways; or drop PROVIDER_IDS and have TypeScript derive its union from the file the way Python does, which costs the compile-time type. I would take the first — the whole point of the file is that a mistake in it stops every Bot the same way, and "every Bot" currently means three of them.
One small thing
The two changelog entries contradict each other. The first says the Mastra Bot's default moved from gpt-4o-mini to gpt-5.5; the second says "the defaults themselves are unchanged". I can see that the second means moving to the file changed nothing by itself, but they sit in the same Unreleased section, and the top of CHANGELOG.md says it is written "for somebody deciding whether to upgrade". A reader who hits the second sentence has been told the thing they most need to check is not worth checking.
I have not run this: thirteen runtimes, ten of them Python, and I have no Python toolchain set up for this repo. The claims above are all read off the diff and off main, and I would rather be told I have misread one than have you find out I guessed.
shared/model_providers.py checked the file against its own contents and required only the openai row, while shared/model-providers.ts checked against PROVIDER_IDS in both directions. A provider row added to the JSON therefore stopped the three TypeScript Bots and was accepted, validated and ignored by the ten Python ones; a provider row dropped from it stopped the TypeScript Bots and left the Python Bots starting clean, so a deployment on BOT_PROVIDER=anthropic met the missing row at its first model call rather than at startup — the failure this validation exists to prevent, on ten of the thirteen Bots. Both loaders now keep their own PROVIDER_IDS and check the file against it both ways, in the same words: providers is missing its <id> row., providers has a <id> row this module has never heard of., and bots.<id>.provider is "<p>", not one of openai, anthropic, google. A bots entry naming a provider is checked against the list rather than against the file's own providers map, which is the same check once the two agree. The comment that said "the same fallback the TypeScript loader makes" sat on the openai-only check and described the fallback rather than the validation beside it; it goes with the check. The rule is now written where the next reader finds it: both module docstrings, the _readme inside the JSON file, and docs/configuration.md, which gains "Adding a provider" beside "Adding a Bot". The TypeScript docblock no longer implies a new provider is one entry in its own list alone. New: agent-langgraph-agui/tests/test_spec_validation.py imports the loader in a subprocess against a file built in tmp_path — the shipped file accepted, each of the three provider rows dropped, an unknown row, and a bots entry naming an unknown provider — so a refusal here cannot leave a half-loaded module beside it. It lives in that harness's tests because that is where CI already runs pytest for shared code. Verified: format:check, lint, typecheck; bun test shared/ tests/compose.test.ts (106 pass); pytest test_spec_validation.py and the ten agent-*/tests/test_model_spec.py (50 pass) in a throwaway venv; bun run test against a stashed baseline, unchanged at 4376 pass and 60 fail both before and after, every failure the missing TEST_DATABASE_URL of this machine.
…radicting itself The two Unreleased entries argued with each other: the first said the Mastra Bot's default moved from gpt-4o-mini to gpt-5.5, the second said "the defaults themselves are unchanged". Both describe the same release, and the top of this file says it is written for somebody deciding whether to upgrade — a reader who reached the second sentence had been told the one thing worth checking was not worth checking. The Mastra change is now the first line of its entry, with what a deployment running that Bot on BOT_MODEL unset should do about it, rather than a parenthesis inside a sentence about consolidation. It is the only behaviour change in a refactor, and it moves model and cost on upgrade. The second entry now says reading the file moved no default on its own and points at the one that did move, and it describes the loader parity — both loaders refusing the same wrong file — instead of a validation only one of them performed.
|
@Hotragn Thanks — the loader gap was the one I wanted fixed too, and both changelog points are in. The loaders now enforce the same contract. New Changelog: the Mastra line is now the first line of its entry, with what a deployment running that Bot without Two things I would read back differently: Verified locally: CI has been sitting at |
Move both unchanged provider-default changelog entries to distinct existing anchors. Preserve every source and test blob from the repaired head.
What this changes
shared/model-providers.jsonis now the single place that names a provider'sfacts — which environment variable carries its key, which carries its endpoint,
and which model runs when nothing else does — plus one row per Bot for the
provider and model it runs when its environment names neither. The TypeScript
Bots read it through
shared/model-providers.ts, the ten Python Bots throughshared/model_providers.py, and any other language can read the file directly:a spec file does not need a loader written for it.
botsentry naming a provider the file does not know, stops the Bot with thepath of the offending key rather than at its first model call; a missing
botsentry throws rather than silently answering on somebody else's model.docs/configuration.mdalready promised:BOT_PROVIDERandBOT_MODELbeat the file, the file beats the hard-codeddefault,
AGENT_BOT_MODELstill applies to the proof-of-concept Bot on itsown, and
model_key()keeps splittingprovider/modeloverrides.so a deployment that changes nothing answers on the same models it did. The
proof-of-concept and TypeScript LangGraph Bots keep
gpt-5.5, the MastraBot keeps
gpt-4o-mini, and the Python Bots keepgpt-4o-miniorgpt-5.5as each one was. Moving a Bot to a different model, or giving a Bot written in any
other language its first one, is editing one row in one file instead of one
line per language.
variable that carries its key, and every refusal message names that variable
and where the provider row lives.
gpt-5.5foragent-langgraphwheneverBOT_MODELis unset: it passes the unset value through, so the Bot's row —or the moved provider's default row — is what answers. An OpenAI deployment
keeps
gpt-5.5either way; a Google or Anthropic one stops being handed amodel its vendor has never heard of. The picked harness keeps its own
default, pinned by
tests/compose.test.ts.agent-*Bots — the processes thatcall a model provider themselves. The built-in Bots that run inside the
server keep the server's own default (
server/src/copilot.ts) and are nottouched here. A
remote_ag_uicoworker such asrisk-analysthas no modelof its own: it answers through the agent endpoint it forwards to (here
agent-langgraph, whose row is in the file), so it inherits this changeindirectly rather than gaining a row of its own.
Wired: three TypeScript Bots through
botSettings(), ten Python Bots throughbot_settings(), ten newagent-*/tests/test_model_spec.pypinning eachwiring and its source-level binding, and the Dockerfiles copy the loader and
the file into the images (the TypeScript Bots already copy the whole
shared/directory, so they needed no change).
Where it runs
startup into process memory and never written.
read-only file from its own image; replicas never coordinate over it.
arbitrating.
Boundary and audit
configuration before serving, on the same path the Bots already used.
throws before any request exists; it acts on nothing.
— the file ships inside the image and is never client input.
Changelog
Unreleased("The Bots agree on one set of providerdefaults" and "One spec file, in every language"), the second covering the
Compose default fix.
Original contributor proof (before repair)
After merging
main(146 files,@copilotkit/runtime1.70.1 → 1.73.1,bun installfirst):bun run format:check(962 files),bun run lint(977 files, no warnings),bun run typecheck(app, server, worker),bun run build— all clean.bun run test:ci— 5355 pass, 0 fail, 38 skip across 415 files, floorof 400 tests satisfied.
pytest agent-*/tests/test_model_spec.pyacross the ten Python harnesses —40/40, four assertions each: provider and model defaulting, env beating
file, and the
AGENT_BOT_MODELoverride with its separation.bun test tests/compose.test.ts— 17 pass: the Compose default is nowan empty
BOT_MODEL, the picked-harness default is untouched, and therendered YAML for the Unset case shows
BOT_MODEL: "".docker buildofagent-mastraandagent-adk(both sources changed by themerge) — succeed; the merged
agent-langgraphimage rebuilt and picked thespec file up from
shared/.BOT_PROVIDER=googleand a real key,agent-langgraphanswered
/healthwith{"provider":"google","model":...}straight fromthe file with no
BOT_MODELset, and answered a chat through the managedcoworker on the first run (Risk Analyst persona,
17 x 23 = 391). BothGoogle candidates,
gemini-2.5-flashandgemini-3.1-flash-lite, verifiedHTTP 200 against the same key.
.envrestored to its committed state andno
BOT_MODELanywhere:/healthreports{"status":"ok","provider":"openai","model":"gpt-5.5"}— the value fromagent-langgraph's row in the spec file, not from a Compose substitution,which is the same model an OpenAI deployment answered with before this
change.
Local environment notes, not code: the start script's Google gap and the
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTSflag for local coworker endpoints arepre-existing behaviour, documented in the test session rather than changed
here.
Repair verification
The Python LangGraph harness now passes the spec-resolved model into its SDK, including edited Bot rows and provider defaults. The existing
google_genaialias, explicitBOT_MODELvalues, provider-qualified model names, and ChatGPT authentication branch are preserved. Mastra retains its priorgpt-4o-minidefault; this consolidation does not change its default model or cost.Verified locally on the repaired tree:
--noEmitand Python compilation passed.The original contributor's broader validation above predates this repair. Current CI results should be read from this PR's checks.