From ae93c5d7eb59ae796337b27cbb9ae6b26f83923a Mon Sep 17 00:00:00 2001 From: Hieuej147 Date: Sun, 27 Sep 2026 12:48:00 +0700 Subject: [PATCH 1/6] feat: move the three TypeScript Bots onto one shared provider registry 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. --- .env.example | 12 +- CHANGELOG.md | 9 ++ agent-bot/src/index.ts | 29 ++-- agent-bot/src/model-key.ts | 34 ----- agent-bot/tests/model-key.test.ts | 56 ------- agent-langgraph/src/index.ts | 78 +++++----- agent-langgraph/src/model-key.ts | 42 ----- agent-langgraph/tests/model-key.test.ts | 34 ----- agent-mastra/Dockerfile | 1 + agent-mastra/src/mastra/index.test.ts | 61 +++++++- agent-mastra/src/mastra/index.ts | 46 ++++-- docker-compose.yml | 2 + docs/configuration.md | 2 +- shared/model-providers.test.ts | 194 ++++++++++++++++++++++++ shared/model-providers.ts | 164 ++++++++++++++++++++ tests/compose.test.ts | 25 ++- 16 files changed, 548 insertions(+), 241 deletions(-) delete mode 100644 agent-bot/src/model-key.ts delete mode 100644 agent-bot/tests/model-key.test.ts delete mode 100644 agent-langgraph/src/model-key.ts delete mode 100644 agent-langgraph/tests/model-key.test.ts create mode 100644 shared/model-providers.test.ts create mode 100644 shared/model-providers.ts diff --git a/.env.example b/.env.example index 5d14e12b3..52e22953a 100644 --- a/.env.example +++ b/.env.example @@ -209,6 +209,13 @@ OPENAI_API_KEY= # ANTHROPIC_API_KEY= # GOOGLE_API_KEY= +# A model sign-in in place of a key: the Anthropic token from `claude setup-token`, and the file a +# signed-in ChatGPT plan writes. The desktop app sets both when a provider is connected that way; a +# box you configure yourself sets them here. The API server reads them for built-in agents, and +# compose passes them to the picked harness. See docs/configuration.md. +# CLAUDE_CODE_OAUTH_TOKEN= +# CHATGPT_AUTH_FILE= + # Which model the framework Bot uses. Defaults per provider: gpt-5.5, claude-sonnet-4-5, # gemini-2.5-flash. A 5.6 tier works here: set one and the Responses API is switched on # automatically. It used to answer nothing at all on those models — RUN_STARTED, RUN_FINISHED, no @@ -341,8 +348,9 @@ MANAGED_AGENT_TOKEN= # proof of concept, and is reached the same way: point MANAGED_AGENT_AG_UI_URL at it, or add it as a # Bot of its own in the tenant package or at /agents. -# Which model the Bots use. BOT_MODEL is the framework Bot's: it runs gpt-5.6-terra and switches to -# the Responses API by itself, because 5.6 rejects function tools on /v1/chat/completions. +# Which model the Bots use. BOT_MODEL is the framework Bot's: it defaults to gpt-5.5 and switches +# to the Responses API by itself for a model that needs it — a 5.6 tier does, because 5.6 rejects +# function tools on /v1/chat/completions. # # The proof-of-concept Bot has its own, AGENT_BOT_MODEL, defaulting to gpt-5.5, because it writes # that endpoint by hand and refuses to start on a model whose tools it cannot use. One variable for diff --git a/CHANGELOG.md b/CHANGELOG.md index c459fd61c..234ea0106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The Bots agree on one set of provider defaults + +The three TypeScript Bots now read a single shared list of provider facts instead of keeping their +own copies. On OpenAI they all default to `gpt-5.5` — the Mastra Bot previously defaulted to +`gpt-4o-mini` — and the Mastra Bot refuses a `BOT_PROVIDER` it does not recognize (such as +`google`) instead of quietly answering through OpenAI with a different model. The picked harness +in Compose now receives `GOOGLE_API_KEY` and `GOOGLE_GENERATIVE_AI_BASE_URL` as well, so a harness +picked on `BOT_PROVIDER=google` has the key it needs. + ### Dictate messages and talk to a coworker in a live voice call Deployments can configure transcription separately from their Bots' models, with a waveform composer diff --git a/agent-bot/src/index.ts b/agent-bot/src/index.ts index b470ac932..711ed1a14 100644 --- a/agent-bot/src/index.ts +++ b/agent-bot/src/index.ts @@ -4,13 +4,13 @@ import { serve } from "bun"; import OpenAI from "openai"; import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { listenPort } from "../../shared/listen-port"; -import { toProviderMessages } from "./history"; import { apiKeyOrPlaceholder, + configuredModel, keyIsRequired, - modelIsUnusable, - modelName, -} from "./model-key"; + requiresResponsesApi, +} from "../../shared/model-providers"; +import { toProviderMessages } from "./history"; /** * The built-in Bot is an AG-UI HTTP service registered the same way as any customer-provided Bot. @@ -39,24 +39,25 @@ if (!MANAGED_AGENT_TOKEN) { /** * Which model drives the Bot. * + * This Bot speaks one provider's API by hand, so the provider is this file's and only the model is + * configurable; the default is that provider's row in the shared registry. + * * `gpt-5.5` works through `/v1/chat/completions`, which is the API this file uses. * * `gpt-5.6-*` models require the Responses API for tool use and cannot be used by this * chat-completions streaming loop. */ -const MODEL = modelName(process.env.BOT_MODEL); +const MODEL = configuredModel("openai", process.env.BOT_MODEL); /* - * Refuse a model this file cannot use, rather than discover it one tool call at a time. - * - * `gpt-5.6-*` rejects function tools on `/v1/chat/completions`: "To use function tools, use - * /v1/responses or set reasoning_effort to 'none'." The provider answers with an error, this Bot - * ends the run, and the person sees no reply and no reason. Silence is the worst failure available - * here, and it is what a single mistaken `BOT_MODEL` produced: every tool-using turn stopped dead - * while the Bot looked healthy. + * Refuse a model this file cannot use, rather than discover it one tool call at a time — the + * failure `requiresResponsesApi` names, asked as a question about this Bot rather than about the + * model. The provider answers with an error, this Bot ends the run, and the person sees no reply + * and no reason. Silence is the worst failure available here, and it is what a single mistaken + * `BOT_MODEL` produced: every tool-using turn stopped dead while the Bot looked healthy. * * Startup is where a deployment can act on it, which is the same posture as the token check above. */ -if (modelIsUnusable(MODEL)) { +if (requiresResponsesApi(MODEL)) { console.error( `BOT_MODEL=${MODEL} cannot be used by this Bot. It speaks /v1/chat/completions directly, and ` + "that endpoint refuses function tools for this model, so every tool call would fail with no " + @@ -97,7 +98,7 @@ const API_KEY = process.env.OPENAI_API_KEY?.trim(); * * The check still holds for plain OpenAI, which is the case it was written for. */ -if (!API_KEY && keyIsRequired(BASE_URL)) { +if (!API_KEY && keyIsRequired("openai", BASE_URL)) { console.error( "OPENAI_API_KEY is not set, and no OPENAI_BASE_URL names an endpoint that needs no key. This Bot cannot answer without a model.", ); diff --git a/agent-bot/src/model-key.ts b/agent-bot/src/model-key.ts deleted file mode 100644 index 9b219f56d..000000000 --- a/agent-bot/src/model-key.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Whether this Bot needs a model key, checked before it starts. - * - * Its own module because `index.ts` serves at module scope, so importing it to reach one pure - * function binds a port. - */ - -/** - * A key is required unless an endpoint was named to answer instead. - * - * `OPENAI_BASE_URL` set means any endpoint speaking that API, and Ollama, vLLM, LM Studio and - * llama.cpp all serve it with no key. The setup window offers exactly those by name and accepts a - * blank key for them, so requiring one here exited this Bot on startup for every one of them. - */ -export function keyIsRequired(baseUrl: string | undefined): boolean { - return !baseUrl?.trim(); -} - -/** - * What to hand the SDK, which insists on a string even when the endpoint ignores it. - * - * A placeholder rather than an empty string: empty is a client that cannot be constructed. - */ -export function apiKeyOrPlaceholder(apiKey: string | undefined): string { - return apiKey?.trim() || "no-key-needed"; -} - -export function modelName(configured: string | undefined): string { - return configured?.trim() || "gpt-5.5"; -} - -export function modelIsUnusable(model: string): boolean { - return /^gpt-5\.[6-9]|^gpt-[6-9]/.test(model); -} diff --git a/agent-bot/tests/model-key.test.ts b/agent-bot/tests/model-key.test.ts deleted file mode 100644 index 3e0c43a71..000000000 --- a/agent-bot/tests/model-key.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - apiKeyOrPlaceholder, - keyIsRequired, - modelIsUnusable, - modelName, -} from "../src/model-key"; - -/** - * A named endpoint is a model, and its key belongs to it. - * - * The failure this pins: the setup window's "any OpenAI-compatible endpoint" row takes an address - * with no key, because Ollama and vLLM have none. This Bot then refused to start, saying - * OPENAI_API_KEY was not set, so the keyless half of that feature produced a dead container and a - * red line on the last screen about a key the person's own server does not have. - */ -describe("whether a model key is required", () => { - test("plain OpenAI still needs its key", () => { - expect(keyIsRequired(undefined)).toBe(true); - expect(keyIsRequired("")).toBe(true); - expect(keyIsRequired(" ")).toBe(true); - }); - - test("an endpoint named instead of OpenAI answers without one", () => { - expect(keyIsRequired("http://127.0.0.1:11434/v1")).toBe(false); - }); - - test("the SDK is always handed a string", () => { - expect(apiKeyOrPlaceholder(undefined)).toBe("no-key-needed"); - expect(apiKeyOrPlaceholder(" ")).toBe("no-key-needed"); - expect(apiKeyOrPlaceholder("sk-real")).toBe("sk-real"); - }); -}); - -describe("which model this Bot was told to use", () => { - test("an unset or empty choice falls back", () => { - expect(modelName(undefined)).toBe("gpt-5.5"); - expect(modelName("")).toBe("gpt-5.5"); - expect(modelName(" ")).toBe("gpt-5.5"); - }); - - test("a padded name is the name", () => { - expect(modelName(" gpt-5.5 ")).toBe("gpt-5.5"); - }); - - test("the models this Bot cannot drive are refused", () => { - expect(modelIsUnusable("gpt-5.6-terra")).toBe(true); - expect(modelIsUnusable("gpt-6")).toBe(true); - expect(modelIsUnusable("gpt-5.5")).toBe(false); - }); - - test("padding does not get one past the guard", () => { - expect(modelIsUnusable(modelName(" gpt-5.6-terra"))).toBe(true); - expect(modelIsUnusable(modelName("\tgpt-6 "))).toBe(true); - }); -}); diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index 7fb7d7650..219ff0e64 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -13,9 +13,16 @@ import { ChatOpenAI } from "@langchain/openai"; import { serve } from "bun"; import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { listenPort } from "../../shared/listen-port"; +import { + apiKeyOrPlaceholder, + baseUrlVariableFor, + configuredModel, + keyIsRequired, + keyVariableFor, + requiresResponsesApi, +} from "../../shared/model-providers"; import { toLangChainMessages } from "./history"; import { readReasoningEffort } from "./model-options"; -import { apiKeyOrPlaceholder, KEY_VARIABLE, keyIsRequired } from "./model-key"; import { streamRun } from "./stream"; import { toolAnswer } from "./tool-answer"; @@ -65,47 +72,48 @@ if (!MANAGED_AGENT_TOKEN) { * Each provider reads its own key. A deployment that only runs Anthropic never needs an OpenAI key, * which is the point of making this configurable rather than assuming one vendor. * - * The default is unchanged so the two shipped Bots stay comparable out of the box. - */ -const PROVIDER = (process.env.BOT_PROVIDER ?? "openai").toLowerCase(); -/* - * An unset model and an empty one are the same thing. + * The default is unchanged so the two shipped Bots stay comparable out of the box. Which default + * that is, and which variable each provider's key and endpoint arrive in, are read from the shared + * provider registry rather than repeated here. * - * `??` only catches undefined, and a compose file passing `BOT_MODEL: ${BOT_MODEL:-}` hands this an - * empty string, which is a value. The agent then asked its provider for a model named "" and the - * run died with "you must provide a model parameter", which reads as a broken Bot rather than as - * missing configuration. + * Blank is OpenAI, the reading every other consumer of `BOT_PROVIDER` gives it: the desktop writes + * an empty provider when switching back to OpenAI, and the server reads empty as OpenAI. Padded and + * differently-cased names are the same provider, because a value typed into a setup window arrives + * with a space on it more often than not. A name nobody has heard of is kept, so the check below + * can put it in its message. */ -const MODEL = process.env.BOT_MODEL?.trim() || defaultModelFor(PROVIDER); +const PROVIDER = + (process.env.BOT_PROVIDER ?? "").trim().toLowerCase() || "openai"; +// An unset model and an empty one are the same thing; see `configuredModel`. +const MODEL = configuredModel(PROVIDER, process.env.BOT_MODEL); /** * OpenAI only. Its newer models require the Responses API, which the integration handles. * * Inferred from the model rather than left to a separate switch. `gpt-5.6-*` rejects function tools * on `/v1/chat/completions`, so a deployment that set `BOT_MODEL` to one and did not also know about * this flag got a Bot that started, looked healthy, and failed on its first tool call. The switch is - * still honoured, so a model this list has not heard of can be told to use it. + * still honoured, so a model the registry has not heard of can be told to use it. */ -const NEEDS_RESPONSES_API = /^gpt-5\.[6-9]|^gpt-[6-9]/.test(MODEL); +const NEEDS_RESPONSES_API = requiresResponsesApi(MODEL); const USE_RESPONSES_API = process.env.BOT_RESPONSES_API === "true" || NEEDS_RESPONSES_API; /** - * OpenAI only, and the same variable the API server reads for its built-in agents. + * Read from the provider registry rather than spelled out here three times, under the names the + * API server already reads. Sharing the names is the point: one line moves the built-in agents and + * this Bot together, and a deployment cannot end up with half of itself pointed somewhere else. * - * Unset, `openai` means OpenAI. Set, it means any endpoint speaking that API: a gateway in front of - * several providers, a proxy, or a model on hardware you control. The integration owns the HTTP, so - * this is a base URL rather than another provider branch, and `BOT_MODEL` is sent verbatim because - * an endpoint names its own catalogue. - */ -const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined; -/** - * The same idea for the other two providers, under the names the API server already reads. + * Unset, this is that provider's own public endpoint. Set, it means any endpoint speaking that API: + * a gateway in front of several providers, a proxy, or a model on hardware you control. The + * integration owns the HTTP, so this is a base URL rather than another provider branch, and + * `BOT_MODEL` is sent verbatim because an endpoint names its own catalogue. * - * Sharing the variable names is the point: one line moves the built-in agents and this Bot - * together, and a deployment cannot end up with half of itself pointed somewhere else. + * Only the provider this Bot was configured for is read, and each branch of `buildModel` below + * ever took its own and no other, so nothing that used to be visible has changed. */ -const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL?.trim() || undefined; -const GOOGLE_BASE_URL = - process.env.GOOGLE_GENERATIVE_AI_BASE_URL?.trim() || undefined; +const baseUrlVariable = baseUrlVariableFor(PROVIDER); +const BASE_URL = baseUrlVariable + ? process.env[baseUrlVariable]?.trim() || undefined + : undefined; /** * OpenAI only, and Responses API only: how hard this Bot is allowed to think. @@ -141,12 +149,6 @@ if (REASONING_EFFORT && !USE_RESPONSES_API) { process.exit(1); } -function defaultModelFor(provider: string): string { - if (provider === "anthropic") return "claude-sonnet-4-5"; - if (provider === "google") return "gemini-2.5-flash"; - return "gpt-5.5"; -} - /** * The key this provider needs, checked at startup rather than on the first run. * @@ -154,7 +156,7 @@ function defaultModelFor(provider: string): string { * a missing key should fail in front of whoever is deploying, not as a conversation that errors in * front of somebody trying to use it. */ -const keyVariable = KEY_VARIABLE[PROVIDER]; +const keyVariable = keyVariableFor(PROVIDER); if (!keyVariable) { console.error( `BOT_PROVIDER=${PROVIDER} is not one this Bot knows. Use openai, anthropic or google.`, @@ -163,7 +165,7 @@ if (!keyVariable) { } const API_KEY = process.env[keyVariable]?.trim(); // Unless an endpoint was named to answer instead: see `keyIsRequired`. -if (!API_KEY && keyIsRequired(PROVIDER, OPENAI_BASE_URL)) { +if (!API_KEY && keyIsRequired(PROVIDER, BASE_URL)) { console.error( `${keyVariable} is not set, and BOT_PROVIDER=${PROVIDER} needs it. This Bot cannot answer without a model.`, ); @@ -199,7 +201,7 @@ function buildModel() { model: MODEL, apiKey: apiKeyOrPlaceholder(API_KEY), streaming: true, - ...(ANTHROPIC_BASE_URL ? { anthropicApiUrl: ANTHROPIC_BASE_URL } : {}), + ...(BASE_URL ? { anthropicApiUrl: BASE_URL } : {}), }); } if (PROVIDER === "google") { @@ -207,14 +209,14 @@ function buildModel() { model: MODEL, apiKey: apiKeyOrPlaceholder(API_KEY), streaming: true, - ...(GOOGLE_BASE_URL ? { baseUrl: GOOGLE_BASE_URL } : {}), + ...(BASE_URL ? { baseUrl: BASE_URL } : {}), }); } return new ChatOpenAI({ model: MODEL, apiKey: apiKeyOrPlaceholder(API_KEY), streaming: true, - ...(OPENAI_BASE_URL ? { configuration: { baseURL: OPENAI_BASE_URL } } : {}), + ...(BASE_URL ? { configuration: { baseURL: BASE_URL } } : {}), ...(USE_RESPONSES_API ? { useResponsesApi: true } : {}), /* * `reasoning.effort`, not the `reasoningEffort` convenience field: the integration deprecated diff --git a/agent-langgraph/src/model-key.ts b/agent-langgraph/src/model-key.ts deleted file mode 100644 index 4bc6aa367..000000000 --- a/agent-langgraph/src/model-key.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Whether this Bot needs a model key, checked before it starts. - * - * Its own module for the reason `model-options.ts` is: `index.ts` calls `serve()` at module scope, - * so importing it to reach one pure function binds a port. - */ - -/** The environment variable each provider's key arrives in. */ -export const KEY_VARIABLE: Record = { - openai: "OPENAI_API_KEY", - anthropic: "ANTHROPIC_API_KEY", - google: "GOOGLE_API_KEY", -}; - -/** - * A key is required unless an endpoint was named to answer instead. - * - * `OPENAI_BASE_URL` set means any endpoint speaking that API, and Ollama, vLLM, LM Studio and - * llama.cpp all serve it with no key at all. The setup window offers exactly those by name and - * accepts a blank key for them, so requiring one here exited this Bot on startup for every one of - * them: the person filled in an address and got a dead container complaining about a key their - * server does not have. Two ends of one feature disagreeing. - * - * Only the OpenAI branch has a base URL to be named by, so nothing changes for the other two. - */ -export function keyIsRequired( - provider: string, - baseUrl: string | undefined, -): boolean { - const named = provider === "openai" && Boolean(baseUrl?.trim()); - return !named; -} - -/** - * What to hand the SDK, which insists on a string even when the endpoint ignores it. - * - * A placeholder rather than an empty string: empty is a client that cannot be constructed, and the - * value is never sent anywhere that reads it. - */ -export function apiKeyOrPlaceholder(apiKey: string | undefined): string { - return apiKey?.trim() || "no-key-needed"; -} diff --git a/agent-langgraph/tests/model-key.test.ts b/agent-langgraph/tests/model-key.test.ts deleted file mode 100644 index cd6eff0f6..000000000 --- a/agent-langgraph/tests/model-key.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { apiKeyOrPlaceholder, keyIsRequired } from "../src/model-key"; - -/** - * A named endpoint is a model, and its key belongs to it. - * - * The failure this pins: the setup window's "any OpenAI-compatible endpoint" row takes an address - * with no key, because Ollama and vLLM have none. This Bot then refused to start, saying - * OPENAI_API_KEY was not set, so the whole keyless half of that feature produced a dead container. - */ -describe("whether a model key is required", () => { - test("plain OpenAI still needs its key", () => { - expect(keyIsRequired("openai", undefined)).toBe(true); - expect(keyIsRequired("openai", "")).toBe(true); - expect(keyIsRequired("openai", " ")).toBe(true); - }); - - test("an endpoint named instead of OpenAI answers without one", () => { - expect(keyIsRequired("openai", "http://127.0.0.1:11434/v1")).toBe(false); - }); - - /** Neither of the other providers has a base URL to be named by, so neither changes. */ - test("anthropic and google are unchanged", () => { - expect(keyIsRequired("anthropic", "http://127.0.0.1:11434/v1")).toBe(true); - expect(keyIsRequired("google", "http://127.0.0.1:11434/v1")).toBe(true); - }); - - /** The SDK cannot be constructed with an empty string, so there is always something to pass. */ - test("the SDK is always handed a string", () => { - expect(apiKeyOrPlaceholder(undefined)).toBe("no-key-needed"); - expect(apiKeyOrPlaceholder(" ")).toBe("no-key-needed"); - expect(apiKeyOrPlaceholder("sk-real")).toBe("sk-real"); - }); -}); diff --git a/agent-mastra/Dockerfile b/agent-mastra/Dockerfile index a2d21a457..e03a64b94 100644 --- a/agent-mastra/Dockerfile +++ b/agent-mastra/Dockerfile @@ -7,6 +7,7 @@ COPY agent-mastra/package.json ./ RUN bun install COPY shared/listen-port.ts /shared/listen-port.ts +COPY shared/model-providers.ts /shared/model-providers.ts COPY agent-mastra/src ./src # Built at image time, not at start: `mastra start` runs a bundle, and building on every container diff --git a/agent-mastra/src/mastra/index.test.ts b/agent-mastra/src/mastra/index.test.ts index 93454bb1d..93a84eb77 100644 --- a/agent-mastra/src/mastra/index.test.ts +++ b/agent-mastra/src/mastra/index.test.ts @@ -104,6 +104,41 @@ async function configuredPort(port: string | undefined) { }; } +/** + * How this Bot answers a provider it has no module for: refused at startup, before a model exists. + * + * The message is the result rather than an error to be thrown past the assertion, so the exit + * status is returned the way the port probe returns it. + */ +async function providerStartup(botProvider: string) { + const env: Record = { + PATH: process.env.PATH ?? "/opt/homebrew/bin:/usr/bin:/bin", + MASTRA_TELEMETRY_DISABLED: "true", + DO_NOT_TRACK: "1", + NODE_ENV: "test", + BOT_PROVIDER: botProvider, + }; + + const child = Bun.spawn( + [ + Bun.argv[0], + "-e", + [ + 'const { mastra } = await import("./agent-mastra/src/mastra/index.ts");', + 'const model = mastra.getAgent("openbot").model;', + "console.log(JSON.stringify({ modelId: model.modelId }));", + ].join("\n"), + ], + { env, stdout: "pipe", stderr: "pipe" }, + ); + + const [stderr, exitCode] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stderr }; +} + describe("OpenBot Mastra receiver instructions", () => { test("adds model-visible OpenBot role context in receiver order", () => { const instructions = buildOpenBotInstructions({ @@ -144,9 +179,9 @@ describe("OpenBot Mastra receiver instructions", () => { describe("OpenBot Mastra model configuration", () => { const modelCases: ModelCase[] = [ - { name: "absent", expected: "gpt-4o-mini" }, - { name: "empty", value: "", expected: "gpt-4o-mini" }, - { name: "whitespace", value: " ", expected: "gpt-4o-mini" }, + { name: "absent", expected: "gpt-5.5" }, + { name: "empty", value: "", expected: "gpt-5.5" }, + { name: "whitespace", value: " ", expected: "gpt-5.5" }, { name: "custom", value: " fixture/custom:model ", @@ -161,6 +196,26 @@ describe("OpenBot Mastra model configuration", () => { } }); +/** + * A provider this Bot cannot answer for is refused before it can fall into the OpenAI branch and + * quietly answer with somebody else's model. + */ +describe("OpenBot Mastra provider configuration", () => { + test("refuses a provider it loads no module for", async () => { + const { exitCode, stderr } = await providerStartup("google"); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("loads no Google module"); + }); + + test("refuses a provider the registry has not heard of", async () => { + const { exitCode, stderr } = await providerStartup("mistral"); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("is not one this Bot knows"); + }); +}); + describe("OpenBot Mastra listen port configuration", () => { const validPortCases: PortCase[] = [ { name: "absent", expected: 4213 }, diff --git a/agent-mastra/src/mastra/index.ts b/agent-mastra/src/mastra/index.ts index 83e8e715b..e11d14bfe 100644 --- a/agent-mastra/src/mastra/index.ts +++ b/agent-mastra/src/mastra/index.ts @@ -16,19 +16,45 @@ import { Agent } from "@mastra/core/agent"; import { Mastra } from "@mastra/core/mastra"; import { registerApiRoute } from "@mastra/core/server"; import { listenPort } from "../../../shared/listen-port"; +import { + apiKeyOrPlaceholder, + configuredModel, + providerSpec, +} from "../../../shared/model-providers"; -async function configuredModel() { - const provider = process.env.BOT_PROVIDER?.trim() || "openai"; - const model = - process.env.BOT_MODEL?.trim() || - (provider === "anthropic" ? "claude-sonnet-4-5" : "gpt-4o-mini"); - const baseVariable = - provider === "anthropic" ? "ANTHROPIC_BASE_URL" : "OPENAI_BASE_URL"; +/** The providers this Bot can drive: the ones whose SDK modules it loads below. */ +const SUPPORTED_PROVIDERS = new Set(["openai", "anthropic"]); + +/** + * The model this Bot answers with, read from the shared provider registry rather than remembered + * in this file. + * + * The registry says which providers exist; this file still decides which of them it can drive, + * because only two SDK modules are loaded here. Both halves refuse at startup: a provider the + * registry has not heard of, and one it has that this harness has no module for, used to fall + * through to the OpenAI branch below and answer with a model the deployment never chose. + */ +async function buildModel() { + const providerName = process.env.BOT_PROVIDER?.trim() || "openai"; + const spec = providerSpec(providerName); + if (!spec) { + // What to use is what this harness answers on, which is narrower than the registry. + throw new Error( + `BOT_PROVIDER=${providerName} is not one this Bot knows. Use ${[...SUPPORTED_PROVIDERS].join(" or ")}.`, + ); + } + if (!SUPPORTED_PROVIDERS.has(spec.id)) { + throw new Error( + `BOT_PROVIDER=${providerName} names ${spec.label}, and this Bot loads no ${spec.label} module. It answers on OpenAI and Anthropic only.`, + ); + } + const model = configuredModel(spec.id, process.env.BOT_MODEL); + const baseVariable = spec.baseUrlVariable; const baseURL = process.env[baseVariable]?.trim(); // Provider modules create default clients at import, which reject Compose's empty overrides. if (!baseURL) delete process.env[baseVariable]; - if (provider === "anthropic") { + if (spec.id === "anthropic") { const { createAnthropic } = await import("@ai-sdk/anthropic"); // Other harnesses accept an Anthropic origin; AI SDK expects the /v1 API prefix. const origin = (baseURL || "https://api.anthropic.com").replace(/\/+$/, ""); @@ -43,7 +69,7 @@ async function configuredModel() { const openai = createOpenAI({ baseURL: baseURL || "https://api.openai.com/v1", apiKey: compatible - ? process.env.OPENAI_API_KEY?.trim() || "no-key-needed" + ? apiKeyOrPlaceholder(process.env[spec.keyVariable]) : undefined, }); // Compatible endpoints commonly expose Chat Completions; OpenAI keeps its Responses default. @@ -109,7 +135,7 @@ const openbot = new Agent({ id: "openbot", name: "openbot", instructions: buildOpenBotInstructions, - model: await configuredModel(), + model: await buildModel(), }); /** The one header OpenBot's server sends, compared without leaking length through timing. */ diff --git a/docker-compose.yml b/docker-compose.yml index 5cd73aac0..257984685 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -321,6 +321,8 @@ services: OPENAI_BASE_URL: ${OPENAI_CONTAINER_BASE_URL:-${OPENAI_BASE_URL:-}} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-} + GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} + GOOGLE_GENERATIVE_AI_BASE_URL: ${GOOGLE_GENERATIVE_AI_BASE_URL:-} BOT_PROVIDER: ${BOT_PROVIDER:-openai} CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} # A path into the mount below, set only when a ChatGPT plan was signed in to. The file is diff --git a/docs/configuration.md b/docs/configuration.md index c32a655f9..076e78f2b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,7 +48,7 @@ at `agent-langgraph` on a laptop. | `DEPLOYMENT_ID` | the tenant package's id | Names this deployment inside a shared Intelligence project. | | `OPENAI_API_KEY` | unset | Default model key for built-in agents and both shipped Bots. | | `OPENAI_BASE_URL` | unset | OpenAI-compatible endpoint that key is spent against. See below. | -| `BOT_PROVIDER` | `openai` | Provider for `agent-langgraph`: `openai`, `anthropic`, or `google`. | +| `BOT_PROVIDER` | `openai` | Provider the framework Bot (`agent-langgraph`) and the picked harness run on: `openai`, `anthropic`, or `google`. The Python Bots read it too; `agent-bot` does not, it is OpenAI only. | | `ANTHROPIC_API_KEY` | unset | Anthropic key when `BOT_PROVIDER=anthropic`. | | `ANTHROPIC_BASE_URL` | unset | Anthropic-compatible endpoint that key is spent against. | | `GOOGLE_API_KEY` | unset | Google key when `BOT_PROVIDER=google`. | diff --git a/shared/model-providers.test.ts b/shared/model-providers.test.ts new file mode 100644 index 000000000..fc2acdc27 --- /dev/null +++ b/shared/model-providers.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test"; +import { + MODEL_PROVIDERS, + apiKeyOrPlaceholder, + baseUrlVariableFor, + configuredModel, + defaultModelFor, + keyIsRequired, + keyVariableFor, + providerSpec, + requiresResponsesApi, +} from "./model-providers"; + +/** + * The table is the contract, so its rows are written down. + * + * Every one of these values is read out of a `process.env` name or sent to a provider as a model + * name; changing one silently retargets a running Bot. They are pinned here because they were the + * thing that drifted — five files, five default OpenAI models — and a registry that can drift the + * same way inside its own file has only moved the bug. + */ +describe("the provider registry", () => { + test("each provider names the variable its key arrives in", () => { + expect(MODEL_PROVIDERS.openai.keyVariable).toBe("OPENAI_API_KEY"); + expect(MODEL_PROVIDERS.anthropic.keyVariable).toBe("ANTHROPIC_API_KEY"); + expect(MODEL_PROVIDERS.google.keyVariable).toBe("GOOGLE_API_KEY"); + }); + + test("each provider names the variable its endpoint override arrives in", () => { + expect(MODEL_PROVIDERS.openai.baseUrlVariable).toBe("OPENAI_BASE_URL"); + expect(MODEL_PROVIDERS.anthropic.baseUrlVariable).toBe( + "ANTHROPIC_BASE_URL", + ); + expect(MODEL_PROVIDERS.google.baseUrlVariable).toBe( + "GOOGLE_GENERATIVE_AI_BASE_URL", + ); + }); + + test("each provider has a model to run when none was configured", () => { + expect(MODEL_PROVIDERS.openai.defaultModel).toBe("gpt-5.5"); + expect(MODEL_PROVIDERS.anthropic.defaultModel).toBe("claude-sonnet-4-5"); + expect(MODEL_PROVIDERS.google.defaultModel).toBe("gemini-2.5-flash"); + }); + + test("each provider has a name to be refused by", () => { + expect(MODEL_PROVIDERS.openai.label).toBe("OpenAI"); + expect(MODEL_PROVIDERS.anthropic.label).toBe("Anthropic"); + expect(MODEL_PROVIDERS.google.label).toBe("Google"); + }); +}); + +/** Which provider was meant, and whether a name nobody has heard of is said out loud. */ +describe("which provider was configured", () => { + test("the three names the Bots accept resolve", () => { + expect(providerSpec("openai")?.id).toBe("openai"); + expect(providerSpec("anthropic")?.id).toBe("anthropic"); + expect(providerSpec("google")?.id).toBe("google"); + }); + + /** + * Blank means OpenAI everywhere else that reads `BOT_PROVIDER`: the desktop writes an empty + * provider when switching back to OpenAI, the server reads empty as OpenAI. + */ + test("no provider named means OpenAI", () => { + expect(providerSpec(undefined)?.id).toBe("openai"); + expect(providerSpec("")?.id).toBe("openai"); + expect(providerSpec(" ")?.id).toBe("openai"); + }); + + /** Padded and differently-cased names are the same provider, the way `BOT_MODEL` is trimmed. */ + test("casing and padding are not a different provider", () => { + expect(providerSpec(" OpenAI ")?.id).toBe("openai"); + expect(providerSpec("ANTHROPIC")?.id).toBe("anthropic"); + expect(providerSpec("Google")?.id).toBe("google"); + }); + + /** + * Answering "openai" for a name nobody has heard of is how a Bot silently falls into the OpenAI + * branch; an unknown provider has to stay unknown so the Bot can name what went wrong. + */ + test("a provider nobody has heard of does not become OpenAI", () => { + expect(providerSpec("mistral")).toBeUndefined(); + expect(providerSpec("open-ai")).toBeUndefined(); + expect(keyVariableFor("mistral")).toBeUndefined(); + expect(baseUrlVariableFor("mistral")).toBeUndefined(); + }); + + test("the key and endpoint variables come from the row", () => { + expect(keyVariableFor("anthropic")).toBe("ANTHROPIC_API_KEY"); + expect(baseUrlVariableFor("google")).toBe("GOOGLE_GENERATIVE_AI_BASE_URL"); + expect(keyVariableFor(undefined)).toBe("OPENAI_API_KEY"); + }); +}); + +/** + * A named endpoint is a model, and its key belongs to it. + * + * The failure this pins: the setup window's "any OpenAI-compatible endpoint" row takes an address + * with no key, because Ollama and vLLM have none. The Bots then refused to start, saying + * OPENAI_API_KEY was not set, so the whole keyless half of that feature produced a dead container + * and a red line on the last screen about a key the person's own server does not have. + */ +describe("whether a model key is required", () => { + test("plain OpenAI still needs its key", () => { + expect(keyIsRequired("openai", undefined)).toBe(true); + expect(keyIsRequired("openai", "")).toBe(true); + expect(keyIsRequired("openai", " ")).toBe(true); + expect(keyIsRequired(undefined, undefined)).toBe(true); + expect(keyIsRequired("", "")).toBe(true); + }); + + test("an endpoint named instead of OpenAI answers without one", () => { + expect(keyIsRequired("openai", "http://127.0.0.1:11434/v1")).toBe(false); + expect(keyIsRequired(undefined, "http://127.0.0.1:11434/v1")).toBe(false); + }); + + /** Neither of the other providers has a base URL to be named by, so neither changes. */ + test("anthropic and google are unchanged", () => { + expect(keyIsRequired("anthropic", "http://127.0.0.1:11434/v1")).toBe(true); + expect(keyIsRequired("google", "http://127.0.0.1:11434/v1")).toBe(true); + }); + + /** The SDK cannot be constructed with an empty string, so there is always something to pass. */ + test("the SDK is always handed a string", () => { + expect(apiKeyOrPlaceholder(undefined)).toBe("no-key-needed"); + expect(apiKeyOrPlaceholder(" ")).toBe("no-key-needed"); + expect(apiKeyOrPlaceholder("sk-real")).toBe("sk-real"); + }); +}); + +/** + * Which model a Bot runs: what was configured, or its provider's default when it was not. + * + * Blank is not configured — a compose file passing `BOT_MODEL: ${BOT_MODEL:-}` hands an empty + * string, and sending that on would ask a provider for a model named "". + */ +describe("which model this Bot was told to use", () => { + test("an unset or empty choice falls back to the provider's default", () => { + expect(configuredModel("openai", undefined)).toBe("gpt-5.5"); + expect(configuredModel("openai", "")).toBe("gpt-5.5"); + expect(configuredModel("openai", " ")).toBe("gpt-5.5"); + expect(configuredModel("anthropic", undefined)).toBe("claude-sonnet-4-5"); + expect(configuredModel("google", "")).toBe("gemini-2.5-flash"); + }); + + /** An unknown provider has no default of its own; the Bot refuses it on its own a line later. */ + test("an unknown provider still has a model to be refused with", () => { + expect(defaultModelFor("mistral")).toBe("gpt-5.5"); + expect(configuredModel("mistral", undefined)).toBe("gpt-5.5"); + }); + + test("a padded name is the name", () => { + expect(configuredModel("openai", " gpt-5.5 ")).toBe("gpt-5.5"); + expect(configuredModel("anthropic", "claude-sonnet-4-5")).toBe( + "claude-sonnet-4-5", + ); + }); + + /** A configured model wins over the provider's default; that is the whole point of configuring. */ + test("a configured model is run, whatever the default says", () => { + expect(configuredModel("openai", "gpt-4o-mini")).toBe("gpt-4o-mini"); + expect(configuredModel("anthropic", "claude-3-5-haiku")).toBe( + "claude-3-5-haiku", + ); + }); +}); + +/** + * The models that cannot be driven through chat completions, and the ones that can. + * + * `gpt-5.6-*` rejects function tools on `/v1/chat/completions`; which question a Bot asks of + * this predicate — refuse the model, or turn on the Responses API — belongs to the Bot. + */ +describe("whether a model has to be driven through the Responses API", () => { + test("the models that reject tools on chat completions are flagged", () => { + expect(requiresResponsesApi("gpt-5.6-terra")).toBe(true); + expect(requiresResponsesApi("gpt-6")).toBe(true); + expect(requiresResponsesApi("gpt-5.5")).toBe(false); + }); + + test("padding does not get one past the guard", () => { + expect( + requiresResponsesApi(configuredModel("openai", " gpt-5.6-terra")), + ).toBe(true); + expect(requiresResponsesApi(configuredModel("openai", "\tgpt-6 "))).toBe( + true, + ); + }); + + test("other providers' models are not OpenAI's problem", () => { + expect(requiresResponsesApi("claude-sonnet-4-5")).toBe(false); + expect(requiresResponsesApi("gemini-2.5-flash")).toBe(false); + }); +}); diff --git a/shared/model-providers.ts b/shared/model-providers.ts new file mode 100644 index 000000000..48781f18a --- /dev/null +++ b/shared/model-providers.ts @@ -0,0 +1,164 @@ +/** + * The facts every Bot shares about the model providers it may be pointed at. + * + * One entry per provider, rather than the same default model, key variable and base URL variable + * written out in each Bot. They were written out in each Bot, and they drifted: five files, five + * different default OpenAI models, the first time somebody changed one of them in one place only. + * A provider is added here; the Bots that speak its SDK take their names from this table. + * + * Facts only. Which provider a Bot can DRIVE is that Bot's own decision, beside the code that + * loads its SDK: this module knows Google exists and what its key is called, and `agent-mastra` + * still refuses it because it loads no Google module. Keeping the two apart is what lets a new + * provider be registered here without pretending every harness can answer for it. + * + * Its own module for the reason `model-key.ts` had to be one: `agent-bot` and `agent-langgraph` + * call `serve()` at module scope, so importing a pure function from an entry point binds a port. + */ + +/** The providers this deployment knows the names of. Adding one is adding one entry below. */ +export type ModelProviderId = "openai" | "anthropic" | "google"; + +export type ProviderSpec = { + readonly id: ModelProviderId; + /** How the provider is named in an error message. */ + readonly label: string; + /** The environment variable its API key arrives in. */ + readonly keyVariable: string; + /** The environment variable an endpoint override for it arrives in. */ + readonly baseUrlVariable: string; + /** What a Bot uses when it is told a provider and no model. */ + readonly defaultModel: string; +}; + +export const MODEL_PROVIDERS: Record = { + openai: { + id: "openai", + label: "OpenAI", + keyVariable: "OPENAI_API_KEY", + baseUrlVariable: "OPENAI_BASE_URL", + defaultModel: "gpt-5.5", + }, + anthropic: { + id: "anthropic", + label: "Anthropic", + keyVariable: "ANTHROPIC_API_KEY", + baseUrlVariable: "ANTHROPIC_BASE_URL", + defaultModel: "claude-sonnet-4-5", + }, + google: { + id: "google", + label: "Google", + keyVariable: "GOOGLE_API_KEY", + baseUrlVariable: "GOOGLE_GENERATIVE_AI_BASE_URL", + defaultModel: "gemini-2.5-flash", + }, +}; + +/** + * The provider somebody configured, or nothing if they named one nobody has heard of. + * + * Blank means OpenAI, because that is what every other reader of `BOT_PROVIDER` already decided: + * the desktop writes an empty provider when switching back to OpenAI, the server reads empty as + * OpenAI, and a compose file passing `${BOT_PROVIDER:-}` hands the variable an empty string rather + * than no variable at all. A Bot that refused that would disagree with the screen that configured + * it. + * + * Padded and differently-cased names are the same provider, for the same reason `BOT_MODEL` is + * trimmed before it is used: a value somebody typed into a setup window arrives with a space on + * it more often than not. A Bot that has to report which provider was named gets `undefined` here + * and shows the raw value in its own message. + */ +export function providerSpec( + provider: string | undefined, +): ProviderSpec | undefined { + const normalized = provider?.trim().toLowerCase() || "openai"; + return MODEL_PROVIDERS[normalized as ModelProviderId]; +} + +/** The environment variable this provider's key arrives in, or nothing for a provider unknown. */ +export function keyVariableFor( + provider: string | undefined, +): string | undefined { + return providerSpec(provider)?.keyVariable; +} + +/** The environment variable an endpoint override for this provider arrives in. */ +export function baseUrlVariableFor( + provider: string | undefined, +): string | undefined { + return providerSpec(provider)?.baseUrlVariable; +} + +/** + * What this Bot runs when it was told a provider and no usable model. + * + * An unknown provider falls back to the OpenAI default rather than refusing here, because the + * Bots validate the provider themselves and their error message is the one that should name what + * went wrong. This function is asked before that check runs. + */ +export function defaultModelFor(provider: string | undefined): string { + return ( + providerSpec(provider)?.defaultModel ?? MODEL_PROVIDERS.openai.defaultModel + ); +} + +/** + * The model this Bot runs: what was configured, or the provider's default when it was not. + * + * Blank is not configured. A compose file passing `BOT_MODEL: ${BOT_MODEL:-}` hands this an empty + * string, and a Bot that sent it on would ask its provider for a model named "" and die with + * "you must provide a model parameter", which reads as a broken Bot rather than as missing + * configuration. + */ +export function configuredModel( + provider: string | undefined, + configured: string | undefined, +): string { + return configured?.trim() || defaultModelFor(provider); +} + +/** + * Whether this model has to be driven through the Responses API. + * + * `gpt-5.6-*` rejects function tools on `/v1/chat/completions` — "To use function tools, use + * /v1/responses or set reasoning_effort to 'none'" — so a Bot that speaks chat completions by hand + * cannot use it, and a Bot whose integration offers the Responses API has to turn it on. The same + * predicate answers both questions; which question is asked belongs to the Bot. + * + * Named for the fact rather than for either consequence, because `agent-bot` uses it to refuse a + * model and `agent-langgraph` uses it to select a flag, and neither name fits both. + */ +export function requiresResponsesApi(model: string): boolean { + return /^gpt-5\.[6-9]|^gpt-[6-9]/.test(model); +} + +/** + * Whether this Bot must hold this provider's key before it can start. + * + * Not when an endpoint was named to answer instead. `OPENAI_BASE_URL` set means any endpoint + * speaking that API, and Ollama, vLLM, LM Studio and llama.cpp all serve it with no key. The + * setup window offers exactly those by name and accepts a blank key for them, so requiring one + * refused the whole keyless half of that feature: somebody filled in an address, the app raised + * the Bot, and it exited on startup complaining about a key their endpoint does not have. + * + * Only OpenAI has a base URL that means "somebody else's server"; Anthropic and Google are asked + * with their own variable so the rule can grow without a second signature. + */ +export function keyIsRequired( + provider: string | undefined, + baseUrl: string | undefined, +): boolean { + const named = + providerSpec(provider)?.id === "openai" && Boolean(baseUrl?.trim()); + return !named; +} + +/** + * What to hand an SDK that insists on a string even when the endpoint ignores it. + * + * A placeholder rather than an empty string: empty is a client that cannot be constructed, and + * the value is never sent anywhere that reads it when the endpoint needs no key. + */ +export function apiKeyOrPlaceholder(apiKey: string | undefined): string { + return apiKey?.trim() || "no-key-needed"; +} diff --git a/tests/compose.test.ts b/tests/compose.test.ts index 96064f9a6..314034286 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -334,13 +334,24 @@ for (const { name, environment, expected } of compatibleEndpointCases) { }); } -test("preserves the framework Bot's other provider endpoints", () => { - const compose = composeFile(); - for (const variable of [ - "ANTHROPIC_BASE_URL", - "GOOGLE_GENERATIVE_AI_BASE_URL", - ]) { - expect(compose).toContain(`${variable}: \${${variable}:-}`); +test("passes every other provider's key and endpoint into the framework Bot and the picked harness", () => { + const config = runComposeConfig({ + ANTHROPIC_API_KEY: "sk-ant-synthetic", + ANTHROPIC_BASE_URL: "https://anthropic-gateway.example", + GOOGLE_API_KEY: "synthetic-google", + GOOGLE_GENERATIVE_AI_BASE_URL: "https://generativelanguage.example/v1beta", + }); + + // A service either receives the variable or it does not; reading the file's text cannot tell + // the difference, because any one service declaring it would satisfy a raw `toContain`. + for (const service of ["agent-langgraph", "agent-harness"]) { + expect(config.services[service].environment).toMatchObject({ + ANTHROPIC_API_KEY: "sk-ant-synthetic", + ANTHROPIC_BASE_URL: "https://anthropic-gateway.example", + GOOGLE_API_KEY: "synthetic-google", + GOOGLE_GENERATIVE_AI_BASE_URL: + "https://generativelanguage.example/v1beta", + }); } }); From c24d1399289cf231c6a7451ac8888315b762ecb1 Mon Sep 17 00:00:00 2001 From: Hieuej147 Date: Sun, 27 Sep 2026 16:05:55 +0700 Subject: [PATCH 2/6] feat: keep every Bot's provider defaults in one spec file 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. --- CHANGELOG.md | 14 ++ agent-adk/Dockerfile | 1 + agent-adk/src/main.py | 12 +- agent-adk/tests/test_model_spec.py | 37 ++++ agent-ag2/Dockerfile | 1 + agent-ag2/src/main.py | 12 +- agent-ag2/tests/test_model_spec.py | 37 ++++ agent-agno/Dockerfile | 1 + agent-agno/src/main.py | 12 +- agent-agno/tests/test_model_spec.py | 37 ++++ agent-bot/src/index.ts | 10 +- agent-crewai/Dockerfile | 1 + agent-crewai/src/main.py | 12 +- agent-crewai/tests/test_model_spec.py | 37 ++++ agent-langgraph-agui/Dockerfile | 1 + agent-langgraph-agui/src/main.py | 16 +- agent-langgraph-agui/tests/test_model_spec.py | 37 ++++ agent-langgraph/src/index.ts | 24 +-- agent-langroid/Dockerfile | 1 + agent-langroid/src/main.py | 12 +- agent-langroid/tests/test_model_spec.py | 37 ++++ agent-llamaindex/Dockerfile | 1 + agent-llamaindex/src/main.py | 12 +- agent-llamaindex/tests/test_model_spec.py | 37 ++++ agent-mastra/Dockerfile | 1 + agent-mastra/src/mastra/index.ts | 20 +- agent-microsoft/Dockerfile | 1 + agent-microsoft/src/main.py | 12 +- agent-microsoft/tests/test_model_spec.py | 37 ++++ agent-pydantic-ai/Dockerfile | 1 + agent-pydantic-ai/src/main.py | 12 +- agent-pydantic-ai/tests/test_model_spec.py | 37 ++++ agent-strands/Dockerfile | 1 + agent-strands/src/main.py | 12 +- agent-strands/tests/test_model_spec.py | 37 ++++ docker-compose.yml | 10 +- docs/configuration.md | 46 +++- shared/model-providers.json | 38 ++++ shared/model-providers.test.ts | 127 +++++++++++ shared/model-providers.ts | 201 +++++++++++++++--- shared/model_providers.py | 157 ++++++++++++++ 41 files changed, 1079 insertions(+), 73 deletions(-) create mode 100644 agent-adk/tests/test_model_spec.py create mode 100644 agent-ag2/tests/test_model_spec.py create mode 100644 agent-agno/tests/test_model_spec.py create mode 100644 agent-crewai/tests/test_model_spec.py create mode 100644 agent-langgraph-agui/tests/test_model_spec.py create mode 100644 agent-langroid/tests/test_model_spec.py create mode 100644 agent-llamaindex/tests/test_model_spec.py create mode 100644 agent-microsoft/tests/test_model_spec.py create mode 100644 agent-pydantic-ai/tests/test_model_spec.py create mode 100644 agent-strands/tests/test_model_spec.py create mode 100644 shared/model-providers.json create mode 100644 shared/model_providers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 234ea0106..4611edee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ own copies. On OpenAI they all default to `gpt-5.5` — the Mastra Bot previousl in Compose now receives `GOOGLE_API_KEY` and `GOOGLE_GENERATIVE_AI_BASE_URL` as well, so a harness picked on `BOT_PROVIDER=google` has the key it needs. +### One spec file, in every language + +`shared/model-providers.json` now holds the provider facts and every Bot's default provider and +model. The TypeScript Bots read it through `shared/model-providers.ts` and the ten Python Bots +through `shared/model_providers.py`, with `BOT_PROVIDER` and `BOT_MODEL` still winning over both +as they always have — the defaults themselves are unchanged. 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. A row naming a provider the file does not know stops the Bot at startup +with the key that is wrong, rather than at its first model call. Compose used to substitute +`gpt-5.5` for `agent-langgraph` whenever `BOT_MODEL` was unset, whatever `BOT_PROVIDER` named; it +now passes the unset value through, so the Bot's row — or the moved provider's default row — is +what answers. An OpenAI deployment keeps the same `gpt-5.5` either way; a Google or Anthropic one +stops being handed a model its vendor has never heard of. + ### Dictate messages and talk to a coworker in a live voice call Deployments can configure transcription separately from their Bots' models, with a waveform composer diff --git a/agent-adk/Dockerfile b/agent-adk/Dockerfile index 635be0137..fb990dbb8 100644 --- a/agent-adk/Dockerfile +++ b/agent-adk/Dockerfile @@ -9,6 +9,7 @@ COPY agent-adk/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-adk/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4208 EXPOSE 4208 diff --git a/agent-adk/src/main.py b/agent-adk/src/main.py index eb6436a7e..82a919567 100644 --- a/agent-adk/src/main.py +++ b/agent-adk/src/main.py @@ -5,6 +5,13 @@ """ import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings from ag_ui_adk import ADKAgent, AGUIToolset, add_adk_fastapi_endpoint from fastapi import FastAPI, Request @@ -26,8 +33,9 @@ def _model_id() -> str: and litellm then either routed to a provider nobody configured (`LLM Provider NOT provided`) or sent only the second half to the endpoint. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + settings = bot_settings("agent-adk") + provider = settings.provider + model = settings.model return f"{provider}/{model}" diff --git a/agent-adk/tests/test_model_spec.py b/agent-adk/tests/test_model_spec.py new file mode 100644 index 000000000..0a54e5cc5 --- /dev/null +++ b/agent-adk/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-adk` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-adk" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-adk")' in source diff --git a/agent-ag2/Dockerfile b/agent-ag2/Dockerfile index de95c3aac..df952e81a 100644 --- a/agent-ag2/Dockerfile +++ b/agent-ag2/Dockerfile @@ -9,6 +9,7 @@ COPY agent-ag2/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-ag2/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4210 EXPOSE 4210 diff --git a/agent-ag2/src/main.py b/agent-ag2/src/main.py index 5828e4ab9..26d8ecdb7 100644 --- a/agent-ag2/src/main.py +++ b/agent-ag2/src/main.py @@ -1,6 +1,13 @@ """AG2 as a Bot. AG-UI is an extra in AG2's own package, so `ag2[ag-ui]` is the dependency.""" import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings from ag2 import Agent from ag2.ag_ui import AGUIStream @@ -17,8 +24,9 @@ def _config() -> AnthropicConfig | OpenAIConfig: `BOT_PROVIDER` is `anthropic` for an Anthropic key and `openai` otherwise, an OpenAI-compatible endpoint included. Each SDK reads its own key from the environment. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + settings = bot_settings("agent-ag2") + provider = settings.provider + model = settings.model if provider == "anthropic": # Compose exports missing overrides as ""; the SDK only defaults an absent URL. base_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip() or "https://api.anthropic.com" diff --git a/agent-ag2/tests/test_model_spec.py b/agent-ag2/tests/test_model_spec.py new file mode 100644 index 000000000..00aa3a0d0 --- /dev/null +++ b/agent-ag2/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-ag2` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-ag2" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-ag2")' in source diff --git a/agent-agno/Dockerfile b/agent-agno/Dockerfile index 6a790e9ad..9e97f4343 100644 --- a/agent-agno/Dockerfile +++ b/agent-agno/Dockerfile @@ -9,6 +9,7 @@ COPY agent-agno/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-agno/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4203 EXPOSE 4203 diff --git a/agent-agno/src/main.py b/agent-agno/src/main.py index ed8b6e10d..3a545b049 100644 --- a/agent-agno/src/main.py +++ b/agent-agno/src/main.py @@ -6,6 +6,13 @@ """ import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings from agno.agent import Agent from agno.db.in_memory import InMemoryDb @@ -29,8 +36,9 @@ def _model_id() -> str: and litellm then either routed to a provider nobody configured (`LLM Provider NOT provided`) or sent only the second half to the endpoint. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-5.5").strip() + settings = bot_settings("agent-agno") + provider = settings.provider + model = settings.model return f"{provider}/{model}" diff --git a/agent-agno/tests/test_model_spec.py b/agent-agno/tests/test_model_spec.py new file mode 100644 index 000000000..df4ce3615 --- /dev/null +++ b/agent-agno/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-agno` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-agno" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-5.5") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-5.5") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-agno")' in source diff --git a/agent-bot/src/index.ts b/agent-bot/src/index.ts index 711ed1a14..4b22a8680 100644 --- a/agent-bot/src/index.ts +++ b/agent-bot/src/index.ts @@ -6,7 +6,7 @@ import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { listenPort } from "../../shared/listen-port"; import { apiKeyOrPlaceholder, - configuredModel, + botSettings, keyIsRequired, requiresResponsesApi, } from "../../shared/model-providers"; @@ -40,14 +40,18 @@ if (!MANAGED_AGENT_TOKEN) { * Which model drives the Bot. * * This Bot speaks one provider's API by hand, so the provider is this file's and only the model is - * configurable; the default is that provider's row in the shared registry. + * configurable. What the default is, and which file every language in the box reads it from, is + * `shared/model-providers.json`: this Bot's `bots.agent-bot` row, under `BOT_MODEL` when a + * deployment sets one. The provider is pinned to `openai` rather than read from the environment — + * this file has never read `BOT_PROVIDER`, and a Bot that answers on chat completions by hand + * cannot start answering somewhere else because a variable changed. * * `gpt-5.5` works through `/v1/chat/completions`, which is the API this file uses. * * `gpt-5.6-*` models require the Responses API for tool use and cannot be used by this * chat-completions streaming loop. */ -const MODEL = configuredModel("openai", process.env.BOT_MODEL); +const MODEL = botSettings("agent-bot", process.env, "openai").model; /* * Refuse a model this file cannot use, rather than discover it one tool call at a time — the * failure `requiresResponsesApi` names, asked as a question about this Bot rather than about the diff --git a/agent-crewai/Dockerfile b/agent-crewai/Dockerfile index df4a07d3b..e2672f4f8 100644 --- a/agent-crewai/Dockerfile +++ b/agent-crewai/Dockerfile @@ -9,6 +9,7 @@ COPY agent-crewai/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-crewai/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4202 EXPOSE 4202 diff --git a/agent-crewai/src/main.py b/agent-crewai/src/main.py index ed62d7003..1e31ff12e 100644 --- a/agent-crewai/src/main.py +++ b/agent-crewai/src/main.py @@ -10,10 +10,17 @@ """ import os +import sys from collections.abc import Mapping from copy import deepcopy +from pathlib import Path from typing import Any +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings + import ag_ui_crewai.endpoint as crewai_endpoint from ag_ui.core import Message, Tool from ag_ui_crewai import add_crewai_flow_fastapi_endpoint @@ -46,8 +53,9 @@ def _model() -> str: and litellm then either routed to a provider nobody configured (`LLM Provider NOT provided`) or sent only the second half to the endpoint. """ - provider = (os.environ.get("BOT_PROVIDER") or "").strip() or "openai" - model = (os.environ.get("BOT_MODEL") or "").strip() or "gpt-5.5" + settings = bot_settings("agent-crewai") + provider = settings.provider + model = settings.model return f"{provider}/{model}" diff --git a/agent-crewai/tests/test_model_spec.py b/agent-crewai/tests/test_model_spec.py new file mode 100644 index 000000000..74c43fe9e --- /dev/null +++ b/agent-crewai/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-crewai` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-crewai" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-5.5") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-5.5") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-crewai")' in source diff --git a/agent-langgraph-agui/Dockerfile b/agent-langgraph-agui/Dockerfile index 655796cd1..989639ec0 100644 --- a/agent-langgraph-agui/Dockerfile +++ b/agent-langgraph-agui/Dockerfile @@ -9,6 +9,7 @@ COPY agent-langgraph-agui/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-langgraph-agui/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4206 EXPOSE 4206 diff --git a/agent-langgraph-agui/src/main.py b/agent-langgraph-agui/src/main.py index 7b38134a3..93a9dd52b 100644 --- a/agent-langgraph-agui/src/main.py +++ b/agent-langgraph-agui/src/main.py @@ -6,8 +6,14 @@ """ import os +import sys from pathlib import Path +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings + from ag_ui_langgraph import add_langgraph_fastapi_endpoint from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -116,7 +122,13 @@ def _model(): and the selected provider is passed separately, including when that ID contains a colon. """ configured_model = os.environ.get("BOT_MODEL") - model = (configured_model or "gpt-4o-mini").strip() + # Two readings of one file: `settings` is the environment over the file, for the provider + # below; `file_row` is the file alone, because the signed-in ChatGPT branch above returns + # before any provider is consulted and runs whatever this Bot's row says whatever the + # environment claims the provider to be. + settings = bot_settings("agent-langgraph-agui") + file_row = bot_settings("agent-langgraph-agui", {}) + model = (configured_model or "").strip() or file_row.model store = (os.environ.get("CHATGPT_AUTH_FILE") or "").strip() if store: store_path = _chatgpt_auth_file(store) @@ -138,7 +150,7 @@ def _model(): ) _normalize_openai_base_url() - provider = (os.environ.get("BOT_PROVIDER") or "").strip() or "openai" + provider = settings.provider provider = _resolve_provider(provider) if not configured_model: model = { diff --git a/agent-langgraph-agui/tests/test_model_spec.py b/agent-langgraph-agui/tests/test_model_spec.py new file mode 100644 index 000000000..a6e9d3c1d --- /dev/null +++ b/agent-langgraph-agui/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-langgraph-agui` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-langgraph-agui" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-langgraph-agui")' in source diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index 219ff0e64..945cf9777 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -16,7 +16,7 @@ import { listenPort } from "../../shared/listen-port"; import { apiKeyOrPlaceholder, baseUrlVariableFor, - configuredModel, + botSettings, keyIsRequired, keyVariableFor, requiresResponsesApi, @@ -72,20 +72,20 @@ if (!MANAGED_AGENT_TOKEN) { * Each provider reads its own key. A deployment that only runs Anthropic never needs an OpenAI key, * which is the point of making this configurable rather than assuming one vendor. * - * The default is unchanged so the two shipped Bots stay comparable out of the box. Which default - * that is, and which variable each provider's key and endpoint arrive in, are read from the shared - * provider registry rather than repeated here. + * The default comes from `shared/model-providers.json` — this Bot's `bots.agent-langgraph` row — + * so the two shipped Bots stay comparable out of the box and every language in the box reads the + * same decision. Which variable each provider's key and endpoint arrive in is read from the same + * file's provider rows rather than repeated here. * * Blank is OpenAI, the reading every other consumer of `BOT_PROVIDER` gives it: the desktop writes - * an empty provider when switching back to OpenAI, and the server reads empty as OpenAI. Padded and - * differently-cased names are the same provider, because a value typed into a setup window arrives - * with a space on it more often than not. A name nobody has heard of is kept, so the check below - * can put it in its message. + * an empty provider when switching back to OpenAI, and the server reads empty as OpenAI. An unset + * environment falls through to the spec row, and when that says openai — as it does today — the + * two readings are the same. Padded and differently-cased names are the same provider, because a + * value typed into a setup window arrives with a space on it more often than not. A name nobody + * has heard of is kept, so the check below can put it in its message. The lookup order is + * environment over spec file over provider default; see `botSettings`. */ -const PROVIDER = - (process.env.BOT_PROVIDER ?? "").trim().toLowerCase() || "openai"; -// An unset model and an empty one are the same thing; see `configuredModel`. -const MODEL = configuredModel(PROVIDER, process.env.BOT_MODEL); +const { provider: PROVIDER, model: MODEL } = botSettings("agent-langgraph"); /** * OpenAI only. Its newer models require the Responses API, which the integration handles. * diff --git a/agent-langroid/Dockerfile b/agent-langroid/Dockerfile index bfa27a1bb..b34f1675d 100644 --- a/agent-langroid/Dockerfile +++ b/agent-langroid/Dockerfile @@ -9,6 +9,7 @@ COPY agent-langroid/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-langroid/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4209 EXPOSE 4209 diff --git a/agent-langroid/src/main.py b/agent-langroid/src/main.py index 58b59c2d2..7dafb4ed3 100644 --- a/agent-langroid/src/main.py +++ b/agent-langroid/src/main.py @@ -1,6 +1,13 @@ """Langroid as a Bot, through `ag-ui-langroid`, which AG-UI maintains.""" import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings from ag_ui_langroid import create_langroid_app from fastapi import Request @@ -19,8 +26,9 @@ def _model_id() -> str: `openai/gpt-4o-mini` is rejected by its OpenAI client as an invalid model id, so the prefix goes on only when the provider is somebody else. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + settings = bot_settings("agent-langroid") + provider = settings.provider + model = settings.model if "/" in model or provider == "openai": return model return f"litellm/{provider}/{model}" diff --git a/agent-langroid/tests/test_model_spec.py b/agent-langroid/tests/test_model_spec.py new file mode 100644 index 000000000..6a2d6ce1c --- /dev/null +++ b/agent-langroid/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-langroid` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-langroid" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-langroid")' in source diff --git a/agent-llamaindex/Dockerfile b/agent-llamaindex/Dockerfile index 84c2212f0..7aca05b66 100644 --- a/agent-llamaindex/Dockerfile +++ b/agent-llamaindex/Dockerfile @@ -9,6 +9,7 @@ COPY agent-llamaindex/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-llamaindex/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4204 EXPOSE 4204 diff --git a/agent-llamaindex/src/main.py b/agent-llamaindex/src/main.py index 4870bcd73..12478fc3c 100644 --- a/agent-llamaindex/src/main.py +++ b/agent-llamaindex/src/main.py @@ -5,6 +5,13 @@ """ import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings import litellm from fastapi import FastAPI, Request @@ -26,8 +33,9 @@ def _model_id() -> str: and litellm then either routed to a provider nobody configured (`LLM Provider NOT provided`) or sent only the second half to the endpoint. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-5.5").strip() + settings = bot_settings("agent-llamaindex") + provider = settings.provider + model = settings.model return f"{provider}/{model}" diff --git a/agent-llamaindex/tests/test_model_spec.py b/agent-llamaindex/tests/test_model_spec.py new file mode 100644 index 000000000..b3ecc171d --- /dev/null +++ b/agent-llamaindex/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-llamaindex` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-llamaindex" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-5.5") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-5.5") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-llamaindex")' in source diff --git a/agent-mastra/Dockerfile b/agent-mastra/Dockerfile index e03a64b94..520663674 100644 --- a/agent-mastra/Dockerfile +++ b/agent-mastra/Dockerfile @@ -8,6 +8,7 @@ RUN bun install COPY shared/listen-port.ts /shared/listen-port.ts COPY shared/model-providers.ts /shared/model-providers.ts +COPY shared/model-providers.json /shared/model-providers.json COPY agent-mastra/src ./src # Built at image time, not at start: `mastra start` runs a bundle, and building on every container diff --git a/agent-mastra/src/mastra/index.ts b/agent-mastra/src/mastra/index.ts index e11d14bfe..f4886b676 100644 --- a/agent-mastra/src/mastra/index.ts +++ b/agent-mastra/src/mastra/index.ts @@ -18,7 +18,7 @@ import { registerApiRoute } from "@mastra/core/server"; import { listenPort } from "../../../shared/listen-port"; import { apiKeyOrPlaceholder, - configuredModel, + botSettings, providerSpec, } from "../../../shared/model-providers"; @@ -26,16 +26,18 @@ import { const SUPPORTED_PROVIDERS = new Set(["openai", "anthropic"]); /** - * The model this Bot answers with, read from the shared provider registry rather than remembered - * in this file. + * The model this Bot answers with, read from the spec file rather than remembered in this file. * - * The registry says which providers exist; this file still decides which of them it can drive, - * because only two SDK modules are loaded here. Both halves refuse at startup: a provider the - * registry has not heard of, and one it has that this harness has no module for, used to fall - * through to the OpenAI branch below and answer with a model the deployment never chose. + * `shared/model-providers.json` holds this Bot's `bots.agent-mastra` row and the provider facts + * around it; `BOT_PROVIDER` and `BOT_MODEL` still win over both, as they always have. The file + * says which providers exist; this file still decides which of them it can drive, because only two + * SDK modules are loaded here. Both halves refuse at startup: a provider the file has not heard of, + * and one it has that this harness has no module for, used to fall through to the OpenAI branch + * below and answer with a model the deployment never chose. */ async function buildModel() { - const providerName = process.env.BOT_PROVIDER?.trim() || "openai"; + const settings = botSettings("agent-mastra"); + const providerName = settings.provider; const spec = providerSpec(providerName); if (!spec) { // What to use is what this harness answers on, which is narrower than the registry. @@ -48,7 +50,7 @@ async function buildModel() { `BOT_PROVIDER=${providerName} names ${spec.label}, and this Bot loads no ${spec.label} module. It answers on OpenAI and Anthropic only.`, ); } - const model = configuredModel(spec.id, process.env.BOT_MODEL); + const model = settings.model; const baseVariable = spec.baseUrlVariable; const baseURL = process.env[baseVariable]?.trim(); // Provider modules create default clients at import, which reject Compose's empty overrides. diff --git a/agent-microsoft/Dockerfile b/agent-microsoft/Dockerfile index 2c4d71c36..a740f7c78 100644 --- a/agent-microsoft/Dockerfile +++ b/agent-microsoft/Dockerfile @@ -9,6 +9,7 @@ COPY agent-microsoft/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-microsoft/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4211 EXPOSE 4211 diff --git a/agent-microsoft/src/main.py b/agent-microsoft/src/main.py index 82af5a934..b957445b6 100644 --- a/agent-microsoft/src/main.py +++ b/agent-microsoft/src/main.py @@ -1,6 +1,13 @@ """Microsoft Agent Framework as a Bot, through `agent-framework-ag-ui`, which Microsoft publishes.""" import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings from agent_framework.anthropic import AnthropicClient from agent_framework.openai import OpenAIChatClient @@ -17,8 +24,9 @@ def _client() -> AnthropicClient | OpenAIChatClient: `BOT_PROVIDER` is `anthropic` for an Anthropic key and `openai` otherwise, an OpenAI-compatible endpoint included. Each client reads its own key from the environment. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + settings = bot_settings("agent-microsoft") + provider = settings.provider + model = settings.model if provider == "anthropic": # Compose exports missing overrides as ""; the SDK only defaults an absent URL. base_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip() or "https://api.anthropic.com" diff --git a/agent-microsoft/tests/test_model_spec.py b/agent-microsoft/tests/test_model_spec.py new file mode 100644 index 000000000..2e775bc47 --- /dev/null +++ b/agent-microsoft/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-microsoft` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-microsoft" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-microsoft")' in source diff --git a/agent-pydantic-ai/Dockerfile b/agent-pydantic-ai/Dockerfile index 6894de471..7cf748647 100644 --- a/agent-pydantic-ai/Dockerfile +++ b/agent-pydantic-ai/Dockerfile @@ -9,6 +9,7 @@ COPY agent-pydantic-ai/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-pydantic-ai/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4205 EXPOSE 4205 diff --git a/agent-pydantic-ai/src/main.py b/agent-pydantic-ai/src/main.py index 28ebe4b2b..007290a08 100644 --- a/agent-pydantic-ai/src/main.py +++ b/agent-pydantic-ai/src/main.py @@ -5,6 +5,13 @@ """ import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -21,8 +28,9 @@ def _model_id() -> str: other colon is part of the model's name: Ollama tags every model with one, as in `llama3.1:8b`, and Pydantic AI read the part before it as a provider, refused an unknown one and started no Bot. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + settings = bot_settings("agent-pydantic-ai") + provider = settings.provider + model = settings.model return model if model.startswith(f"{provider}:") else f"{provider}:{model}" diff --git a/agent-pydantic-ai/tests/test_model_spec.py b/agent-pydantic-ai/tests/test_model_spec.py new file mode 100644 index 000000000..5e6f161b9 --- /dev/null +++ b/agent-pydantic-ai/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-pydantic-ai` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-pydantic-ai" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-pydantic-ai")' in source diff --git a/agent-strands/Dockerfile b/agent-strands/Dockerfile index b0c106685..27ac341d8 100644 --- a/agent-strands/Dockerfile +++ b/agent-strands/Dockerfile @@ -9,6 +9,7 @@ COPY agent-strands/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY agent-strands/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ ENV PORT=4207 EXPOSE 4207 diff --git a/agent-strands/src/main.py b/agent-strands/src/main.py index 46275c23f..d1d2f9eb4 100644 --- a/agent-strands/src/main.py +++ b/agent-strands/src/main.py @@ -1,6 +1,13 @@ """AWS Strands as a Bot, through `ag_ui_strands`, which AG-UI maintains.""" import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import bot_settings from ag_ui_strands import StrandsAgent, add_strands_fastapi_endpoint from fastapi import FastAPI, Request @@ -20,8 +27,9 @@ def _model_id() -> str: and litellm then either routed to a provider nobody configured (`LLM Provider NOT provided`) or sent only the second half to the endpoint. """ - provider = (os.environ.get("BOT_PROVIDER") or "openai").strip() - model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip() + settings = bot_settings("agent-strands") + provider = settings.provider + model = settings.model return f"{provider}/{model}" diff --git a/agent-strands/tests/test_model_spec.py b/agent-strands/tests/test_model_spec.py new file mode 100644 index 000000000..6961425f3 --- /dev/null +++ b/agent-strands/tests/test_model_spec.py @@ -0,0 +1,37 @@ +"""The spec file this Bot reads, pinned to what this repository decided. + +`shared/model-providers.json` is one file every language in the box reads; this test is the row +for `agent-strands` and the order the Python loader applies it in. If the file changes on purpose, this +test changes with it — the review point the file exists to create. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-strands" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_environment_beats_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setenv("BOT_MODEL", "claude-haiku") + assert bot_settings(BOT_ID) == BotSettings(provider="anthropic", model="claude-haiku") + + +def test_blank_environment_falls_through_to_the_file(monkeypatch): + monkeypatch.setenv("BOT_PROVIDER", "") + monkeypatch.setenv("BOT_MODEL", " ") + assert bot_settings(BOT_ID) == BotSettings(provider="openai", model="gpt-4o-mini") + + +def test_this_bots_source_reads_the_spec(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert f'bot_settings("agent-strands")' in source diff --git a/docker-compose.yml b/docker-compose.yml index 257984685..735c08b11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -364,9 +364,13 @@ services: ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-} GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} GOOGLE_GENERATIVE_AI_BASE_URL: ${GOOGLE_GENERATIVE_AI_BASE_URL:-} - # gpt-5.5, to stay comparable with agent-bot above rather than because 5.6 does not work: - # set BOT_MODEL to one and the Responses API is switched on for it automatically. - BOT_MODEL: ${BOT_MODEL:-gpt-5.5} + # Unset, this Bot takes the spec file's row for its model rather than this line + # substituting gpt-5.5 whatever BOT_PROVIDER named — a provider other than OpenAI was + # handed that substitute and then failed at its own vendor on it. The file's OpenAI row is + # the same gpt-5.5 as before, so an OpenAI deployment answers on the model it always did; + # BOT_MODEL still wins when it is set, and a 5.6 tier named there switches the Responses + # API on by itself below. + BOT_MODEL: ${BOT_MODEL:-} BOT_RESPONSES_API: ${BOT_RESPONSES_API:-false} # How hard it thinks, on a model that reasons. Empty leaves the provider's own default. BOT_REASONING_EFFORT: ${BOT_REASONING_EFFORT:-} diff --git a/docs/configuration.md b/docs/configuration.md index 076e78f2b..75b7c9807 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,7 +53,7 @@ at `agent-langgraph` on a laptop. | `ANTHROPIC_BASE_URL` | unset | Anthropic-compatible endpoint that key is spent against. | | `GOOGLE_API_KEY` | unset | Google key when `BOT_PROVIDER=google`. | | `GOOGLE_GENERATIVE_AI_BASE_URL` | unset | Google-compatible endpoint that key is spent against. | -| `BOT_MODEL` | provider default from Bot code/env | Model for the framework Bot (`agent-langgraph`). Provider defaults are `gpt-5.5`, `claude-sonnet-4-5`, and `gemini-2.5-flash`. | +| `BOT_MODEL` | the Bot's row in the spec file | Model for whichever Bot is starting. Unset, it comes from that Bot's row in [the provider spec file](#the-provider-spec-file); the provider fallbacks are `gpt-5.5`, `claude-sonnet-4-5`, and `gemini-2.5-flash`. | | `AGENT_BOT_MODEL` | `gpt-5.5` | Model for the proof-of-concept Bot (`agent-bot`), kept separate because it speaks `/v1/chat/completions` directly and refuses a model it cannot use. | | `BOT_RESPONSES_API` | `false` | Makes `agent-langgraph` use the OpenAI Responses API. | | `BOT_REASONING_EFFORT` | unset (provider default) | OpenAI and the Responses API only: one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. `agent-langgraph` refuses to start on any other value, on a non-`openai` provider, or without the Responses API. | @@ -128,6 +128,50 @@ where the worker runs rather than a fact about the deployment `loadConfig` descr it at the server's own port on a laptop; the Helm chart's routines CronJob points it at the server's in-cluster Service address. +## The provider spec file + +`shared/model-providers.json` is one file, and every language in the box reads it: the TypeScript +Bots through `shared/model-providers.ts`, the Python Bots through `shared/model_providers.py`, and +any other implementation straight as JSON. It has two sections — the facts per provider, and the +provider and model each Bot runs: + +```json +{ + "providers": { + "openai": { + "label": "OpenAI", + "key_variable": "OPENAI_API_KEY", + "base_url_variable": "OPENAI_BASE_URL", + "default_model": "gpt-5.5" + } + }, + "bots": { + "agent-langgraph": { "provider": "openai", "model": "gpt-5.5" }, + "agent-adk": { "provider": "openai", "model": "gpt-4o-mini" } + } +} +``` + +The lookup order for `BOT_PROVIDER` and `BOT_MODEL` is unchanged, and the file sits in the middle +of it: + +1. the environment — how a deployment overrides what the repository decided; +2. the Bot's row in this file — what the repository decided; +3. the provider's `default_model` — what is left when neither says. + +A blank value is read as unset, which is what a compose file passing `${BOT_MODEL:-}` hands a Bot +when nobody chose a model. API keys never appear in the file: they arrive in the environment +under the `key_variable` the provider row names. + +Adding a Bot in any language is adding one `bots` entry — `"agent-java": { "provider": +"anthropic", "model": "claude-sonnet-4-5" }` — after which that Bot resolves its model the same +way everything else does. A row that names a provider no `providers` entry exists for, or leaves a +field empty, stops every Bot at startup with the path of the key that is wrong, rather than at the +first model call. + +`agent-bot` is the one Bot that pins its provider: it speaks `/v1/chat/completions` directly and +has never read `BOT_PROVIDER`, and `bots.agent-bot` supplies only its model. + ## OpenAI-compatible endpoints `OPENAI_BASE_URL` decides where an OpenAI-shaped request is answered. Unset, that is OpenAI. Set, it is any endpoint speaking the same API: a gateway in front of several providers, a proxy, or a model on hardware you control. diff --git a/shared/model-providers.json b/shared/model-providers.json new file mode 100644 index 000000000..eba497cda --- /dev/null +++ b/shared/model-providers.json @@ -0,0 +1,38 @@ +{ + "_readme": "One file every language reads. providers = facts per provider (key variable, endpoint variable, default model). bots = which provider and model each Bot runs when its environment names none; API keys never live here. Environment beats this file: BOT_PROVIDER and BOT_MODEL still win. See docs/configuration.md, 'The provider spec file'.", + "providers": { + "openai": { + "label": "OpenAI", + "key_variable": "OPENAI_API_KEY", + "base_url_variable": "OPENAI_BASE_URL", + "default_model": "gpt-5.5" + }, + "anthropic": { + "label": "Anthropic", + "key_variable": "ANTHROPIC_API_KEY", + "base_url_variable": "ANTHROPIC_BASE_URL", + "default_model": "claude-sonnet-4-5" + }, + "google": { + "label": "Google", + "key_variable": "GOOGLE_API_KEY", + "base_url_variable": "GOOGLE_GENERATIVE_AI_BASE_URL", + "default_model": "gemini-2.5-flash" + } + }, + "bots": { + "agent-bot": { "provider": "openai", "model": "gpt-5.5" }, + "agent-langgraph": { "provider": "openai", "model": "gpt-5.5" }, + "agent-mastra": { "provider": "openai", "model": "gpt-5.5" }, + "agent-adk": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-ag2": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-agno": { "provider": "openai", "model": "gpt-5.5" }, + "agent-crewai": { "provider": "openai", "model": "gpt-5.5" }, + "agent-langgraph-agui": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-langroid": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-llamaindex": { "provider": "openai", "model": "gpt-5.5" }, + "agent-microsoft": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-pydantic-ai": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-strands": { "provider": "openai", "model": "gpt-4o-mini" } + } +} diff --git a/shared/model-providers.test.ts b/shared/model-providers.test.ts index fc2acdc27..090244068 100644 --- a/shared/model-providers.test.ts +++ b/shared/model-providers.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { MODEL_PROVIDERS, + PROVIDER_IDS, apiKeyOrPlaceholder, baseUrlVariableFor, + botSettings, configuredModel, defaultModelFor, keyIsRequired, @@ -192,3 +194,128 @@ describe("whether a model has to be driven through the Responses API", () => { expect(requiresResponsesApi("gemini-2.5-flash")).toBe(false); }); }); + +/** + * The spec file, and the one lookup order it sits in. + * + * `shared/model-providers.json` is the file every language in the box reads; this module is its + * TypeScript loader. These tests pin the order the loader applies — environment, then file, then + * provider default — because that order is what `docs/configuration.md` promises and what a + * developer in any other language is entitled to expect from their own loader too. + */ +describe("what a Bot runs from the spec file", () => { + /** The shipped Bots, each at the row the file holds for it. */ + test("a Bot with an environment that names nothing runs its file row", () => { + expect(botSettings("agent-bot", {})).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + expect(botSettings("agent-langgraph", {})).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + expect(botSettings("agent-mastra", {})).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + // A Bot the file pairs with a different model than the provider's own default: + expect(botSettings("agent-adk", {})).toEqual({ + provider: "openai", + model: "gpt-4o-mini", + }); + expect(botSettings("agent-crewai", {})).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + }); + + /** The environment is how a deployment overrides the repository; it wins over the file. */ + test("the environment beats the file", () => { + expect(botSettings("agent-adk", { BOT_MODEL: "gpt-4.1" })).toEqual({ + provider: "openai", + model: "gpt-4.1", + }); + expect( + botSettings("agent-adk", { + BOT_PROVIDER: "anthropic", + BOT_MODEL: "claude-haiku", + }), + ).toEqual({ provider: "anthropic", model: "claude-haiku" }); + expect( + botSettings("agent-langgraph", { BOT_PROVIDER: " Google " }), + ).toEqual({ provider: "google", model: "gemini-2.5-flash" }); + }); + + /** + * A blank variable is not a choice. Compose hands `BOT_MODEL: ${BOT_MODEL:-}` an empty string + * when nobody picked a model, and the desktop writes an empty provider when switching back to + * OpenAI; both mean "no opinion", and the file answers. + */ + test("a blank environment value falls through to the file", () => { + expect( + botSettings("agent-adk", { BOT_PROVIDER: "", BOT_MODEL: "" }), + ).toEqual({ provider: "openai", model: "gpt-4o-mini" }); + expect(botSettings("agent-adk", { BOT_MODEL: " " })).toEqual({ + provider: "openai", + model: "gpt-4o-mini", + }); + }); + + /** + * The file's model belongs to the file's provider. + * + * A deployment that moved this Bot to Anthropic gets Anthropic's default rather than the + * OpenAI model the file pairs with the provider it left — sending `gpt-4o-mini` to Anthropic + * would be a model name neither side chose. + */ + test("a provider from the environment takes that provider's default", () => { + expect(botSettings("agent-adk", { BOT_PROVIDER: "anthropic" })).toEqual({ + provider: "anthropic", + model: "claude-sonnet-4-5", + }); + }); + + /** + * A pinned provider is a Bot answering to one name only. + * + * `agent-bot` speaks chat completions by hand and has never read `BOT_PROVIDER`; pinning keeps + * that true while the model still comes from the file. + */ + test("a pinned Bot ignores the provider environment it never read", () => { + expect( + botSettings("agent-bot", { BOT_PROVIDER: "anthropic" }, "openai"), + ).toEqual({ provider: "openai", model: "gpt-5.5" }); + expect( + botSettings("agent-bot", { BOT_MODEL: " gpt-4.1 " }, "openai"), + ).toEqual({ provider: "openai", model: "gpt-4.1" }); + }); + + /** A name nobody has heard of is kept, so the Bot's own refusal can put it in the message. */ + test("an unknown provider stays unknown, with a model to be refused with", () => { + expect( + botSettings("agent-langgraph", { BOT_PROVIDER: " Mistral " }), + ).toEqual({ provider: "mistral", model: "gpt-5.5" }); + }); + + /** A Bot wired to the file without a row in it is a mistake made in this repository. */ + test("a Bot with no row in the file is refused at startup", () => { + expect(() => botSettings("agent-java")).toThrow( + /bots has no agent-java entry/, + ); + }); + + /** + * The type and the file cannot drift apart. + * + * `PROVIDER_IDS` is what TypeScript believes; the file is what every other language reads. + * Either missing the other stops the process here rather than in a language with no opinion. + */ + test("the file carries a row for every provider the type names", () => { + for (const id of PROVIDER_IDS) { + expect(MODEL_PROVIDERS[id].id).toBe(id); + } + expect(Object.keys(MODEL_PROVIDERS).sort()).toEqual( + [...PROVIDER_IDS].sort(), + ); + }); +}); diff --git a/shared/model-providers.ts b/shared/model-providers.ts index 48781f18a..9cf1acf61 100644 --- a/shared/model-providers.ts +++ b/shared/model-providers.ts @@ -6,17 +6,39 @@ * different default OpenAI models, the first time somebody changed one of them in one place only. * A provider is added here; the Bots that speak its SDK take their names from this table. * + * THE VALUES LIVE IN `model-providers.json`, beside this file. That file is the contract every + * language in the box reads — TypeScript and Python through the loaders beside it, any other + * language straight as JSON — so a developer adding a Bot in Java adds one entry there and never + * reads this module. What this module adds to the file is types and validation: a row missing a + * field, or a `bots` entry naming a provider nobody has heard of, stops the process at startup + * with the path of the offending key rather than at the first model call. + * + * ENVIRONMENT BEATS FILE. `BOT_PROVIDER` and `BOT_MODEL` still win, exactly as + * `docs/configuration.md` has always described; the file supplies what they leave unset. API keys + * never appear in the file — they arrive in the environment under the `key_variable` the provider + * row names. + * * Facts only. Which provider a Bot can DRIVE is that Bot's own decision, beside the code that * loads its SDK: this module knows Google exists and what its key is called, and `agent-mastra` * still refuses it because it loads no Google module. Keeping the two apart is what lets a new - * provider be registered here without pretending every harness can answer for it. + * provider be registered here without pretending every harness can answer for it. The `bots` + * section records what a Bot runs by default, not what it is able to drive. * * Its own module for the reason `model-key.ts` had to be one: `agent-bot` and `agent-langgraph` * call `serve()` at module scope, so importing a pure function from an entry point binds a port. */ -/** The providers this deployment knows the names of. Adding one is adding one entry below. */ -export type ModelProviderId = "openai" | "anthropic" | "google"; +import specJson from "./model-providers.json"; + +/** + * The providers this deployment knows the names of. + * + * Adding one is adding one entry to `PROVIDER_IDS` and one row to the JSON file; the loader below + * refuses to start if the two disagree in either direction. + */ +export const PROVIDER_IDS = ["openai", "anthropic", "google"] as const; + +export type ModelProviderId = (typeof PROVIDER_IDS)[number]; export type ProviderSpec = { readonly id: ModelProviderId; @@ -30,30 +52,99 @@ export type ProviderSpec = { readonly defaultModel: string; }; -export const MODEL_PROVIDERS: Record = { - openai: { - id: "openai", - label: "OpenAI", - keyVariable: "OPENAI_API_KEY", - baseUrlVariable: "OPENAI_BASE_URL", - defaultModel: "gpt-5.5", - }, - anthropic: { - id: "anthropic", - label: "Anthropic", - keyVariable: "ANTHROPIC_API_KEY", - baseUrlVariable: "ANTHROPIC_BASE_URL", - defaultModel: "claude-sonnet-4-5", - }, - google: { - id: "google", - label: "Google", - keyVariable: "GOOGLE_API_KEY", - baseUrlVariable: "GOOGLE_GENERATIVE_AI_BASE_URL", - defaultModel: "gemini-2.5-flash", - }, +/** A row as `model-providers.json` writes it: snake_case, because every language reads it. */ +type SpecProviderRow = { + readonly label?: unknown; + readonly key_variable?: unknown; + readonly base_url_variable?: unknown; + readonly default_model?: unknown; }; +/** What the file says a Bot runs when its environment names nothing. */ +type SpecBotRow = { + readonly provider?: unknown; + readonly model?: unknown; +}; + +type SpecFile = { + readonly providers?: Readonly>; + readonly bots?: Readonly>; +}; + +function fail(problem: string): never { + throw new Error(`shared/model-providers.json: ${problem}`); +} + +function requireText(value: unknown, where: string): string { + if (typeof value !== "string" || !value.trim()) { + fail(`${where} must be a non-empty string.`); + } + return value; +} + +/** + * The file, checked. Every read below is of a row this function has already seen. + * + * Checked once, at module load, because the failure it reports is a mistake in the repository + * rather than in a deployment: a hand editing the JSON wrong should stop every Bot at startup + * with the key they got wrong, not send one Bot to a model named by a typo. + */ +function readSpec(): { + providers: Record; + bots: Record; +} { + const spec = specJson as SpecFile; + + const providers = {} as Record; + for (const id of PROVIDER_IDS) { + const row = spec.providers?.[id]; + if (!row) fail(`providers is missing its ${id} row.`); + providers[id] = { + id, + label: requireText(row.label, `providers.${id}.label`), + keyVariable: requireText( + row.key_variable, + `providers.${id}.key_variable`, + ), + baseUrlVariable: requireText( + row.base_url_variable, + `providers.${id}.base_url_variable`, + ), + defaultModel: requireText( + row.default_model, + `providers.${id}.default_model`, + ), + }; + } + for (const id of Object.keys(spec.providers ?? {})) { + if (!(PROVIDER_IDS as readonly string[]).includes(id)) { + fail(`providers has a ${id} row this module has never heard of.`); + } + } + + const bots: Record = {}; + for (const [botId, entry] of Object.entries(spec.bots ?? {})) { + if (!entry) fail(`bots.${botId} must be an object.`); + const provider = requireText(entry.provider, `bots.${botId}.provider`); + if (!(PROVIDER_IDS as readonly string[]).includes(provider)) { + fail( + `bots.${botId}.provider is ${JSON.stringify(provider)}, not one of ${PROVIDER_IDS.join(", ")}.`, + ); + } + bots[botId] = { + provider: provider as ModelProviderId, + model: requireText(entry.model, `bots.${botId}.model`), + }; + } + + return { providers, bots }; +} + +const { providers: SPEC_PROVIDERS, bots: BOT_ENTRIES } = readSpec(); + +export const MODEL_PROVIDERS: Record = + SPEC_PROVIDERS; + /** * The provider somebody configured, or nothing if they named one nobody has heard of. * @@ -117,6 +208,66 @@ export function configuredModel( return configured?.trim() || defaultModelFor(provider); } +/** What one Bot was decided to run: its provider, and the model on it. */ +export type BotSettings = { + /** The provider this Bot answers on. */ + readonly provider: string; + /** The model it runs. */ + readonly model: string; +}; + +/** + * This Bot's provider and model: environment over spec file over provider default. + * + * The lookup order is the whole contract, and it is the order `docs/configuration.md` already + * documents for `BOT_PROVIDER` and `BOT_MODEL` — the environment is how a deployment overrides + * what the repository decided, so it wins. What is new is the middle: `model-providers.json`'s + * `bots` entry, the default this Bot had before this function existed, written down in one file + * every language in the box reads instead of repeated in each Bot's source. What is unchanged is + * the last: the provider's own default row, for a provider whose Bot entry the file pairs with + * somebody else. + * + * `pinnedProvider` is for a Bot that drives exactly one provider's API and answers to no other + * name — `agent-bot` speaks chat completions by hand and has never read `BOT_PROVIDER`. Pinning + * keeps that Bot's behavior what it was while its model still comes from the file. + * + * A provider nobody has heard of is kept as typed, not rewritten to OpenAI, so the Bot's own + * refusal can put the name in its message. Its model falls back to the OpenAI default for the + * same reason `defaultModelFor` does it: the refusal is one line below and belongs to the Bot. + * + * A missing `bots` entry throws rather than defaults. The file is in this repository; a Bot wired + * to it without a row is a mistake made while editing it, made at development time, and the + * developer should meet it at startup rather than wonder why every deployment answers on + * gpt-5.5. + */ +export function botSettings( + botId: string, + env: Readonly> = process.env, + pinnedProvider?: string, +): BotSettings { + const entry = BOT_ENTRIES[botId]; + if (!entry) { + fail( + `bots has no ${botId} entry. Add one before this Bot reads the spec file.`, + ); + } + + const provider = + pinnedProvider?.trim() || + (env.BOT_PROVIDER ?? "").trim().toLowerCase() || + entry.provider; + + // A model the environment names goes to whatever provider was resolved, because an endpoint + // names its own catalogue. The file's model belongs to the file's provider, so a deployment + // that moved this Bot to another provider gets that provider's default instead of a model + // paired with the one it left. + const model = + env.BOT_MODEL?.trim() || + (provider === entry.provider ? entry.model : defaultModelFor(provider)); + + return { provider, model }; +} + /** * Whether this model has to be driven through the Responses API. * diff --git a/shared/model_providers.py b/shared/model_providers.py new file mode 100644 index 000000000..0a7b2b958 --- /dev/null +++ b/shared/model_providers.py @@ -0,0 +1,157 @@ +"""The provider facts every Bot shares, read from `model-providers.json`. + +The file beside this loader is the contract every language in the box reads: TypeScript reads it +through `shared/model-providers.ts`, this module reads it for the Python Bots, and any other +language reads it straight as JSON. A developer adding a Bot in Java adds one `bots` entry to the +file and never reads either loader. + +ENVIRONMENT BEATS FILE, exactly as `docs/configuration.md` has always described: `BOT_PROVIDER` +and `BOT_MODEL` win, and the file supplies what they leave unset. API keys never appear in the +file — they arrive in the environment under the `key_variable` the provider row names. + +A wrong or missing file stops the process at import with the path of the offending key, rather +than at the first model call: the mistakes this guards against are made in this repository, by a +hand editing the JSON, and the person who made it should meet it at startup. + +The lookup here mirrors `botSettings` in `shared/model-providers.ts` line for line — same order, +same blank-is-unset reading, same refusal for a Bot with no row — because the two loaders answering +differently would put the languages of one box on different models, which is the drift this file +exists to end. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +_WHERE = "shared/model_providers.py" + + +def _spec_path() -> Path: + """Where the file sits: beside this loader, unless the environment points elsewhere.""" + override = (os.environ.get("BOT_SPEC_PATH") or "").strip() + if override: + return Path(override) + return Path(__file__).resolve().with_name("model-providers.json") + + +def _require_text(row: Any, field: str, where: str) -> str: + value = row.get(field) if isinstance(row, dict) else None + if not isinstance(value, str) or not value.strip(): + raise RuntimeError(f"shared/model-providers.json: {where} must be a non-empty string.") + return value + + +def _load() -> dict[str, Any]: + path = _spec_path() + if not path.is_file(): + raise FileNotFoundError( + f"shared/model-providers.json not found at {path}. The file sits beside this loader " + "in shared/, or is named by BOT_SPEC_PATH; a Bot without it has no defaults to run." + ) + try: + spec = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise RuntimeError(f"shared/model-providers.json at {path} is not JSON: {error}") from error + if not isinstance(spec, dict): + raise RuntimeError(f"shared/model-providers.json at {path} must hold an object.") + + providers = spec.get("providers") + if not isinstance(providers, dict) or not providers: + raise RuntimeError("shared/model-providers.json: providers must be a non-empty object.") + for provider_id, row in providers.items(): + _require_text(row, "label", f"providers.{provider_id}.label") + _require_text(row, "key_variable", f"providers.{provider_id}.key_variable") + _require_text(row, "base_url_variable", f"providers.{provider_id}.base_url_variable") + _require_text(row, "default_model", f"providers.{provider_id}.default_model") + if "openai" not in providers: + # The same fallback the TypeScript loader makes: an unknown provider runs the OpenAI + # default while the Bot that owns the refusal names what went wrong. + raise RuntimeError("shared/model-providers.json: providers needs its openai row.") + + bots = spec.get("bots") + if not isinstance(bots, dict): + raise RuntimeError("shared/model-providers.json: bots must be an object.") + for bot_id, entry in bots.items(): + if not isinstance(entry, dict): + raise RuntimeError(f"shared/model-providers.json: bots.{bot_id} must be an object.") + provider = _require_text(entry, "provider", f"bots.{bot_id}.provider") + if provider not in providers: + raise RuntimeError( + f"shared/model-providers.json: bots.{bot_id}.provider is {provider!r}, " + f"not one of {', '.join(sorted(providers))}." + ) + _require_text(entry, "model", f"bots.{bot_id}.model") + + return spec + + +SPEC: dict[str, Any] = _load() + + +@dataclass(frozen=True) +class BotSettings: + """What one Bot was decided to run: its provider, and the model on it.""" + + provider: str + model: str + + +def default_model_for(provider: str) -> str: + """A provider's default row, or OpenAI's for a name nobody has heard of.""" + row = SPEC["providers"].get(provider) + if row is None: + row = SPEC["providers"]["openai"] + return row["default_model"] + + +def bot_settings( + bot_id: str, + env: Mapping[str, str] | None = None, + pinned_provider: str | None = None, +) -> BotSettings: + """This Bot's provider and model: environment over spec file over provider default. + + The lookup order is the whole contract, and it is the order `docs/configuration.md` already + documents for `BOT_PROVIDER` and `BOT_MODEL` — the environment is how a deployment overrides + what the repository decided, so it wins. What is new is the middle: the `bots` row this Bot + had before, written down in one file every language reads instead of repeated in each Bot's + source. A blank value is not a choice: compose hands `BOT_MODEL=` an empty string when nobody + picked a model, and empty falls through to the file. + + A provider nobody has heard of is kept as typed, not rewritten to OpenAI, so the Bot's own + refusal can put the name in its message. Its model falls back to the OpenAI default for the + same reason the TypeScript loader does it: the refusal belongs to the Bot, one line below. + + A missing `bots` row throws rather than defaults. The file is in this repository; a Bot wired + to it without a row is a mistake made while editing it, made at development time. + """ + environ = os.environ if env is None else env + + entry = SPEC["bots"].get(bot_id) + if entry is None: + raise RuntimeError( + f"shared/model-providers.json: bots has no {bot_id} entry. " + "Add one before this Bot reads the spec file." + ) + + provider = ( + (pinned_provider or "").strip() + or (environ.get("BOT_PROVIDER") or "").strip().lower() + or entry["provider"] + ) + + # A model the environment names goes to whatever provider was resolved, because an endpoint + # names its own catalogue. The file's model belongs to the file's provider, so a deployment + # that moved this Bot to another provider gets that provider's default instead of a model + # paired with the one it left. + model = (environ.get("BOT_MODEL") or "").strip() + if not model: + model = ( + entry["model"] if provider == entry["provider"] else default_model_for(provider) + ) + + return BotSettings(provider=provider, model=model) From b739bab6b777d81e82d1f016554f93e0ec786b69 Mon Sep 17 00:00:00 2001 From: Hieuej147 Date: Mon, 28 Sep 2026 03:54:40 +0700 Subject: [PATCH 3/6] fix: make the Python loader refuse what the TypeScript loader refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 row., providers has a row this module has never heard of., and bots..provider is "

", 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. --- .../tests/test_spec_validation.py | 99 +++++++++++++++++++ docs/configuration.md | 8 ++ shared/model-providers.json | 2 +- shared/model-providers.ts | 13 ++- shared/model_providers.py | 42 ++++++-- 5 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 agent-langgraph-agui/tests/test_spec_validation.py diff --git a/agent-langgraph-agui/tests/test_spec_validation.py b/agent-langgraph-agui/tests/test_spec_validation.py new file mode 100644 index 000000000..a0265b23a --- /dev/null +++ b/agent-langgraph-agui/tests/test_spec_validation.py @@ -0,0 +1,99 @@ +"""The spec file's validation, exercised the way the TypeScript loader exercises it. + +`shared/model-providers.json` is one file, but `shared/model-providers.ts` and +`shared/model_providers.py` each keep their own list of the providers they have heard of and check +the file against their own list in both directions. This test is the Python half of that. Each case +imports the loader in a subprocess against a file written to `tmp_path`, so a refusal here cannot +leave a half-loaded module behind for the tests beside it, and the words asserted are the words the +TypeScript loader says for the same file. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +SHARED = Path(__file__).resolve().parents[2] / "shared" + +sys.path.insert(0, str(SHARED)) +from model_providers import PROVIDER_IDS + +_PROVIDER_FIELDS = { + "key_variable": "SOME_API_KEY", + "base_url_variable": "SOME_BASE_URL", + "default_model": "some-model", +} + + +def _spec(): + """A file this module accepts, for a test to take one thing out of.""" + return { + "providers": { + provider_id: {**_PROVIDER_FIELDS, "label": provider_id.title()} + for provider_id in PROVIDER_IDS + }, + "bots": {"agent-x": {"provider": "openai", "model": "gpt-5.5"}}, + } + + +def _import(spec, tmp_path, shipped=False): + """Import the loader against `spec`, or against the file in the repository when `shipped`.""" + environment = dict(os.environ) + environment.pop("BOT_SPEC_PATH", None) + if not shipped: + path = tmp_path / "model-providers.json" + path.write_text(json.dumps(spec), encoding="utf-8") + environment["BOT_SPEC_PATH"] = str(path) + environment["PYTHONPATH"] = str(SHARED) + os.pathsep + environment.get("PYTHONPATH", "") + return subprocess.run( + [sys.executable, "-c", "import model_providers"], + cwd=str(tmp_path), + env=environment, + text=True, + capture_output=True, + ) + + +def _refusal(spec, tmp_path): + """The words the loader refused `spec` with.""" + result = _import(spec, tmp_path) + assert result.returncode != 0, "the loader accepted a file it should have refused." + return result.stderr + + +def test_the_file_in_the_repository_is_one_this_module_accepts(tmp_path): + result = _import(None, tmp_path, shipped=True) + assert result.returncode == 0, result.stderr + + declared = json.loads( + (SHARED / "model-providers.json").read_text(encoding="utf-8") + )["providers"] + assert set(declared) == set(PROVIDER_IDS) + + +@pytest.mark.parametrize("missing", PROVIDER_IDS) +def test_a_provider_row_the_list_names_may_not_be_dropped(tmp_path, missing): + spec = _spec() + del spec["providers"][missing] + assert f"providers is missing its {missing} row." in _refusal(spec, tmp_path) + + +def test_a_provider_row_the_list_has_never_heard_of_is_refused(tmp_path): + spec = _spec() + spec["providers"]["mistral"] = {**_PROVIDER_FIELDS, "label": "Mistral"} + assert ( + "providers has a mistral row this module has never heard of." + in _refusal(spec, tmp_path) + ) + + +def test_a_bot_naming_a_provider_the_list_has_never_heard_of_is_refused(tmp_path): + spec = _spec() + spec["bots"]["agent-x"]["provider"] = "mistral" + assert ( + 'bots.agent-x.provider is "mistral", not one of openai, anthropic, google.' + in _refusal(spec, tmp_path) + ) diff --git a/docs/configuration.md b/docs/configuration.md index cd7164b4e..96bfc5b95 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,6 +169,14 @@ way everything else does. A row that names a provider no `providers` entry exist field empty, stops every Bot at startup with the path of the key that is wrong, rather than at the first model call. +Adding a **provider** is three places rather than one: a row under `providers` here, one entry to +`PROVIDER_IDS` in `shared/model-providers.ts`, and one entry to `PROVIDER_IDS` in +`shared/model_providers.py`. Each loader checks this file against its own list in both +directions, so neither the Python Bots nor the TypeScript ones start against a provider row their +own loader has never heard of, nor against a provider their own loader names when the file has no +row for it. Either refusal names the key that is wrong, at startup rather than at the first model +call, in either language. + `agent-bot` is the one Bot that pins its provider: it speaks `/v1/chat/completions` directly and has never read `BOT_PROVIDER`, and `bots.agent-bot` supplies only its model. diff --git a/shared/model-providers.json b/shared/model-providers.json index eba497cda..15c3dc4b8 100644 --- a/shared/model-providers.json +++ b/shared/model-providers.json @@ -1,5 +1,5 @@ { - "_readme": "One file every language reads. providers = facts per provider (key variable, endpoint variable, default model). bots = which provider and model each Bot runs when its environment names none; API keys never live here. Environment beats this file: BOT_PROVIDER and BOT_MODEL still win. See docs/configuration.md, 'The provider spec file'.", + "_readme": "One file every language reads. providers = facts per provider (key variable, endpoint variable, default model). bots = which provider and model each Bot runs when its environment names none; API keys never live here. Environment beats this file: BOT_PROVIDER and BOT_MODEL still win. Adding a provider is one row here plus one entry to PROVIDER_IDS in each of shared/model-providers.ts and shared/model_providers.py: each loader checks this file against its own list in both directions, so a Bot never starts against a provider row its own loader has not heard of. See docs/configuration.md, 'The provider spec file'.", "providers": { "openai": { "label": "OpenAI", diff --git a/shared/model-providers.ts b/shared/model-providers.ts index 9cf1acf61..ab1ca34dd 100644 --- a/shared/model-providers.ts +++ b/shared/model-providers.ts @@ -9,9 +9,10 @@ * THE VALUES LIVE IN `model-providers.json`, beside this file. That file is the contract every * language in the box reads — TypeScript and Python through the loaders beside it, any other * language straight as JSON — so a developer adding a Bot in Java adds one entry there and never - * reads this module. What this module adds to the file is types and validation: a row missing a - * field, or a `bots` entry naming a provider nobody has heard of, stops the process at startup - * with the path of the offending key rather than at the first model call. + * reads this module. What the two loaders add to the file is types and validation: in either + * loader, a row missing a field, a provider row the file lacks, a provider row that loader's list + * has never heard of, or a `bots` entry naming a provider outside that list, stops the process at + * startup with the path of the offending key rather than at the first model call. * * ENVIRONMENT BEATS FILE. `BOT_PROVIDER` and `BOT_MODEL` still win, exactly as * `docs/configuration.md` has always described; the file supplies what they leave unset. API keys @@ -33,8 +34,10 @@ import specJson from "./model-providers.json"; /** * The providers this deployment knows the names of. * - * Adding one is adding one entry to `PROVIDER_IDS` and one row to the JSON file; the loader below - * refuses to start if the two disagree in either direction. + * Adding one is one entry here, one in `PROVIDER_IDS` in `shared/model_providers.py`, and one row + * to the JSON file. Each loader checks the file against its own list in both directions, so a + * provider row this list names and the file lacks, or a row in the file this list has never heard + * of, stops this Bot at startup rather than at its first model call. */ export const PROVIDER_IDS = ["openai", "anthropic", "google"] as const; diff --git a/shared/model_providers.py b/shared/model_providers.py index 0a7b2b958..7345a0b5f 100644 --- a/shared/model_providers.py +++ b/shared/model_providers.py @@ -17,6 +17,12 @@ same blank-is-unset reading, same refusal for a Bot with no row — because the two loaders answering differently would put the languages of one box on different models, which is the drift this file exists to end. + +`PROVIDER_IDS` below is this module's copy of the list `shared/model-providers.ts` keeps for +itself, and each loader checks the file against its own list in both directions at import: a row +this module has never heard of, or a provider this module names that the file has no row for, +stops these Bots at startup rather than at their first model call. Adding a provider is one row in +the JSON file and one entry in each loader. """ from __future__ import annotations @@ -29,6 +35,13 @@ _WHERE = "shared/model_providers.py" +# The providers this deployment knows the names of. The same list `shared/model-providers.ts` +# keeps for itself, written out here rather than read from the file: each loader checking the file +# against its own list is what makes a provider added to the JSON alone stop these Bots too, where +# before this list existed only the TypeScript Bots were stopped by it. Adding a provider is one +# entry here, one in that module, and one row to the JSON file. +PROVIDER_IDS: tuple[str, ...] = ("openai", "anthropic", "google") + def _spec_path() -> Path: """Where the file sits: beside this loader, unless the environment points elsewhere.""" @@ -60,17 +73,26 @@ def _load() -> dict[str, Any]: raise RuntimeError(f"shared/model-providers.json at {path} must hold an object.") providers = spec.get("providers") - if not isinstance(providers, dict) or not providers: - raise RuntimeError("shared/model-providers.json: providers must be a non-empty object.") - for provider_id, row in providers.items(): + if not isinstance(providers, dict): + raise RuntimeError("shared/model-providers.json: providers must be an object.") + # The list's order rather than the file's, so the row an error names first is the row the list + # named first — the same read the TypeScript loader makes. + for provider_id in PROVIDER_IDS: + row = providers.get(provider_id) + if row is None: + raise RuntimeError( + f"shared/model-providers.json: providers is missing its {provider_id} row." + ) _require_text(row, "label", f"providers.{provider_id}.label") _require_text(row, "key_variable", f"providers.{provider_id}.key_variable") _require_text(row, "base_url_variable", f"providers.{provider_id}.base_url_variable") _require_text(row, "default_model", f"providers.{provider_id}.default_model") - if "openai" not in providers: - # The same fallback the TypeScript loader makes: an unknown provider runs the OpenAI - # default while the Bot that owns the refusal names what went wrong. - raise RuntimeError("shared/model-providers.json: providers needs its openai row.") + for provider_id in providers: + if provider_id not in PROVIDER_IDS: + raise RuntimeError( + f"shared/model-providers.json: providers has a {provider_id} row this module " + "has never heard of." + ) bots = spec.get("bots") if not isinstance(bots, dict): @@ -79,10 +101,10 @@ def _load() -> dict[str, Any]: if not isinstance(entry, dict): raise RuntimeError(f"shared/model-providers.json: bots.{bot_id} must be an object.") provider = _require_text(entry, "provider", f"bots.{bot_id}.provider") - if provider not in providers: + if provider not in PROVIDER_IDS: raise RuntimeError( - f"shared/model-providers.json: bots.{bot_id}.provider is {provider!r}, " - f"not one of {', '.join(sorted(providers))}." + f"shared/model-providers.json: bots.{bot_id}.provider is {json.dumps(provider)}, " + f"not one of {', '.join(PROVIDER_IDS)}." ) _require_text(entry, "model", f"bots.{bot_id}.model") From 7eed8a3619804146d178184eeba8ab68ca419f00 Mon Sep 17 00:00:00 2001 From: Hieuej147 Date: Mon, 28 Sep 2026 03:54:48 +0700 Subject: [PATCH 4/6] Say what the Mastra default change costs, and stop the changelog contradicting itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b030746fa..e83cb28fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,26 +10,36 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### The Bots agree on one set of provider defaults +**The Mastra Bot's default on OpenAI moved from `gpt-4o-mini` to `gpt-5.5`.** A deployment running +it with `BOT_MODEL` unset moves model and cost on upgrade; set `BOT_MODEL` to keep what it had. + The three TypeScript Bots now read a single shared list of provider facts instead of keeping their -own copies. On OpenAI they all default to `gpt-5.5` — the Mastra Bot previously defaulted to -`gpt-4o-mini` — and the Mastra Bot refuses a `BOT_PROVIDER` it does not recognize (such as -`google`) instead of quietly answering through OpenAI with a different model. The picked harness -in Compose now receives `GOOGLE_API_KEY` and `GOOGLE_GENERATIVE_AI_BASE_URL` as well, so a harness -picked on `BOT_PROVIDER=google` has the key it needs. +own copies, which is what makes a default changeable in one place rather than in three. The Mastra +Bot also refuses a `BOT_PROVIDER` it does not recognize (such as `google`) instead of quietly +answering through OpenAI with a different model. The picked harness in Compose now receives +`GOOGLE_API_KEY` and `GOOGLE_GENERATIVE_AI_BASE_URL` as well, so a harness picked on +`BOT_PROVIDER=google` has the key it needs. ### One spec file, in every language `shared/model-providers.json` now holds the provider facts and every Bot's default provider and model. The TypeScript Bots read it through `shared/model-providers.ts` and the ten Python Bots through `shared/model_providers.py`, with `BOT_PROVIDER` and `BOT_MODEL` still winning over both -as they always have — the defaults themselves are unchanged. Moving a Bot to a different model, or +as they always have. Reading the file moved no default on its own; the one default that did move, +the Mastra Bot's, is at the top of the previous entry. 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. A row naming a provider the file does not know stops the Bot at startup -with the key that is wrong, rather than at its first model call. Compose used to substitute -`gpt-5.5` for `agent-langgraph` whenever `BOT_MODEL` was unset, whatever `BOT_PROVIDER` named; it -now passes the unset value through, so the Bot's row — or the moved provider's default row — is -what answers. An OpenAI deployment keeps the same `gpt-5.5` either way; a Google or Anthropic one -stops being handed a model its vendor has never heard of. +of one line per language. + +Both loaders check the file against their own list of providers, in both directions, and refuse in +the same words. A wrong row in the file used to stop the three TypeScript Bots while the ten +Python Bots started clean and met it at their first model call instead; all thirteen stop at +startup now, naming the key that is wrong. Adding a provider is one row in the file and one entry +to `PROVIDER_IDS` in each loader. + +Compose used to substitute `gpt-5.5` for `agent-langgraph` whenever `BOT_MODEL` was unset, whatever +`BOT_PROVIDER` named; it now passes the unset value through, so the Bot's row — or the moved +provider's default row — is what answers. An OpenAI deployment keeps the same `gpt-5.5` either way; +a Google or Anthropic one stops being handed a model its vendor has never heard of. ### A hidden coworker can be found again on the Agents screen From 11abc291b1b50baa313962eaa44eb0383f9d2051 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 28 Sep 2026 09:21:41 -0700 Subject: [PATCH 5/6] fix: honor provider spec models without changing Mastra defaults --- CHANGELOG.md | 10 +-- agent-langgraph-agui/src/main.py | 27 +++--- .../tests/test_provider_boundaries.py | 85 +++++++++++++++++++ agent-mastra/src/mastra/index.test.ts | 6 +- docs/configuration.md | 5 ++ shared/model-providers.json | 2 +- shared/model-providers.test.ts | 2 +- 7 files changed, 111 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e83cb28fb..cf87e9472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,6 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### The Bots agree on one set of provider defaults -**The Mastra Bot's default on OpenAI moved from `gpt-4o-mini` to `gpt-5.5`.** A deployment running -it with `BOT_MODEL` unset moves model and cost on upgrade; set `BOT_MODEL` to keep what it had. - The three TypeScript Bots now read a single shared list of provider facts instead of keeping their own copies, which is what makes a default changeable in one place rather than in three. The Mastra Bot also refuses a `BOT_PROVIDER` it does not recognize (such as `google`) instead of quietly @@ -25,10 +22,9 @@ answering through OpenAI with a different model. The picked harness in Compose n `shared/model-providers.json` now holds the provider facts and every Bot's default provider and model. The TypeScript Bots read it through `shared/model-providers.ts` and the ten Python Bots through `shared/model_providers.py`, with `BOT_PROVIDER` and `BOT_MODEL` still winning over both -as they always have. Reading the file moved no default on its own; the one default that did move, -the Mastra Bot's, is at the top of the previous entry. 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. +as they always have. Each Bot keeps its existing default, including Mastra's `gpt-4o-mini`. +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. Both loaders check the file against their own list of providers, in both directions, and refuse in the same words. A wrong row in the file used to stop the three TypeScript Bots while the ten diff --git a/agent-langgraph-agui/src/main.py b/agent-langgraph-agui/src/main.py index 93a9dd52b..349d46f70 100644 --- a/agent-langgraph-agui/src/main.py +++ b/agent-langgraph-agui/src/main.py @@ -122,15 +122,12 @@ def _model(): and the selected provider is passed separately, including when that ID contains a colon. """ configured_model = os.environ.get("BOT_MODEL") - # Two readings of one file: `settings` is the environment over the file, for the provider - # below; `file_row` is the file alone, because the signed-in ChatGPT branch above returns - # before any provider is consulted and runs whatever this Bot's row says whatever the - # environment claims the provider to be. - settings = bot_settings("agent-langgraph-agui") - file_row = bot_settings("agent-langgraph-agui", {}) - model = (configured_model or "").strip() or file_row.model store = (os.environ.get("CHATGPT_AUTH_FILE") or "").strip() if store: + # A signed-in ChatGPT plan ignores BOT_PROVIDER, retaining this Bot's file model unless + # BOT_MODEL overrides it. Other providers use the fully resolved settings below. + file_row = bot_settings("agent-langgraph-agui", {}) + model = (configured_model or "").strip() or file_row.model store_path = _chatgpt_auth_file(store) # Private and experimental, both deliberately. `langchain-openai` exports no public Codex # model and warns in the module that this one is unofficial. That is a maintenance cost we @@ -150,13 +147,15 @@ def _model(): ) _normalize_openai_base_url() - provider = settings.provider - provider = _resolve_provider(provider) - if not configured_model: - model = { - "anthropic": "claude-sonnet-4-5", - "google_genai": "gemini-2.5-flash", - }.get(provider, model) + settings = bot_settings("agent-langgraph-agui") + if settings.provider == "google_genai": + # LangChain's provider spelling is also an existing BOT_PROVIDER choice. Resolve its + # defaults through the spec's Google row before mapping back to the SDK spelling. + settings = bot_settings( + "agent-langgraph-agui", {**os.environ, "BOT_PROVIDER": "google"} + ) + provider = _resolve_provider(settings.provider) + model = settings.model prefix, separator, _ = model.partition(":") if separator and prefix in MODEL_PROVIDERS: return init_chat_model(model, **_google_genai_kwargs(prefix)) diff --git a/agent-langgraph-agui/tests/test_provider_boundaries.py b/agent-langgraph-agui/tests/test_provider_boundaries.py index 5be6fcf8e..a0a824f13 100644 --- a/agent-langgraph-agui/tests/test_provider_boundaries.py +++ b/agent-langgraph-agui/tests/test_provider_boundaries.py @@ -16,6 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from src import main +from model_providers import SPEC _LOOPBACK_SOCKET_GUARD_INSTALLED = False @@ -344,6 +345,60 @@ async def test_anthropic_selection_reaches_anthropic_boundary_without_openai_key ] +@pytest.mark.asyncio +@pytest.mark.parametrize("spec_source", ["bot", "provider"]) +@pytest.mark.parametrize( + ("configured_model", "request_model"), + [ + (None, "claude-spec-model"), + ("", "claude-spec-model"), + (" ", "claude-spec-model"), + (" claude-override ", "claude-override"), + ("anthropic:claude-override", "claude-override"), + ], +) +async def test_spec_model_reaches_anthropic_request( + monkeypatch, spec_source, configured_model, request_model +): + if spec_source == "bot": + monkeypatch.setitem( + SPEC["bots"], + "agent-langgraph-agui", + {"provider": "anthropic", "model": "claude-spec-model"}, + ) + else: + monkeypatch.setenv("BOT_PROVIDER", "anthropic") + monkeypatch.setitem( + SPEC["providers"]["anthropic"], "default_model", "claude-spec-model" + ) + if configured_model is not None: + monkeypatch.setenv("BOT_MODEL", configured_model) + if configured_model and configured_model.startswith("anthropic:"): + # A provider-qualified model still selects its provider independently of BOT_PROVIDER. + monkeypatch.setenv("BOT_PROVIDER", "openai") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-openbot-ci") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:4311") + + result, captured = await _run_answer_with_httpx2_capture( + monkeypatch, + { + "id": "msg-openbot-ci", + "type": "message", + "role": "assistant", + "model": request_model, + "content": [{"type": "text", "text": "spec model proof"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + assert result["messages"][0].content == "spec model proof" + assert len(captured) == 1 + assert captured[0]["url"] == "http://127.0.0.1:4311/v1/messages" + assert captured[0]["body"]["model"] == request_model + + # A conversation as the server sends it after somebody picked a skill: the coworker's standing role # at the head, and the skill's instruction as a system turn just ahead of the message it was picked # for. The turn stays in the thread's history, so every later run carries it too. @@ -463,6 +518,36 @@ async def test_google_provider_reaches_google_genai_boundary( ] +@pytest.mark.asyncio +@pytest.mark.parametrize("provider", [None, "google", "google_genai"]) +async def test_google_spec_model_reaches_google_request( + monkeypatch, google_genai_endpoint, provider +): + _install_loopback_socket_guard() + base_url, captured = google_genai_endpoint + monkeypatch.setenv("GOOGLE_API_KEY", "synthetic-google") + monkeypatch.setenv("GOOGLE_GENERATIVE_AI_BASE_URL", base_url) + if provider is None: + monkeypatch.setitem( + SPEC["bots"], + "agent-langgraph-agui", + {"provider": "google", "model": "gemini-spec-model"}, + ) + else: + monkeypatch.setenv("BOT_PROVIDER", provider) + monkeypatch.setitem( + SPEC["providers"]["google"], "default_model", "gemini-spec-model" + ) + + result = await main.answer( + {"messages": [{"role": "user", "content": "Say hello."}]} + ) + + assert result["messages"][0].content == "google loopback proof" + assert len(captured) == 1 + assert captured[0]["path"] == "/v1beta/models/gemini-spec-model:generateContent" + + def _write_synthetic_chatgpt_store(path: Path): from langchain_openai.chatgpt_oauth import _ChatGPTToken from langchain_openai.chat_models.codex import _FileChatGPTOAuthTokenProvider diff --git a/agent-mastra/src/mastra/index.test.ts b/agent-mastra/src/mastra/index.test.ts index 8dc1d3dea..02016b486 100644 --- a/agent-mastra/src/mastra/index.test.ts +++ b/agent-mastra/src/mastra/index.test.ts @@ -207,9 +207,9 @@ describe("OpenBot Mastra receiver instructions", () => { describe("OpenBot Mastra model configuration", () => { const modelCases: ModelCase[] = [ - { name: "absent", expected: "gpt-5.5" }, - { name: "empty", value: "", expected: "gpt-5.5" }, - { name: "whitespace", value: " ", expected: "gpt-5.5" }, + { name: "absent", expected: "gpt-4o-mini" }, + { name: "empty", value: "", expected: "gpt-4o-mini" }, + { name: "whitespace", value: " ", expected: "gpt-4o-mini" }, { name: "custom", value: " fixture/custom:model ", diff --git a/docs/configuration.md b/docs/configuration.md index 96bfc5b95..8aad6ec4a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -159,6 +159,11 @@ of it: 2. the Bot's row in this file — what the repository decided; 3. the provider's `default_model` — what is left when neither says. +Each Bot retains its existing default: for example, `agent-mastra` uses `gpt-4o-mini`, while +`agent-langgraph` uses `gpt-5.5`. Editing a Bot's row changes that Bot's default without changing +another Bot's choice. The Python LangGraph harness also accepts `BOT_PROVIDER=google_genai` as an +alias for the spec's `google` provider. + A blank value is read as unset, which is what a compose file passing `${BOT_MODEL:-}` hands a Bot when nobody chose a model. API keys never appear in the file: they arrive in the environment under the `key_variable` the provider row names. diff --git a/shared/model-providers.json b/shared/model-providers.json index 15c3dc4b8..58b2c6785 100644 --- a/shared/model-providers.json +++ b/shared/model-providers.json @@ -23,7 +23,7 @@ "bots": { "agent-bot": { "provider": "openai", "model": "gpt-5.5" }, "agent-langgraph": { "provider": "openai", "model": "gpt-5.5" }, - "agent-mastra": { "provider": "openai", "model": "gpt-5.5" }, + "agent-mastra": { "provider": "openai", "model": "gpt-4o-mini" }, "agent-adk": { "provider": "openai", "model": "gpt-4o-mini" }, "agent-ag2": { "provider": "openai", "model": "gpt-4o-mini" }, "agent-agno": { "provider": "openai", "model": "gpt-5.5" }, diff --git a/shared/model-providers.test.ts b/shared/model-providers.test.ts index 090244068..6f42fde3e 100644 --- a/shared/model-providers.test.ts +++ b/shared/model-providers.test.ts @@ -216,7 +216,7 @@ describe("what a Bot runs from the spec file", () => { }); expect(botSettings("agent-mastra", {})).toEqual({ provider: "openai", - model: "gpt-5.5", + model: "gpt-4o-mini", }); // A Bot the file pairs with a different model than the provider's own default: expect(botSettings("agent-adk", {})).toEqual({ From 91d4b7f9223bf17070434fa8508153fd037c1422 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 28 Sep 2026 09:26:48 -0700 Subject: [PATCH 6/6] Prepare PR #649 for conflict-free batch integration Move both unchanged provider-default changelog entries to distinct existing anchors. Preserve every source and test blob from the repaired head. --- CHANGELOG.md | 58 ++++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf87e9472..1bfbaf4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,35 +8,6 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -### The Bots agree on one set of provider defaults - -The three TypeScript Bots now read a single shared list of provider facts instead of keeping their -own copies, which is what makes a default changeable in one place rather than in three. The Mastra -Bot also refuses a `BOT_PROVIDER` it does not recognize (such as `google`) instead of quietly -answering through OpenAI with a different model. The picked harness in Compose now receives -`GOOGLE_API_KEY` and `GOOGLE_GENERATIVE_AI_BASE_URL` as well, so a harness picked on -`BOT_PROVIDER=google` has the key it needs. - -### One spec file, in every language - -`shared/model-providers.json` now holds the provider facts and every Bot's default provider and -model. The TypeScript Bots read it through `shared/model-providers.ts` and the ten Python Bots -through `shared/model_providers.py`, with `BOT_PROVIDER` and `BOT_MODEL` still winning over both -as they always have. Each Bot keeps its existing default, including Mastra's `gpt-4o-mini`. -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. - -Both loaders check the file against their own list of providers, in both directions, and refuse in -the same words. A wrong row in the file used to stop the three TypeScript Bots while the ten -Python Bots started clean and met it at their first model call instead; all thirteen stop at -startup now, naming the key that is wrong. Adding a provider is one row in the file and one entry -to `PROVIDER_IDS` in each loader. - -Compose used to substitute `gpt-5.5` for `agent-langgraph` whenever `BOT_MODEL` was unset, whatever -`BOT_PROVIDER` named; it now passes the unset value through, so the Bot's row — or the moved -provider's default row — is what answers. An OpenAI deployment keeps the same `gpt-5.5` either way; -a Google or Anthropic one stops being handed a model its vendor has never heard of. - ### A hidden coworker can be found again on the Agents screen Hiding a coworker took it off both lists on `/agents`, and Unhide is only in the coworker's dialog, @@ -185,6 +156,15 @@ gave up if the whole request had not arrived, and on Windows the accepted socket listener's non-blocking mode, so a timeout did not apply. A good sign-in could be answered "Sign-in did not match". Both paths now read until the request line is complete, with a real timeout. +### The Bots agree on one set of provider defaults + +The three TypeScript Bots now read a single shared list of provider facts instead of keeping their +own copies, which is what makes a default changeable in one place rather than in three. The Mastra +Bot also refuses a `BOT_PROVIDER` it does not recognize (such as `google`) instead of quietly +answering through OpenAI with a different model. The picked harness in Compose now receives +`GOOGLE_API_KEY` and `GOOGLE_GENERATIVE_AI_BASE_URL` as well, so a harness picked on +`BOT_PROVIDER=google` has the key it needs. + ### Compose file lists separate correctly on Windows The separator between Compose files fell back to `:` everywhere, which is right on macOS and Linux @@ -193,6 +173,26 @@ reachable on every platform now that a port overlay is passed, where before it w ## 0.0.14 +### One spec file, in every language + +`shared/model-providers.json` now holds the provider facts and every Bot's default provider and +model. The TypeScript Bots read it through `shared/model-providers.ts` and the ten Python Bots +through `shared/model_providers.py`, with `BOT_PROVIDER` and `BOT_MODEL` still winning over both +as they always have. Each Bot keeps its existing default, including Mastra's `gpt-4o-mini`. +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. + +Both loaders check the file against their own list of providers, in both directions, and refuse in +the same words. A wrong row in the file used to stop the three TypeScript Bots while the ten +Python Bots started clean and met it at their first model call instead; all thirteen stop at +startup now, naming the key that is wrong. Adding a provider is one row in the file and one entry +to `PROVIDER_IDS` in each loader. + +Compose used to substitute `gpt-5.5` for `agent-langgraph` whenever `BOT_MODEL` was unset, whatever +`BOT_PROVIDER` named; it now passes the unset value through, so the Bot's row — or the moved +provider's default row — is what answers. An OpenAI deployment keeps the same `gpt-5.5` either way; +a Google or Anthropic one stops being handed a model its vendor has never heard of. + ### A tool cannot be granted for an app this deployment has not added Granting a Bot a connector's tool checked only that the person asking was an administrator, so a