Skip to content

Commit 019e647

Browse files
Answer the OpenAI-shaped calls from wherever the deployment says (#13)
* Answer the OpenAI-shaped calls from wherever the deployment says OpenBot already runs on your own machine, in your own PostgreSQL, with a model key you supply. The one thing it could not do was decide where that key is spent: `agent-bot` constructed its client with a key and no base URL, `agent-langgraph` did the same through `ChatOpenAI`, and compose handed neither of them a way to be told otherwise. A deployment that had put a gateway or a proxy in front of its models, or that runs the model on hardware it controls, had to fork two files to use it. `OPENAI_BASE_URL` is that decision, and the API server already honoured it: `resolveModel` in the pinned runtime reads it for every `openai/*` model, so the package built-in agents have always been movable. This gives the two shipped Bots the same variable and hands it to both containers, so one line moves the whole deployment rather than the half of it that happens to run outside Docker. It is a base URL rather than another `BOT_PROVIDER` branch because the API is the contract. `anthropic` and `google` are different APIs, not different URLs for this one, and they are untouched. Model names travel verbatim in `BOT_MODEL` and in the tenant package's `default_model`, because an endpoint names its own catalogue. Verified against a live OpenAI-compatible gateway: both Bots stream AG-UI and emit tool calls, which is what a Bot needs to drive its computer. * Point the other two providers somewhere else as well The OpenAI half of this landed the right idea and stopped one provider short. A deployment that fronts Anthropic or Google the same way, or runs either on its own hardware, had the same silent split this change set out to remove: the API server would follow and agent-langgraph would not. Both use the names the API server already reads, so one line still moves the built-in agents and the Bot together. agent-bot is untouched. It speaks the OpenAI API directly by construction, so there is no Anthropic or Google call in it to redirect. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent 68cc78d commit 019e647

7 files changed

Lines changed: 131 additions & 2 deletions

File tree

.env.example

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,29 @@ COPILOTKIT_LICENSE_TOKEN=
5353
# framework Bot unless you point it at another provider below.
5454
OPENAI_API_KEY=
5555

56+
# Where that key is spent. Unset, it is OpenAI. Set, it is any endpoint speaking the same
57+
# `/v1/chat/completions` API: a gateway in front of several providers, a proxy, or a model on
58+
# hardware you control. The API server reads it for the built-in agents and both shipped Bots read
59+
# it too, so one line moves the whole deployment rather than one Bot.
60+
#
61+
# Model names travel verbatim, so use whatever the endpoint publishes. An endpoint that namespaces
62+
# its catalogue wants both halves of the name, in `BOT_MODEL` and in the tenant package's
63+
# `default_model` alike. LLMTR, a gateway that fronts several providers and hosts models in Turkey
64+
# for deployments that need the data to stay there, addressed that way:
65+
#
66+
# OPENAI_BASE_URL=https://llmtr.com/v1
67+
# OPENAI_API_KEY=llmtr-...
68+
# BOT_MODEL=openai/gpt-4o # or llmtr/gemma-4, or anthropic/claude-sonnet-4.5
69+
#
70+
# Its catalogue is public and needs no key: https://llmtr.com/v1/models
71+
#
72+
# OPENAI_BASE_URL=
73+
74+
# The same for the other two providers, under the names the API server already reads. They are
75+
# different APIs rather than different URLs for this one, so each has its own.
76+
# ANTHROPIC_BASE_URL=
77+
# GOOGLE_GENERATIVE_AI_BASE_URL=
78+
5679
# Framework Bot provider: openai, anthropic or google. It reads that provider's own
5780
# key and refuses to start without it, so a deployment on Anthropic never needs an OpenAI key for it.
5881
# The proof-of-concept Bot is OpenAI only by construction: it speaks that API directly.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ Settings worth knowing:
175175
| Variable | Use |
176176
| ------------------------------------ | ------------------------------------------------------------------------- |
177177
| `OPENBOT_DEV_NO_AUTH` | Admits every request as one administrator. How OpenBot runs today. |
178+
| `OPENAI_BASE_URL` | Answers the OpenAI-shaped calls from somewhere else: a gateway, a proxy. |
179+
| `ANTHROPIC_BASE_URL`, `GOOGLE_GENERATIVE_AI_BASE_URL` | The same, for those two APIs. |
178180
| `COMPUTER_TOKEN` | Secret every Bot computer request must present. `start.sh` sets one. |
179181
| `SUPERVISOR_TOKEN` | Secret the supervisor requires. `start.sh` sets one. |
180182
| `COMPUTER_SUPERVISOR_URL` | Gives each Bot a computer of its own instead of one shared computer. |

agent-bot/src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,21 @@ const PORT = Number.parseInt(process.env.PORT ?? "4200", 10);
2626
*/
2727
const MODEL = process.env.BOT_MODEL ?? "gpt-5.5";
2828

29-
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
29+
/**
30+
* Where that model is answered from.
31+
*
32+
* Unset, this is OpenAI. Set, it is any endpoint speaking the same `/v1/chat/completions` API: a
33+
* gateway in front of several providers, a proxy, or a model on hardware you control. Which is the
34+
* point of writing against that API by hand rather than against one company's URL.
35+
*
36+
* `BOT_MODEL` is sent verbatim, because an endpoint names its own catalogue.
37+
*/
38+
const BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined;
39+
40+
const openai = new OpenAI({
41+
apiKey: process.env.OPENAI_API_KEY,
42+
baseURL: BASE_URL,
43+
});
3044

3145
/** Translate the conversation AG-UI carries into the shape the model provider expects. */
3246
function toProviderMessages(input: RunAgentInput) {

agent-langgraph/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ const PROVIDER = (process.env.BOT_PROVIDER ?? "openai").toLowerCase();
5858
const MODEL = process.env.BOT_MODEL ?? defaultModelFor(PROVIDER);
5959
/** OpenAI only. Its newer models require the Responses API, which the integration handles. */
6060
const USE_RESPONSES_API = process.env.BOT_RESPONSES_API === "true";
61+
/**
62+
* OpenAI only, and the same variable the API server reads for its built-in agents.
63+
*
64+
* Unset, `openai` means OpenAI. Set, it means any endpoint speaking that API: a gateway in front of
65+
* several providers, a proxy, or a model on hardware you control. The integration owns the HTTP, so
66+
* this is a base URL rather than another provider branch, and `BOT_MODEL` is sent verbatim because
67+
* an endpoint names its own catalogue.
68+
*/
69+
const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined;
70+
/**
71+
* The same idea for the other two providers, under the names the API server already reads.
72+
*
73+
* Sharing the variable names is the point: one line moves the built-in agents and this Bot
74+
* together, and a deployment cannot end up with half of itself pointed somewhere else.
75+
*/
76+
const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL?.trim() || undefined;
77+
const GOOGLE_BASE_URL =
78+
process.env.GOOGLE_GENERATIVE_AI_BASE_URL?.trim() || undefined;
6179

6280
function defaultModelFor(provider: string): string {
6381
if (provider === "anthropic") return "claude-sonnet-4-5";
@@ -177,19 +195,22 @@ function buildModel() {
177195
model: MODEL,
178196
apiKey: API_KEY,
179197
streaming: true,
198+
...(ANTHROPIC_BASE_URL ? { anthropicApiUrl: ANTHROPIC_BASE_URL } : {}),
180199
});
181200
}
182201
if (PROVIDER === "google") {
183202
return new ChatGoogleGenerativeAI({
184203
model: MODEL,
185204
apiKey: API_KEY,
186205
streaming: true,
206+
...(GOOGLE_BASE_URL ? { baseUrl: GOOGLE_BASE_URL } : {}),
187207
});
188208
}
189209
return new ChatOpenAI({
190210
model: MODEL,
191211
apiKey: API_KEY,
192212
streaming: true,
213+
...(OPENAI_BASE_URL ? { configuration: { baseURL: OPENAI_BASE_URL } } : {}),
193214
...(USE_RESPONSES_API ? { useResponsesApi: true } : {}),
194215
});
195216
}

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ services:
163163
- "${BOT_PORT:-4200}:4200"
164164
environment:
165165
OPENAI_API_KEY: ${OPENAI_API_KEY}
166+
# Unset means OpenAI. Set, it is any endpoint speaking the same API, and BOT_MODEL is sent
167+
# to it verbatim.
168+
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
166169
BOT_MODEL: ${BOT_MODEL:-gpt-5.5}
167170
healthcheck:
168171
test: ["CMD-SHELL", "bun -e \"await fetch('http://localhost:4200/health')\""]
@@ -182,8 +185,11 @@ services:
182185
# BOT_RESPONSES_API instead of changing the streaming loop here.
183186
BOT_PROVIDER: ${BOT_PROVIDER:-openai}
184187
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
188+
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
185189
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
190+
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
186191
GOOGLE_API_KEY: ${GOOGLE_API_KEY:-}
192+
GOOGLE_GENERATIVE_AI_BASE_URL: ${GOOGLE_GENERATIVE_AI_BASE_URL:-}
187193
BOT_MODEL: ${BOT_MODEL:-}
188194
BOT_RESPONSES_API: ${BOT_RESPONSES_API:-false}
189195
healthcheck:

docs/configuration.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,50 @@ All four Intelligence values are required together. Missing any of them stops se
3737
| `TENANT_PACKAGE_DIR` | `../examples/fintech` | Tenant package directory, resolved from `server/`. |
3838
| `DEPLOYMENT_ID` | the tenant package's id | Names this deployment inside a shared Intelligence project. |
3939
| `OPENAI_API_KEY` | unset | Default model key for built-in agents and both shipped Bots. |
40+
| `OPENAI_BASE_URL` | unset | OpenAI-compatible endpoint that key is spent against. See below. |
4041
| `BOT_PROVIDER` | `openai` | Provider for `agent-langgraph`: `openai`, `anthropic`, or `google`. |
4142
| `ANTHROPIC_API_KEY` | unset | Anthropic key when `BOT_PROVIDER=anthropic`. |
43+
| `ANTHROPIC_BASE_URL` | unset | Anthropic-compatible endpoint that key is spent against. |
4244
| `GOOGLE_API_KEY` | unset | Google key when `BOT_PROVIDER=google`. |
45+
| `GOOGLE_GENERATIVE_AI_BASE_URL` | unset | Google-compatible endpoint that key is spent against. |
4346
| `BOT_MODEL` | provider default from Bot code/env | Model used by the shipped Bots. |
4447
| `BOT_RESPONSES_API` | `false` | Makes `agent-langgraph` use the OpenAI Responses API. |
4548

49+
## OpenAI-compatible endpoints
50+
51+
`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.
52+
53+
It moves the whole deployment rather than one Bot. The API server reads it for package built-in agents, `agent-bot` reads it for the client it constructs, and `agent-langgraph` reads it for `BOT_PROVIDER=openai`.
54+
55+
The other two providers work the same way under their own names, because they are different APIs rather than different URLs for this one: `ANTHROPIC_BASE_URL` and `GOOGLE_GENERATIVE_AI_BASE_URL`. All three are the names the API server already reads, so one line moves the built-in agents and the Bots together and a deployment cannot end up with half of itself pointed somewhere else.
56+
57+
Model names travel verbatim, so use whatever the endpoint publishes. An endpoint that namespaces its catalogue wants both halves of the name, in `BOT_MODEL` and in the tenant package's `default_model` alike.
58+
59+
[LLMTR](https://llmtr.com) is one such gateway. It fronts OpenAI, Anthropic, Google and others behind one key, and hosts models in Turkey for deployments that need the data to stay there, which is the same reason a deployment runs OpenBot on its own infrastructure. Addressed the usual way:
60+
61+
```sh
62+
OPENAI_BASE_URL=https://llmtr.com/v1
63+
OPENAI_API_KEY=llmtr-...
64+
BOT_MODEL=openai/gpt-4o # or llmtr/gemma-4, or anthropic/claude-sonnet-4.5
65+
```
66+
67+
and in the tenant package, where the name is namespaced the same way:
68+
69+
```yaml
70+
model:
71+
provider: openai
72+
credential_secret_ref: openai-api-key
73+
default_model: openai/gpt-4o
74+
```
75+
76+
Its catalogue is public and needs no key, so the names above can be checked before anything is configured:
77+
78+
```sh
79+
curl -s https://llmtr.com/v1/models
80+
```
81+
82+
Two things are worth knowing before pointing a deployment at any gateway. Not every catalogue entry accepts tools, and a Bot without tool calling cannot drive its computer; the model list says which do. And `BOT_RESPONSES_API=true` needs an endpoint that implements the Responses API, not only chat completions.
83+
4684
## Authentication
4785

4886
| Variable | Meaning |
@@ -223,7 +261,7 @@ model:
223261
default_model: gpt-4.1
224262
```
225263

226-
`provider` must be `openai`. `credential_secret_ref` is a reference to a stored credential, not a credential value.
264+
`provider` must be `openai`. `credential_secret_ref` is a reference to a stored credential, not a credential value. `default_model` is passed through as written, so an OpenAI-compatible endpoint reached through `OPENAI_BASE_URL` takes the name that endpoint publishes.
227265

228266
### `knowledge.yaml`
229267

tests/compose.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ test("publishes every service on a settable port with the documented default", (
3737
}
3838
});
3939

40+
/**
41+
* Both Bots are reachable at whatever `OPENAI_BASE_URL` names.
42+
*
43+
* The API server reads that variable from `.env` directly, so it moves with the deployment. The
44+
* Bots run in containers and see only what compose hands them, and a deployment that moved its
45+
* models to a gateway and found half of itself still calling OpenAI would have no way to tell.
46+
*/
47+
test("gives both shipped Bots the OpenAI-compatible endpoint", () => {
48+
const compose = readFileSync(
49+
join(import.meta.dir, "..", "docker-compose.yml"),
50+
"utf8",
51+
);
52+
53+
// Both Bots speak OpenAI; only the framework Bot can be pointed at the other two.
54+
expect(
55+
compose.match(/OPENAI_BASE_URL: \$\{OPENAI_BASE_URL:-?\}/g),
56+
).toHaveLength(2);
57+
for (const variable of [
58+
"ANTHROPIC_BASE_URL",
59+
"GOOGLE_GENERATIVE_AI_BASE_URL",
60+
]) {
61+
expect(compose).toContain(`${variable}: \${${variable}:-}`);
62+
}
63+
});
64+
4065
test("enables pgvector before creating vector columns", () => {
4166
const migration = readFileSync(
4267
join(import.meta.dir, "..", "server", "drizzle", "0000_schema.sql"),

0 commit comments

Comments
 (0)