diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index e37227f0336..9f64d956c13 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -17,6 +17,7 @@ const getActiveEmbeddedRunSnapshotMock = vi.fn(); const resolveSessionAgentIdMock = vi.fn(); const resolveAgentWorkspaceDirMock = vi.fn(); const prepareProviderRuntimeAuthMock = vi.fn(); +const registerProviderStreamForModelMock = vi.fn(); const diagDebugMock = vi.fn(); vi.mock("@mariozechner/pi-ai", async () => { @@ -71,6 +72,11 @@ vi.mock("../plugins/provider-runtime.js", () => ({ prepareProviderRuntimeAuth: (...args: unknown[]) => prepareProviderRuntimeAuthMock(...args), })); +vi.mock("./provider-stream.js", () => ({ + registerProviderStreamForModel: (...args: unknown[]) => + registerProviderStreamForModelMock(...args), +})); + vi.mock("./auth-profiles/session-override.js", () => ({ resolveSessionAuthProfileOverride: (...args: unknown[]) => resolveSessionAuthProfileOverrideMock(...args), @@ -275,6 +281,7 @@ describe("runBtwSideQuestion", () => { resolveSessionAgentIdMock.mockReset(); resolveAgentWorkspaceDirMock.mockReset(); prepareProviderRuntimeAuthMock.mockReset(); + registerProviderStreamForModelMock.mockReset(); diagDebugMock.mockReset(); buildSessionContextMock.mockReturnValue({ @@ -293,6 +300,7 @@ describe("runBtwSideQuestion", () => { resolveSessionAgentIdMock.mockReturnValue("main"); resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace"); prepareProviderRuntimeAuthMock.mockResolvedValue(undefined); + registerProviderStreamForModelMock.mockReturnValue(undefined); }); it("streams blocks without persisting BTW data to disk", async () => { @@ -432,6 +440,48 @@ describe("runBtwSideQuestion", () => { ); }); + it("uses the provider's stream fn when registered so provider URL construction runs (#68336)", async () => { + // Regression: before this fix, /btw called streamSimple directly and + // bypassed the provider's createStreamFn/wrapStreamFn hooks. That caused + // Ollama Cloud (api: "openai-completions", baseUrl: "https://ollama.com/") + // to hit the marketing site instead of /v1/chat/completions. + resolveModelWithRegistryMock.mockReturnValue({ + provider: "ollama", + id: "glm-5.1", + api: "openai-completions", + baseUrl: "https://ollama.com/", + }); + const providerStreamFn = vi + .fn() + .mockReturnValue(makeAsyncEvents([createDoneEvent("Ollama Cloud answer.")])); + registerProviderStreamForModelMock.mockReturnValue(providerStreamFn); + + const result = await runSideQuestion({ provider: "ollama", model: "glm-5.1" }); + + expect(result).toEqual({ text: "Ollama Cloud answer." }); + expect(registerProviderStreamForModelMock).toHaveBeenCalledWith( + expect.objectContaining({ + model: expect.objectContaining({ + provider: "ollama", + api: "openai-completions", + baseUrl: "https://ollama.com/", + }), + }), + ); + expect(providerStreamFn).toHaveBeenCalledTimes(1); + expect(streamSimpleMock).not.toHaveBeenCalled(); + }); + + it("falls back to streamSimple when no provider stream fn is registered", async () => { + registerProviderStreamForModelMock.mockReturnValue(undefined); + mockDoneAnswer("Fallback answer."); + + const result = await runSideQuestion(); + + expect(result).toEqual({ text: "Fallback answer." }); + expect(streamSimpleMock).toHaveBeenCalledTimes(1); + }); + it("strips injected empty tools arrays from BTW payloads before sending", async () => { mockDoneAnswer("Final answer."); diff --git a/src/agents/btw.ts b/src/agents/btw.ts index ab71d9e9b22..0530a2d748a 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -33,6 +33,7 @@ import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js"; import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js"; import { streamWithPayloadPatch } from "./pi-embedded-runner/stream-payload-utils.js"; import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; +import { registerProviderStreamForModel } from "./provider-stream.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; import { sanitizeImageBlocks } from "./tool-images.js"; @@ -432,6 +433,17 @@ export async function runBtwSideQuestion( } } + // Use the provider's own stream fn so providers like Ollama (which build + // `/api/chat` or `/v1/chat/completions` paths based on api mode) construct + // URLs correctly. Without this, streamSimple hits the provider's baseUrl + // directly and 404s on endpoints like Ollama Cloud (#68336). + const providerStreamFn = registerProviderStreamForModel({ + model: runtimeModel, + cfg: params.cfg, + agentDir: params.agentDir, + env: process.env, + }); + const chunker = params.opts?.onBlockReply && params.blockReplyChunking ? new EmbeddedBlockChunker(params.blockReplyChunking) @@ -459,7 +471,7 @@ export async function runBtwSideQuestion( }; const stream = await streamWithPayloadPatch( - streamSimple, + providerStreamFn ?? streamSimple, runtimeModel, { systemPrompt: buildBtwSystemPrompt(),