Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,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
Expand Down Expand Up @@ -365,8 +372,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
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,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
Expand All @@ -164,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
Expand Down
1 change: 1 addition & 0 deletions agent-adk/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions agent-adk/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,8 +60,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}"


Expand Down
37 changes: 37 additions & 0 deletions agent-adk/tests/test_model_spec.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions agent-ag2/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions agent-ag2/src/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions agent-ag2/tests/test_model_spec.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions agent-agno/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions agent-agno/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"


Expand Down
37 changes: 37 additions & 0 deletions agent-agno/tests/test_model_spec.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 19 additions & 14 deletions agent-bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
botSettings,
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.
Expand Down Expand Up @@ -39,24 +39,29 @@ 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. 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 = modelName(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.
*
* `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 " +
Expand Down Expand Up @@ -97,7 +102,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.",
);
Expand Down
34 changes: 0 additions & 34 deletions agent-bot/src/model-key.ts

This file was deleted.

Loading
Loading