Skip to content

Commit 3af815a

Browse files
authored
Honor Anthropic API keys in built-in and Mastra Bots (#614)
1 parent 28acd25 commit 3af815a

14 files changed

Lines changed: 1141 additions & 140 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### Built-in and Mastra Bots can run on Anthropic API keys
12+
13+
The example built-in Bots and the Mastra Bot now use the selected model provider instead of
14+
assuming OpenAI. A deployment with `BOT_PROVIDER=anthropic`, `BOT_MODEL=claude-sonnet-4-5` and an
15+
Anthropic key routes built-in model calls, tool selection and Mastra runs through Anthropic's native
16+
API. Source startup no longer waits for the unused OpenAI-only sample when Anthropic is selected.
17+
1118
### The Agno Bot answers on an OpenAI key
1219

1320
Picked with an OpenAI key, the Agno Bot failed every run before reaching OpenAI. Agno sends a

‎agent-mastra/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"private": true,
44
"type": "module",
55
"dependencies": {
6+
"@ai-sdk/anthropic": "*",
67
"@ai-sdk/openai": "*",
78
"@mastra/core": "*",
89
"mastra": "*"

‎agent-mastra/src/mastra/index.test.ts‎

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,229 @@ describe("OpenBot Mastra listen port configuration", () => {
205205
});
206206
}
207207
});
208+
209+
describe("OpenBot Mastra provider requests", () => {
210+
const choices: {
211+
provider: string;
212+
base: string;
213+
model: string;
214+
url: string;
215+
apiKey?: string;
216+
error?: string;
217+
}[] = [
218+
{
219+
provider: "anthropic",
220+
base: "",
221+
model: "claude-sonnet-4-5",
222+
url: "https://api.anthropic.com/v1/messages",
223+
apiKey: "test-anthropic",
224+
},
225+
{
226+
provider: "anthropic",
227+
base: "http://anthropic.test/v1",
228+
model: "claude-sonnet-4-5",
229+
url: "http://anthropic.test/v1/messages",
230+
apiKey: "test-anthropic",
231+
},
232+
{
233+
provider: "",
234+
base: "",
235+
model: "gpt-5.5",
236+
url: "https://api.openai.com/v1/responses",
237+
apiKey: "test-openai",
238+
},
239+
{
240+
provider: "openai",
241+
base: " https://api.openai.com/v1/ ",
242+
model: "gpt-5.5",
243+
url: "https://api.openai.com/v1/responses",
244+
apiKey: "test-openai",
245+
},
246+
{
247+
provider: "",
248+
base: "http://compatible.test/v1",
249+
model: "llama3.1:8b",
250+
url: "http://compatible.test/v1/chat/completions",
251+
apiKey: "test-openai",
252+
},
253+
...[
254+
"http://anthropic.test",
255+
" http://anthropic.test/proxy/ ",
256+
"http://anthropic.test/proxy/v1/",
257+
].map((base) => ({
258+
provider: "anthropic",
259+
base,
260+
model: "claude-sonnet-4-5",
261+
apiKey: "test-anthropic",
262+
url: base.includes("proxy")
263+
? "http://anthropic.test/proxy/v1/messages"
264+
: "http://anthropic.test/v1/messages",
265+
})),
266+
...[undefined, ""].map((apiKey) => ({
267+
provider: "openai",
268+
base: "http://compatible.test/v1",
269+
model: "llama3.1:8b",
270+
apiKey,
271+
url: "http://compatible.test/v1/chat/completions",
272+
})),
273+
{
274+
provider: "openai",
275+
base: "",
276+
model: "gpt-5.5",
277+
url: "https://api.openai.com/v1/responses",
278+
error: "OpenAI API key is missing",
279+
},
280+
];
281+
282+
for (const choice of choices) {
283+
test(`uses ${choice.base || "the default endpoint"} with ${choice.apiKey === undefined ? "no" : choice.apiKey || "an empty"} API key`, async () => {
284+
const seen: { url: string | null; key: string | null; model: string }[] =
285+
[];
286+
const provider = Bun.serve({
287+
hostname: "127.0.0.1",
288+
port: 0,
289+
async fetch(request) {
290+
const body = await request.json();
291+
seen.push({
292+
url: request.headers.get("x-test-original-url"),
293+
key: request.headers.get(
294+
choice.provider === "anthropic" ? "x-api-key" : "authorization",
295+
),
296+
model: body.model,
297+
});
298+
const path = new URL(request.url).pathname;
299+
if (path.endsWith("/v1/messages")) {
300+
return Response.json({
301+
id: "msg",
302+
type: "message",
303+
role: "assistant",
304+
model: body.model,
305+
content: [{ type: "text", text: "hello" }],
306+
stop_reason: "end_turn",
307+
stop_sequence: null,
308+
usage: { input_tokens: 1, output_tokens: 1 },
309+
});
310+
}
311+
if (path === "/v1/chat/completions") {
312+
return Response.json({
313+
id: "chat",
314+
object: "chat.completion",
315+
created: 0,
316+
model: body.model,
317+
choices: [
318+
{
319+
index: 0,
320+
finish_reason: "stop",
321+
message: { role: "assistant", content: "hello" },
322+
},
323+
],
324+
usage: {
325+
prompt_tokens: 1,
326+
completion_tokens: 1,
327+
total_tokens: 2,
328+
},
329+
});
330+
}
331+
if (path === "/v1/responses") {
332+
return Response.json({
333+
id: "resp",
334+
object: "response",
335+
created_at: 0,
336+
model: body.model,
337+
status: "completed",
338+
output: [
339+
{
340+
id: "msg",
341+
type: "message",
342+
role: "assistant",
343+
status: "completed",
344+
content: [
345+
{ type: "output_text", text: "hello", annotations: [] },
346+
],
347+
},
348+
],
349+
usage: {
350+
input_tokens: 1,
351+
output_tokens: 1,
352+
input_tokens_details: { cached_tokens: 0 },
353+
output_tokens_details: { reasoning_tokens: 0 },
354+
},
355+
});
356+
}
357+
return new Response("unexpected provider route", { status: 404 });
358+
},
359+
});
360+
const child = Bun.spawn(
361+
[
362+
Bun.argv[0],
363+
"-e",
364+
[
365+
// Only HTTP is replaced: the real Mastra Agent and provider SDK build the request.
366+
"const networkFetch = globalThis.fetch;",
367+
"globalThis.fetch = (input, init) => {",
368+
" const request = new Request(input, init);",
369+
" const url = new URL(request.url);",
370+
' request.headers.set("x-test-original-url", request.url);',
371+
` return networkFetch(new Request(${JSON.stringify(provider.url.toString())} + url.pathname.slice(1) + url.search, request));`,
372+
"};",
373+
'const { mastra } = await import("./agent-mastra/src/mastra/index.ts");',
374+
'const result = await mastra.getAgent("openbot").generate("Say hello");',
375+
"console.log(JSON.stringify({ text: result.text }));",
376+
].join("\n"),
377+
],
378+
{
379+
env: {
380+
PATH: process.env.PATH ?? "/opt/homebrew/bin:/usr/bin:/bin",
381+
MASTRA_TELEMETRY_DISABLED: "true",
382+
DO_NOT_TRACK: "1",
383+
NODE_ENV: "test",
384+
BOT_PROVIDER: choice.provider,
385+
BOT_MODEL: choice.model,
386+
ANTHROPIC_API_KEY:
387+
choice.provider === "anthropic" ? choice.apiKey : "",
388+
OPENAI_API_KEY:
389+
choice.provider === "anthropic" ? "" : choice.apiKey,
390+
ANTHROPIC_BASE_URL:
391+
choice.provider === "anthropic" ? choice.base : "",
392+
OPENAI_BASE_URL: choice.provider === "anthropic" ? "" : choice.base,
393+
},
394+
stdout: "pipe",
395+
stderr: "pipe",
396+
},
397+
);
398+
const timeout = setTimeout(() => child.kill(), 10_000);
399+
try {
400+
const [stdout, stderr, exitCode] = await Promise.all([
401+
new Response(child.stdout).text(),
402+
new Response(child.stderr).text(),
403+
child.exited,
404+
]);
405+
if (choice.error) {
406+
expect(exitCode).not.toBe(0);
407+
expect(stderr).toContain(choice.error);
408+
expect(seen).toEqual([]);
409+
return;
410+
}
411+
if (exitCode !== 0)
412+
throw new Error(
413+
`provider probe exited ${exitCode}\n${stdout}\n${stderr}`,
414+
);
415+
expect(stdout).toContain('"text":"hello"');
416+
expect(seen).toEqual([
417+
{
418+
url: choice.url,
419+
key:
420+
choice.provider === "anthropic"
421+
? (choice.apiKey ?? null)
422+
: `Bearer ${choice.apiKey?.trim() || "no-key-needed"}`,
423+
model: choice.model,
424+
},
425+
]);
426+
} finally {
427+
clearTimeout(timeout);
428+
child.kill();
429+
await provider.stop(true);
430+
}
431+
}, 15_000);
432+
}
433+
});

‎agent-mastra/src/mastra/index.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,43 @@
1212
* translation belongs on OpenBot's side, in one place, where every remote Bot is governed the same
1313
* way — not in each harness.
1414
*/
15-
import { openai } from "@ai-sdk/openai";
1615
import { Agent } from "@mastra/core/agent";
1716
import { Mastra } from "@mastra/core/mastra";
1817
import { registerApiRoute } from "@mastra/core/server";
1918
import { listenPort } from "../../../shared/listen-port";
2019

21-
const model = process.env.BOT_MODEL?.trim() || "gpt-4o-mini";
20+
async function configuredModel() {
21+
const provider = process.env.BOT_PROVIDER?.trim() || "openai";
22+
const model =
23+
process.env.BOT_MODEL?.trim() ||
24+
(provider === "anthropic" ? "claude-sonnet-4-5" : "gpt-4o-mini");
25+
const baseVariable =
26+
provider === "anthropic" ? "ANTHROPIC_BASE_URL" : "OPENAI_BASE_URL";
27+
const baseURL = process.env[baseVariable]?.trim();
28+
// Provider modules create default clients at import, which reject Compose's empty overrides.
29+
if (!baseURL) delete process.env[baseVariable];
30+
31+
if (provider === "anthropic") {
32+
const { createAnthropic } = await import("@ai-sdk/anthropic");
33+
// Other harnesses accept an Anthropic origin; AI SDK expects the /v1 API prefix.
34+
const origin = (baseURL || "https://api.anthropic.com").replace(/\/+$/, "");
35+
return createAnthropic({
36+
baseURL: origin.endsWith("/v1") ? origin : `${origin}/v1`,
37+
})(model);
38+
}
39+
const { createOpenAI } = await import("@ai-sdk/openai");
40+
const compatible =
41+
Boolean(baseURL) &&
42+
baseURL?.replace(/\/+$/, "") !== "https://api.openai.com/v1";
43+
const openai = createOpenAI({
44+
baseURL: baseURL || "https://api.openai.com/v1",
45+
apiKey: compatible
46+
? process.env.OPENAI_API_KEY?.trim() || "no-key-needed"
47+
: undefined,
48+
});
49+
// Compatible endpoints commonly expose Chat Completions; OpenAI keeps its Responses default.
50+
return compatible ? openai.chat(model) : openai(model);
51+
}
2252
const port = listenPort(process.env.PORT, 4213);
2353
if (!port.ok) throw new Error(port.reason);
2454

@@ -79,7 +109,7 @@ const openbot = new Agent({
79109
id: "openbot",
80110
name: "openbot",
81111
instructions: buildOpenBotInstructions,
82-
model: openai(model),
112+
model: await configuredModel(),
83113
});
84114

85115
/** The one header OpenBot's server sends, compared without leaking length through timing. */

0 commit comments

Comments
 (0)