Skip to content
Open
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
2 changes: 2 additions & 0 deletions openbot/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ Most gateways publish a model list, which is the way to check a name before conf

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.

With `OPENAI_BASE_URL` set, the built-in Bot sends its Responses API requests with `store: false` and each earlier step of a turn in full, because an endpoint other than OpenAI keeps no stored responses to refer back to. A reasoning model's thinking is therefore not carried from one step of a turn to the next, including on a gateway in front of OpenAI itself.

## Voice dictation

Dictation inserts recorded speech into the composer for review before sending. It works with any
Expand Down
11 changes: 11 additions & 0 deletions openbot/server/src/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,17 @@ export function builtInAgentConfiguration(
...(learnedSkills?.catalog ? [learnedSkills.catalog] : []),
].join("\n\n"),
...(planModel ? {} : { apiKey: apiKey ?? undefined }),
/*
* An OpenAI-compatible endpoint other than OpenAI (OPENAI_BASE_URL) keeps no response store, so
* the Responses API must resend earlier items in full. With `store` on, the AI SDK sends a
* reasoning model's previous thinking back as `item_reference`, and an endpoint such as Ollama
* refuses the whole step after the first tool call ("unknown input item type").
*/
...(!planModel &&
model.provider === "openai" &&
process.env.OPENAI_BASE_URL?.trim()
? { providerOptions: { openai: { store: false } } }
: {}),
/*
* A run stops after one step unless told otherwise, which for a Bot with tools means it calls
* one and never speaks: the tool executes, the result arrives, and the run ends before the model
Expand Down
84 changes: 84 additions & 0 deletions openbot/server/tests/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,90 @@ describe("deployment model selection", () => {
},
);

/*
* A compatible endpoint keeps no response store. With `store` on, the AI SDK sends a step's
* reasoning back as an `item_reference`, which such an endpoint (Ollama, for one) refuses, so a
* reasoning model failed every turn at its first tool call.
*/
test("a compatible endpoint is sent the earlier step in full, not by reference", async () => {
const recorder = new LLMock();
const originalBase = process.env.OPENAI_BASE_URL;
// The journal keeps a normalised body, so the request is read as sent, on its way through.
const bodies: { store?: unknown; input?: unknown }[] = [];
let proxy: ReturnType<typeof Bun.serve> | undefined;
try {
const base = await recorder.start();
proxy = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const body = await request.text();
bodies.push(JSON.parse(body));
return fetch(`${base}${new URL(request.url).pathname}`, {
method: "POST",
headers: request.headers,
body,
});
},
});
process.env.OPENAI_BASE_URL = `${proxy.url.origin}/v1`;
recorder.on({ hasToolResult: true }, { content: "The balance is 42." });
recorder.onMessage(/balance/, {
reasoning: "Read the balance before answering.",
toolCalls: [
{
id: "balance-call",
name: "read_balance",
arguments: { account: "demo" },
},
],
});
const agents = await resolveRuntimeAgents(
async () => [
{
id: "general-assistant",
name: "General Assistant",
type: "built_in" as const,
systemPrompt: "Answer using the granted balance tool.",
},
],
{ provider: "openai", defaultModel: "selected-local-model" },
async () => "synthetic-model-key",
undefined,
async () => [
{
name: "read_balance",
ref: "bank/read_balance",
description: "Read the balance.",
parameters: z.object({ account: z.string() }),
execute: async () => "42",
},
],
);
const agent = agents["general-assistant"]?.clone();
if (!agent) throw new Error("Expected General Assistant.");
agent.addMessage({
id: "compatible-store-request",
role: "user",
content: "Read my balance.",
});
await agent.runAgent();

expect(agent.messages.at(-1)).toMatchObject({
role: "assistant",
content: "The balance is 42.",
});
expect(bodies).toHaveLength(2);
expect(bodies.map((body) => body.store)).toEqual([false, false]);
expect(JSON.stringify(bodies[1]?.input)).not.toContain("item_reference");
} finally {
if (originalBase === undefined) delete process.env.OPENAI_BASE_URL;
else process.env.OPENAI_BASE_URL = originalBase;
await proxy?.stop(true);
await recorder.stop();
}
});

/*
* Switching the provider away from the package's own, without naming a model. The two fallbacks are
* not the same source: OpenAI's is the built-in Bot's own (it speaks the Responses API), Anthropic's
Expand Down