diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b0731b2..bc9e660bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Naming a conversation asks the endpoint OPENAI_BASE_URL names, not OpenAI + +The job that names a conversation sent its request to api.openai.com whatever `OPENAI_BASE_URL` said, +while the Bots, the router and tool selection all used the configured endpoint. A deployment behind a +gateway, a proxy or a local model therefore sent its model key, and the opening of every +conversation, to OpenAI; OpenAI refused the key, so no conversation was ever named. The request now +goes to the same endpoint as every other model call. A deployment that never set `OPENAI_BASE_URL` +behaves as before. + ### A Bot's question to a person survives a route that fails Who "a person" is, is a seam a deployment fills in with its own on-call rota or duty desk. If that diff --git a/server/src/channels/titler.ts b/server/src/channels/titler.ts index 6032935a1..5556d968d 100644 --- a/server/src/channels/titler.ts +++ b/server/src/channels/titler.ts @@ -2,11 +2,9 @@ * One model call for a few words. Not a Bot and not a turn: a title is not said to anybody and is * not part of the conversation it names, so there is no runtime work and no thread to hold. */ +import { chatCompletionsUrl } from "../routing/model"; import { oneLine } from "./text"; -/** Where an OpenAI-compatible provider answers, overridable the way `agent-bot` overrides it. */ -const DEFAULT_BASE_URL = "https://api.openai.com/v1"; - /** Rules, not an example: a model copies an example's subject as readily as its shape. */ const INSTRUCTION = [ "You name conversations, like a title in a sidebar.", @@ -20,7 +18,7 @@ export type TitlerOptions = { model: string; /** Resolved per call, so a rotated credential is picked up without a restart. */ resolveApiKey: () => Promise; - baseUrl?: string; + environment?: Record; /** Injectable so a test drives this without a network. */ fetchImpl?: typeof fetch; /** How long one call may take before it is given up on. */ @@ -30,29 +28,31 @@ export type TitlerOptions = { /** No key resolves to null and is not retried; a provider error throws, so the queue retries it. */ export function createChannelTitler(options: TitlerOptions) { const call = options.fetchImpl ?? fetch; - const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); return async (excerpt: string): Promise => { const apiKey = await options.resolveApiKey(); if (!apiKey) return null; - const response = await call(`${baseUrl}/chat/completions`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, + const response = await call( + chatCompletionsUrl(options.environment ?? process.env), + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: options.model, + messages: [ + { role: "system", content: INSTRUCTION }, + { role: "user", content: excerpt }, + ], + // Reasoning tokens come out of this budget, so a cap sized for the answer alone 400s. + max_completion_tokens: 512, + }), + signal: AbortSignal.timeout(options.timeoutMs ?? 20_000), }, - body: JSON.stringify({ - model: options.model, - messages: [ - { role: "system", content: INSTRUCTION }, - { role: "user", content: excerpt }, - ], - // Reasoning tokens come out of this budget, so a cap sized for the answer alone 400s. - max_completion_tokens: 512, - }), - signal: AbortSignal.timeout(options.timeoutMs ?? 20_000), - }); + ); if (!response.ok) { // Capped: this lands on the work item's row, and an HTML error page would land there whole. diff --git a/server/tests/channel-titler.test.ts b/server/tests/channel-titler.test.ts index 953be3044..60af46751 100644 --- a/server/tests/channel-titler.test.ts +++ b/server/tests/channel-titler.test.ts @@ -25,6 +25,7 @@ describe("asking the model for a title", () => { const answer = await createChannelTitler({ model: "gpt-4.1-mini", resolveApiKey: async () => "key-123", + environment: {}, fetchImpl, })("Asked: which receipts count as travel?"); @@ -33,6 +34,43 @@ describe("asking the model for a title", () => { expect(calls[0]?.body.model).toBe("gpt-4.1-mini"); }); + test("asks the endpoint OPENAI_BASE_URL names, not OpenAI", async () => { + const { calls, fetchImpl } = respondWith({ + choices: [{ message: { content: "Travel receipt rules" } }], + }); + + await createChannelTitler({ + model: "openai/gpt-5.6-terra", + resolveApiKey: async () => "gateway-key", + environment: { OPENAI_BASE_URL: "https://gateway.internal/v1" }, + fetchImpl, + })("Asked: which receipts count as travel?"); + + expect(calls.map((call) => call.url)).toEqual([ + "https://gateway.internal/v1/chat/completions", + ]); + }); + + test("reads OPENAI_BASE_URL from the process when no environment is passed", async () => { + const { calls, fetchImpl } = respondWith({ + choices: [{ message: { content: "Travel receipt rules" } }], + }); + const previous = process.env.OPENAI_BASE_URL; + process.env.OPENAI_BASE_URL = "http://localhost:4010"; + try { + await createChannelTitler({ + model: "gpt-4.1-mini", + resolveApiKey: async () => "gateway-key", + fetchImpl, + })("Asked: anything?"); + } finally { + if (previous === undefined) delete process.env.OPENAI_BASE_URL; + else process.env.OPENAI_BASE_URL = previous; + } + + expect(calls[0]?.url).toBe("http://localhost:4010/v1/chat/completions"); + }); + test("asks nothing at all when the deployment has no key", async () => { const { calls, fetchImpl } = respondWith({});