From 7697ac89705c27d7951663dd6043304dcaacb7e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 18:47:04 +0200 Subject: [PATCH] refactor(ai): move reasoning helpers and the Coach error resolver into their own files The reasoning separation grew three files past their size ratchet. The Local client's reasoning collector and the new separation helpers move to local-reasoning.ts, the gateway client's thinking room and reply separation become two private methods, and the Coach's error-code resolver moves out of chat-bubble.tsx into coach-error-key.ts, still re-exported from message-thread.tsx. Code motion apart from comments that said when a branch was added rather than what it does. The baselines record the shorter files. Refs #1276 --- .../baselines/source-file-lines.json | 9 +- .../baselines/source-function-lines.json | 6 +- .../baselines/version-stamp-comments.json | 10 +- .../insights/coach-panel/chat-bubble.tsx | 81 +-------- .../insights/coach-panel/coach-error-key.ts | 78 +++++++++ .../insights/coach-panel/message-thread.tsx | 7 +- src/lib/ai/local-client.ts | 164 +++--------------- src/lib/ai/local-reasoning.ts | 124 +++++++++++++ src/lib/ai/openai-client.ts | 148 +++++++++------- 9 files changed, 334 insertions(+), 293 deletions(-) create mode 100644 src/components/insights/coach-panel/coach-error-key.ts create mode 100644 src/lib/ai/local-reasoning.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 09fafac62..7478dfb91 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,12 +1,12 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 89, - "total": 99252, + "count": 88, + "total": 98310, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, - "components/insights/coach-panel/chat-bubble.tsx": 888, + "components/insights/coach-panel/chat-bubble.tsx": 822, "components/insights/coach-panel/coach-conversation.tsx": 1286, "components/insights/coach-panel/use-coach.ts": 1486, "components/insights/derived/vitals-dashboard.tsx": 1033, @@ -27,10 +27,9 @@ "lib/ai/coach/results/metric-table-tool.ts": 1102, "lib/ai/coach/snapshot.ts": 1347, "lib/ai/coach/stream-events.ts": 817, - "lib/ai/coach/turn/model.ts": 820, "lib/ai/coach/types.ts": 1011, "lib/ai/codex-client.ts": 1045, - "lib/ai/local-client.ts": 880, + "lib/ai/local-client.ts": 824, "lib/ai/prompts/insight-generator.ts": 1110, "lib/ai/provider.ts": 1632, "lib/analytics/score/reader.ts": 998, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 38bb22fd9..c97a01137 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 352, - "total": 113984, + "total": 113949, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -251,12 +251,12 @@ "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, - "lib/ai/coach/turn/model.ts :: runTurnModel": 560, + "lib/ai/coach/turn/model.ts :: runTurnModel": 540, "lib/ai/coach/turn/pipeline.ts :: runTurn": 274, "lib/ai/coach/turn/reply-guards.ts :: guardReply": 331, "lib/ai/codex-client.ts :: parseResponse": 302, "lib/ai/local-client.ts :: generateCompletionStream": 298, - "lib/ai/openai-client.ts :: generateCompletion": 390, + "lib/ai/openai-client.ts :: generateCompletion": 375, "lib/analytics/bp-in-target-fast-path.ts :: computeFromRollups": 203, "lib/analytics/compliance/display.ts :: calculateCompliance": 212, "lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e3589a68f..91b14f0e0 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2897, - "total": 9282, + "total": 9273, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -914,7 +914,7 @@ "components/insights/coach-panel/assumption-line.tsx": 1, "components/insights/coach-panel/attachment-picker.tsx": 1, "components/insights/coach-panel/attachment-pills.tsx": 2, - "components/insights/coach-panel/chat-bubble.tsx": 30, + "components/insights/coach-panel/chat-bubble.tsx": 25, "components/insights/coach-panel/coach-conversation.tsx": 40, "components/insights/coach-panel/coach-drawer-body.tsx": 6, "components/insights/coach-panel/coach-drawer.tsx": 14, @@ -1729,7 +1729,7 @@ "lib/ai/coach/turn/context.ts": 14, "lib/ai/coach/turn/conversation.ts": 4, "lib/ai/coach/turn/errors.ts": 1, - "lib/ai/coach/turn/model.ts": 48, + "lib/ai/coach/turn/model.ts": 46, "lib/ai/coach/turn/persist.ts": 12, "lib/ai/coach/turn/pipeline.ts": 8, "lib/ai/coach/turn/provenance.ts": 15, @@ -1748,10 +1748,10 @@ "lib/ai/insight-interpretation.ts": 1, "lib/ai/legacy-payload.ts": 2, "lib/ai/legacy-private-host-report.ts": 1, - "lib/ai/local-client.ts": 19, + "lib/ai/local-client.ts": 18, "lib/ai/medical-references.ts": 1, "lib/ai/mock-client.ts": 5, - "lib/ai/openai-client.ts": 18, + "lib/ai/openai-client.ts": 17, "lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts": 5, "lib/ai/prompts/__tests__/briefing-personalisation.test.ts": 1, "lib/ai/prompts/__tests__/coach-prompt-locale.test.ts": 1, diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index a50908555..21f341c8f 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -9,7 +9,6 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; -import { DEMO_READ_ONLY_CODE } from "@/lib/demo-refusal"; import { parseChartTokens, tokenToMetric, @@ -40,89 +39,19 @@ import type { } from "@/lib/ai/coach/types"; import { translateOrNull } from "@/lib/i18n/translate-code"; import { InlineAction } from "@/components/ui/inline-action"; +import { errorCodeToI18nKey } from "./coach-error-key"; /** * Per-message bubble renderer for the Coach thread, split out of * `message-thread.tsx` (v1.28.26 file-size split). Owns the user + assistant * `ChatBubble` (steps, accompanying charts, the action row from * `message-actions.tsx`, and the follow-up chips under the latest answer), - * the typing indicator, the chart-token grounding selector, and the - * error-code → i18n resolver. `message-thread.tsx` re-exports the - * previously public names so external call sites are unchanged. + * the typing indicator and the chart-token grounding selector. The + * error-code → i18n resolver lives in `coach-error-key.ts`. + * `message-thread.tsx` re-exports the previously public names so external + * call sites are unchanged. */ -/** - * v1.4.25 — map server-emitted error codes to specific Coach i18n - * keys. The chat route distinguishes the daily user-quota - * (`coach.budget.exceeded`, returned as a JSON 429) from the provider - * rate-limit (`coach.provider.rate_limited`, streamed as an SSE error - * frame). Both used to surface as the generic provider-unavailable - * copy; we now route each to its dedicated translation so the user - * understands whether the limit is on their side (reset at UTC - * midnight) or transient on the provider side (retry in ~5 min). - * - * Exported so the resolver can be pinned by unit tests without - * standing up the whole thread renderer. - */ -export function errorCodeToI18nKey(code: string): string { - switch (code) { - case "coach.budget.exceeded": - return "insights.coach.dailyLimitBody"; - case "coach.provider.rate_limited": - return "insights.coach.providerRateLimitBody"; - case "coach.network": - // v1.4.43 — a dropped network is the user's local - // problem and needs a different next action ("come back online") - // than a provider failure ("try again in a moment"). Split out - // so the user sees the actionable copy in the offline branch. - return "insights.coach.errorNetwork"; - case "coach.provider.credential_expired": - // v1.11.0 — the user's primary AI provider credential is dead - // (auth-class failure). The next action is "reconnect", not "try - // again later", so it carries its own copy pointing at Settings. - return "insights.coach.errorCredentialExpired"; - case "coach.provider.none": - // v1.18.6 — no AI provider is configured anywhere, so "try again - // in a moment" is the wrong instruction (it will 422 forever). Send - // the user to guided setup with a one-line explainer + a Settings - // link rendered alongside the bubble copy. - return "insights.coach.errorNoProvider"; - case "coach.provider.reasoning_only": - // #1276 — the model was reached and answered with its reasoning - // only, even after being asked once more. "Try again in a moment" - // would be the wrong advice; the copy names what happened. - return "insights.coach.errorReasoningOnly"; - case "coach.provider.unavailable": - case "coach.provider.empty": - case "coach.stream": - return "insights.coach.errorProvider"; - // v1.39 — the capability refusals (`meta.errorCode` on a 403/422/503 - // before the stream opens). Each says what is off and who can change it. - case "consent.ai.required": - return "insights.coach.errorConsent"; - case "ai.record.notPermitted": - return "insights.coach.errorNotPermitted"; - case "ai.unavailable": - return "insights.coach.errorUnavailable"; - case "ai.provider.none": - return "insights.coach.errorNoProvider"; - case "module.disabled": - return "insights.coach.errorCoachOff"; - // A demo deploy refuses every write, the chat POST included. That is - // the demo's nature, not an outage, and the line says so. - case DEMO_READ_ONLY_CODE: - return "insights.coach.errorDemo"; - default: - // The operator switched the Coach (or all AI) off on this server. - if (code.startsWith("assistant.disabled.")) { - return "insights.coach.errorOperatorOff"; - } - // Forward-compat: try `insights.coach.` for codes that - // ship their own translation (e.g. legacy `errorProvider`). - return `insights.coach.${code}`; - } -} - /** * v1.22 — Coach accompanying charts, Phase 1: the renderer half. * diff --git a/src/components/insights/coach-panel/coach-error-key.ts b/src/components/insights/coach-panel/coach-error-key.ts new file mode 100644 index 000000000..86e8fa1e7 --- /dev/null +++ b/src/components/insights/coach-panel/coach-error-key.ts @@ -0,0 +1,78 @@ +/** + * The Coach's error-code → i18n-key resolver, split out of `chat-bubble.tsx` + * (file-size split, code motion). The bubble renders the key; an unknown code + * falls back to the generic provider copy there. + */ +import { DEMO_READ_ONLY_CODE } from "@/lib/demo-refusal"; + +/** + * Map server-emitted error codes to specific Coach i18n + * keys. The chat route distinguishes the daily user-quota + * (`coach.budget.exceeded`, returned as a JSON 429) from the provider + * rate-limit (`coach.provider.rate_limited`, streamed as an SSE error + * frame). Both used to surface as the generic provider-unavailable + * copy; we now route each to its dedicated translation so the user + * understands whether the limit is on their side (reset at UTC + * midnight) or transient on the provider side (retry in ~5 min). + * + * Exported so the resolver can be pinned by unit tests without + * standing up the whole thread renderer. + */ +export function errorCodeToI18nKey(code: string): string { + switch (code) { + case "coach.budget.exceeded": + return "insights.coach.dailyLimitBody"; + case "coach.provider.rate_limited": + return "insights.coach.providerRateLimitBody"; + case "coach.network": + // A dropped network is the user's local + // problem and needs a different next action ("come back online") + // than a provider failure ("try again in a moment"). Split out + // so the user sees the actionable copy in the offline branch. + return "insights.coach.errorNetwork"; + case "coach.provider.credential_expired": + // The user's primary AI provider credential is dead + // (auth-class failure). The next action is "reconnect", not "try + // again later", so it carries its own copy pointing at Settings. + return "insights.coach.errorCredentialExpired"; + case "coach.provider.none": + // No AI provider is configured anywhere, so "try again + // in a moment" is the wrong instruction (it will 422 forever). Send + // the user to guided setup with a one-line explainer + a Settings + // link rendered alongside the bubble copy. + return "insights.coach.errorNoProvider"; + case "coach.provider.reasoning_only": + // #1276 — the model was reached and answered with its reasoning + // only, even after being asked once more. "Try again in a moment" + // would be the wrong advice; the copy names what happened. + return "insights.coach.errorReasoningOnly"; + case "coach.provider.unavailable": + case "coach.provider.empty": + case "coach.stream": + return "insights.coach.errorProvider"; + // The capability refusals (`meta.errorCode` on a 403/422/503 + // before the stream opens). Each says what is off and who can change it. + case "consent.ai.required": + return "insights.coach.errorConsent"; + case "ai.record.notPermitted": + return "insights.coach.errorNotPermitted"; + case "ai.unavailable": + return "insights.coach.errorUnavailable"; + case "ai.provider.none": + return "insights.coach.errorNoProvider"; + case "module.disabled": + return "insights.coach.errorCoachOff"; + // A demo deploy refuses every write, the chat POST included. That is + // the demo's nature, not an outage, and the line says so. + case DEMO_READ_ONLY_CODE: + return "insights.coach.errorDemo"; + default: + // The operator switched the Coach (or all AI) off on this server. + if (code.startsWith("assistant.disabled.")) { + return "insights.coach.errorOperatorOff"; + } + // Forward-compat: try `insights.coach.` for codes that + // ship their own translation (e.g. legacy `errorProvider`). + return `insights.coach.${code}`; + } +} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 2ea6f4301..941d4c55c 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -48,11 +48,8 @@ import { translateOrNull } from "@/lib/i18n/translate-code"; // per-message actions live in `chat-bubble.tsx`, the read-aloud stack in // `read-aloud.tsx`. The previously public names re-export from here so // external call sites are unchanged. -export { - TypingDots, - errorCodeToI18nKey, - selectCoachChartTokens, -} from "./chat-bubble"; +export { TypingDots, selectCoachChartTokens } from "./chat-bubble"; +export { errorCodeToI18nKey } from "./coach-error-key"; /** * v1.4.20 — message-thread renderer. diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 6ddd46018..a8e253993 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -34,7 +34,11 @@ import { rememberReasoningDialect, } from "./reasoning/dialect-cache"; import { uncontrolledThinkingRoom } from "./reasoning/levels"; -import { answerOf, reasoningTitleOf } from "./reasoning/support"; +import { + ReasoningCollector, + separateMessage, + takeReasoning, +} from "./local-reasoning"; interface LocalClientConfig { apiKey?: string | null; @@ -99,59 +103,6 @@ function withStrictJsonPrefix(messages: AiMessage[]): AiMessage[] { return out; } -/** - * v1.41 — collects what a call reasoned and reports it to `onReasoning`. - * Local servers return the reasoning as `reasoning_content` (llama.cpp, - * LiteLLM) or `reasoning` (vLLM, Ollama, LM Studio), on the message and on - * each streamed delta, or inline as a leading `…` block. - * https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md, - * https://docs.vllm.ai/en/latest/features/reasoning_outputs.html, - * https://docs.ollama.com/api/openai-compatibility - */ -class ReasoningCollector { - private text = ""; - private titled = false; - private finished = false; - constructor(private readonly params: CompletionParams) {} - - private get emit() { - return this.params.reasoning ? this.params.onReasoning : undefined; - } - - /** A streamed piece of reasoning; the title goes out once it has closed. */ - add(delta: string): void { - this.text += delta; - if (!this.titled && this.emit) { - const title = reasoningTitleOf(this.text); - if (title) { - this.titled = true; - this.emit({ kind: "title", text: title }); - } - } - } - - /** The reasoning is complete (the answer started, or the call ended). */ - finish(): void { - if (this.finished) return; - this.finished = true; - const text = this.text.trim(); - if (!text || !this.emit) return; - if (!this.titled) { - const title = reasoningTitleOf(text); - if (title) this.emit({ kind: "title", text: title }); - } - this.emit({ kind: "text", text }); - } - - result(tokens: number | null): CompletionResult["reasoning"] { - this.finish(); - if (!this.params.reasoning) return undefined; - this.emit?.({ kind: "done", text: "" }); - const text = this.text.trim(); - return { summary: text ? [text] : [], tokens }; - } -} - /** * The empty-reply error. `httpStatus: 0` is the sentinel the chain classifier * treats as a hard failure; `kind` says whether the reply held reasoning and @@ -171,15 +122,6 @@ function emptyReplyError(hadReasoning: boolean): Error { return err; } -/** The reasoning text a non-streamed message carries, if any. */ -function messageReasoning(message: unknown): string | null { - const m = (message ?? {}) as Record; - const text = [m.reasoning_content, m.reasoning].find( - (v): v is string => typeof v === "string" && v.trim().length > 0, - ); - return text ?? null; -} - /** * Talks to OpenAI-compatible servers: local runtimes (Ollama, LocalAI, * LM Studio, vLLM, …) AND hosted gateways (LiteLLM, OpenRouter, any @@ -226,7 +168,7 @@ export class LocalOpenAICompatibleClient implements AIProvider { body: string; jsonDialect: LocalJsonDialect; /** The ceiling holds room for reasoning on top of the answer. */ - reasoningRoom: boolean; + room: boolean; } { const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; // v1.28.28 (#470) — per-endpoint JSON dialect (see json-dialect.ts). @@ -308,7 +250,7 @@ export class LocalOpenAICompatibleClient implements AIProvider { headers, body, jsonDialect, - reasoningRoom: thinkingTokens > 0, + room: thinkingTokens > 0, }; } @@ -386,52 +328,13 @@ export class LocalOpenAICompatibleClient implements AIProvider { return true; } - /** - * #1276 — the answer and the reasoning of a non-streamed message, for - * every caller: `reasoning_content` / `reasoning` beside `content`, an - * inline `` block (or only its closing tag), or a reply that filled - * a ceiling sized for thinking with unlabelled text (`answerOf`). Reasoning - * never stays in `content`; only a caller that asked for it is told. - */ - private separate( - params: CompletionParams, - message: { content?: string | null } | undefined, - finishReason: string | undefined, - reasoningRoom: boolean, - ): { content: string; hadReasoning: boolean; collector: ReasoningCollector } { - const collector = new ReasoningCollector(params); - const fielded = messageReasoning(message); - if (fielded) collector.add(fielded); - const raw = message?.content ?? ""; - const separated = answerOf(raw, { - fieldedReasoning: fielded !== null, - finishReason, - hasToolCalls: false, - reasoningRoom, - }); - if (separated.reasoning) collector.add(separated.reasoning); - if (separated.unseparated) this.signalUnseparated(); - return { - content: separated.content, - hadReasoning: fielded !== null || separated.content !== raw, - collector, - }; - } - - /** A reply that was reasoning the server never separated from the answer. */ - private signalUnseparated(): void { - emitSignal({ - action: "ai.local.reasoning_unseparated", - level: "warn", - meta: { provider: "local" }, - }); - } - async generateCompletion( params: CompletionParams, ): Promise { - const { url, headers, body, jsonDialect, reasoningRoom } = - this.buildRequest(params, false); + const { url, headers, body, jsonDialect, room } = this.buildRequest( + params, + false, + ); // safeFetch defaults: no redirect-follow (the local endpoint is the // most exploitable on this surface — a user-controlled baseUrl that @@ -560,11 +463,11 @@ export class LocalOpenAICompatibleClient implements AIProvider { }); const finish = json.choices?.[0]?.finish_reason; - const { content, hadReasoning, collector } = this.separate( + const { content, hadReasoning, collector } = separateMessage( params, json.choices?.[0]?.message, finish, - reasoningRoom, + room, ); if (!content) { if (this.learnAnswerlessReasoning(params, hadReasoning, finish)) { @@ -624,10 +527,7 @@ export class LocalOpenAICompatibleClient implements AIProvider { params: CompletionParams, onDelta: (delta: string) => void, ): Promise { - const { url, headers, body, reasoningRoom } = this.buildRequest( - params, - true, - ); + const { url, headers, body, room } = this.buildRequest(params, true); const egress = aiEgressPolicyFor(url, { operatorTrusted: this.config.operatorTrusted, }); @@ -690,17 +590,13 @@ export class LocalOpenAICompatibleClient implements AIProvider { label: "local AI endpoint", model: this.config.model, }); - const { - content: buffered, - hadReasoning, - collector, - } = this.separate( + const { content, hadReasoning, collector } = separateMessage( params, json?.choices?.[0]?.message, json?.choices?.[0]?.finish_reason, - reasoningRoom, + room, ); - if (!buffered) { + if (!content) { if ( this.learnAnswerlessReasoning( params, @@ -714,9 +610,9 @@ export class LocalOpenAICompatibleClient implements AIProvider { throw emptyReplyError(hadReasoning); } const reasoning = collector.result(null); - onDelta(buffered); + onDelta(content); return { - content: buffered, + content, tokensUsed: json?.usage?.total_tokens ?? null, cachedInputTokens: json ? parseCachedTokens(json) : null, model: this.config.model, @@ -738,8 +634,7 @@ export class LocalOpenAICompatibleClient implements AIProvider { // v1.41 — reasoning on the stream: the `reasoning_content` / `reasoning` // delta fields, or a leading `` block inside `content`. Inline // reasoning never reaches `onDelta`; it would otherwise show up as the - // answer. #1276 — for every caller: reasoning is never an answer, and - // only a caller that asked for it hears about it (`ReasoningCollector`). + // answer. #1276 — for every caller (only one that asked is told). const collector = new ReasoningCollector(params); let thinkState: "detect" | "inside" | "passthrough" = "detect"; let pending = ""; @@ -889,21 +784,14 @@ export class LocalOpenAICompatibleClient implements AIProvider { collector.add(pending); } pending = ""; - // #1276 — what the detector cannot see while streaming: a closing tag - // with no opening one (the chat template opened the block), or a reply - // that filled a ceiling sized for thinking with unlabelled text. The - // deltas already sent are a liveness signal only (the Coach counts - // them); the returned content is what every guard reads. - const separated = answerOf(content, { + // #1276 — a lone ``, or unlabelled text that filled the ceiling. + // Sent deltas are a liveness signal only; the guards read `content`. + const tail = takeReasoning(collector, content, { fieldedReasoning: streamedReasoning, finishReason, - hasToolCalls: false, - reasoningRoom, + reasoningRoom: room, }); - if (separated.reasoning) collector.add(separated.reasoning); - if (separated.unseparated) this.signalUnseparated(); - const tookReasoning = separated.content !== content; - content = separated.content; + content = tail.content; if (!content) { // A stream that ended with a failure frame and no token reports the // failure, not "empty content". @@ -914,7 +802,7 @@ export class LocalOpenAICompatibleClient implements AIProvider { }); // An unclosed inline `` block is reasoning too. const hadReasoning = - streamedReasoning || endState === "inside" || tookReasoning; + streamedReasoning || endState === "inside" || tail.took; if (this.learnAnswerlessReasoning(params, hadReasoning, finishReason)) { return this.generateCompletionStream(params, onDelta); } diff --git a/src/lib/ai/local-reasoning.ts b/src/lib/ai/local-reasoning.ts new file mode 100644 index 000000000..7584bfb38 --- /dev/null +++ b/src/lib/ai/local-reasoning.ts @@ -0,0 +1,124 @@ +/** + * What the Local client does with the reasoning a server sends back: collect + * it for the caller that asked, and keep it out of the answer for every + * caller. Split out of `local-client.ts`; that client is its only importer. + * + * Local servers return the reasoning as `reasoning_content` (llama.cpp, + * LiteLLM) or `reasoning` (vLLM, Ollama, LM Studio), on the message and on + * each streamed delta, or inline in `content`. + * https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md, + * https://docs.vllm.ai/en/latest/features/reasoning_outputs.html, + * https://docs.ollama.com/api/openai-compatibility + * + * Server-only. + */ +import { emitSignal } from "@/lib/logging/signal"; + +import { answerOf, reasoningTitleOf } from "./reasoning/support"; +import type { CompletionParams, CompletionResult } from "./types"; + +/** + * Collects what a call reasoned and reports it to `onReasoning`, for + * a caller that asked for reasoning; any other caller is told nothing. + */ +export class ReasoningCollector { + private text = ""; + private titled = false; + private finished = false; + constructor(private readonly params: CompletionParams) {} + + private get emit() { + return this.params.reasoning ? this.params.onReasoning : undefined; + } + + /** A streamed piece of reasoning; the title goes out once it has closed. */ + add(delta: string): void { + this.text += delta; + if (!this.titled && this.emit) { + const title = reasoningTitleOf(this.text); + if (title) { + this.titled = true; + this.emit({ kind: "title", text: title }); + } + } + } + + /** The reasoning is complete (the answer started, or the call ended). */ + finish(): void { + if (this.finished) return; + this.finished = true; + const text = this.text.trim(); + if (!text || !this.emit) return; + if (!this.titled) { + const title = reasoningTitleOf(text); + if (title) this.emit({ kind: "title", text: title }); + } + this.emit({ kind: "text", text }); + } + + result(tokens: number | null): CompletionResult["reasoning"] { + this.finish(); + if (!this.params.reasoning) return undefined; + this.emit?.({ kind: "done", text: "" }); + const text = this.text.trim(); + return { summary: text ? [text] : [], tokens }; + } +} + +/** The reasoning text a non-streamed message carries, if any. */ +export function messageReasoning(message: unknown): string | null { + const m = (message ?? {}) as Record; + const text = [m.reasoning_content, m.reasoning].find( + (v): v is string => typeof v === "string" && v.trim().length > 0, + ); + return text ?? null; +} + +/** + * #1276 — take the reasoning out of `content` (`answerOf`: an inline + * `` block or only its closing tag, or unlabelled text that filled a + * ceiling sized for thinking) and hand it to `collector`. Returns the answer + * and whether anything was taken. For every caller: reasoning is never an + * answer, asked for or not. + */ +export function takeReasoning( + collector: ReasoningCollector, + content: string, + reply: { + fieldedReasoning: boolean; + finishReason: string | undefined; + reasoningRoom: boolean; + }, +): { content: string; took: boolean } { + const separated = answerOf(content, { ...reply, hasToolCalls: false }); + if (separated.reasoning) collector.add(separated.reasoning); + if (separated.unseparated) { + emitSignal({ + action: "ai.local.reasoning_unseparated", + level: "warn", + meta: { provider: "local" }, + }); + } + return { content: separated.content, took: separated.content !== content }; +} + +/** + * The answer and the reasoning of a non-streamed message: the reasoning + * fields beside `content`, and whatever `takeReasoning` finds inside it. + */ +export function separateMessage( + params: CompletionParams, + message: { content?: string | null } | undefined, + finishReason: string | undefined, + reasoningRoom: boolean, +): { content: string; hadReasoning: boolean; collector: ReasoningCollector } { + const collector = new ReasoningCollector(params); + const fielded = messageReasoning(message); + if (fielded) collector.add(fielded); + const { content, took } = takeReasoning(collector, message?.content ?? "", { + fieldedReasoning: fielded !== null, + finishReason, + reasoningRoom, + }); + return { content, hadReasoning: fielded !== null || took, collector }; +} diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 20dce6fea..b5fe23a83 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -2,7 +2,12 @@ import { safeFetch } from "@/lib/safe-fetch"; import { callTimeoutMs } from "./effective-timeout"; import { annotate } from "@/lib/logging/context"; import { emitSignal } from "@/lib/logging/signal"; -import type { AIProvider, CompletionParams, CompletionResult } from "./types"; +import type { + AIProvider, + AiToolCall, + CompletionParams, + CompletionResult, +} from "./types"; import { isCanonicalOpenAIEndpoint, selectOpenAIChatCompletionsCapabilities, @@ -44,6 +49,7 @@ import { parseOpenAIToolCalls, raiseEmbeddedError, sanitiseBodyExcerpt, + type OpenAIChoice, type OpenAIResponseJson, } from "./openai-wire"; @@ -202,30 +208,7 @@ export class OpenAIClient implements AIProvider { // level (sent below unless the wire already carries `none`). const entryEffort = !params.reasoning && this.isGateway ? this.reasoningEffort : null; - // Reasoning tokens count against the completion budget on every one of - // these wires, so the thinking budget rides on top of the answer budget - // (a ceiling, not a charge). Without it a 600-token Coach round spends - // its whole allowance thinking and returns no answer. - const wireLevel = - reasoningWire.kind === "none" || reasoningWire.effort === "none" - ? null - : levelOfWireEffort(reasoningWire.effort); - // #1276 — on a gateway the room follows the call's level, not the wire: - // a model behind it may reason whatever `reasoning_effort` says, `none` - // included, and a `none` sent because of the #1267 lesson or the - // person's Off must not leave it the bare answer budget to think in. - // Without a level of its own, a call gets the `low` room once the model - // has shown it reasons unasked. - const thinkingTokens = this.isGateway - ? params.reasoning - ? uncontrolledThinkingRoom(params.reasoning.effort) - : learnedReasoningDialect(this.reasoningDialectKey()) === - ANSWERS_ONLY_UNREASONED - ? uncontrolledThinkingRoom("off") - : 0 - : wireLevel - ? REASONING_THINKING_BUDGET[wireLevel === "off" ? "low" : wireLevel] - : 0; + const thinkingTokens = this.thinkingRoom(params, reasoningWire); const tokenBudget = (params.maxTokens ?? 1000) + thinkingTokens; const capabilityParams = capabilities.supportsSamplingControls ? { @@ -427,36 +410,8 @@ export class OpenAIClient implements AIProvider { const choice = json.choices?.[0]; const toolCalls = parseOpenAIToolCalls(choice); - // v1.41 — what the call reasoned. Gateways return the text as - // `reasoning` (OpenRouter, vLLM, Ollama) or `reasoning_content` - // (LiteLLM, llama.cpp), some both with the same text; `api.openai.com` - // returns none on this wire. One copy is kept. - const message = (choice?.message ?? {}) as Record; - const fieldedReasoning = [ - message.reasoning, - message.reasoning_content, - ].find((v): v is string => typeof v === "string" && v.trim().length > 0); - // #1276 — reasoning the server did not separate never becomes the - // answer: an inline `` block (or only its closing tag, when the - // chat template opened it), or a reply that filled a ceiling sized for - // thinking with unlabelled text. Only a gateway's ceiling is sized that - // way here; `api.openai.com` never inlines its reasoning. - const rawContent = choice?.message?.content ?? ""; - const separated = answerOf(rawContent, { - fieldedReasoning: fieldedReasoning !== undefined, - finishReason: choice?.finish_reason, - hasToolCalls: toolCalls !== undefined, - reasoningRoom: this.isGateway && thinkingTokens > 0, - }); - const inlineReasoning = separated.content !== rawContent; - if (separated.unseparated) { - emitSignal({ - action: "ai.gateway.reasoning_unseparated", - level: "warn", - meta: { provider: this.type }, - }); - } - let content: string = separated.content; + const reply = this.separateReasoning(choice, toolCalls, thinkingTokens); + let content: string = reply.content; // v1.28.28 (#470) — gateway JSON-mode shim. LiteLLM (and similar // OpenAI-compatible proxies) translate `response_format: json_object` @@ -485,7 +440,7 @@ export class OpenAIClient implements AIProvider { // and ask again. Same rule as the Local client. const sentEffort = reasoningWire.kind !== "none" ? reasoningWire.effort : entryEffort; - const hadReasoning = fieldedReasoning !== undefined || inlineReasoning; + const hadReasoning = reply.hadReasoning; if ( this.isGateway && sentEffort !== "none" && @@ -528,8 +483,7 @@ export class OpenAIClient implements AIProvider { throw err; } - const reasoningText = fieldedReasoning ?? separated.reasoning; - const summary = reasoningText ? [reasoningText.trim()] : []; + const summary = reply.reasoning ? [reply.reasoning.trim()] : []; const emit = params.reasoning ? params.onReasoning : undefined; if (emit) { for (const text of summary) { @@ -539,6 +493,7 @@ export class OpenAIClient implements AIProvider { } emit({ kind: "done", text: "" }); } + const message = (choice?.message ?? {}) as Record; const roundTrip = ROUND_TRIP_FIELDS.filter( (field) => message[field] !== undefined && message[field] !== null, ).map((field) => ({ [field]: message[field] })); @@ -554,9 +509,7 @@ export class OpenAIClient implements AIProvider { model: this.config.model, providerType: this.type, ...(toolCalls ? { toolCalls } : {}), - // #1276 — the tool calls themselves decide, as on the Anthropic and - // Codex wires: vLLM, Ollama and LiteLLM can put `stop` beside them, - // and the loop runs calls only on `tool_calls`. + // #1276 — the calls decide, as on the Anthropic and Codex wires. finishReason: toolCalls ? "tool_calls" : mapFinishReason(choice?.finish_reason), @@ -587,6 +540,79 @@ export class OpenAIClient implements AIProvider { // ── v1.41 reasoning ─────────────────────────────────────────────────────── + /** + * The thinking room on top of the answer budget. Reasoning tokens count + * against the completion budget on every one of these wires, so without it + * a 600-token Coach round spends its whole allowance thinking and returns + * no answer. A ceiling, not a charge. + * + * #1276 — on a gateway the room follows the call's level, not the wire: a + * model behind it may reason whatever `reasoning_effort` says, `none` + * included, and a `none` sent for the person's Off or after the #1267 + * lesson must not leave it the bare answer budget to think in. Without a + * level of its own, a call gets the `low` room once the model has shown it + * reasons unasked. + */ + private thinkingRoom( + params: CompletionParams, + wire: OpenAIReasoningWire, + ): number { + if (this.isGateway) { + if (params.reasoning) { + return uncontrolledThinkingRoom(params.reasoning.effort); + } + return learnedReasoningDialect(this.reasoningDialectKey()) === + ANSWERS_ONLY_UNREASONED + ? uncontrolledThinkingRoom("off") + : 0; + } + if (wire.kind === "none" || wire.effort === "none") return 0; + const level = levelOfWireEffort(wire.effort); + return REASONING_THINKING_BUDGET[level === "off" ? "low" : level]; + } + + /** + * The answer and what the call reasoned. Gateways return the reasoning as + * `reasoning` (OpenRouter, vLLM, Ollama) or `reasoning_content` (LiteLLM, + * llama.cpp), some in both with the same text: one copy is kept. + * `api.openai.com` returns none on this wire. + * + * #1276 — reasoning the server did not separate never becomes the answer: + * an inline `` block (or only its closing tag, when the chat + * template opened it), or a reply that filled a ceiling sized for thinking + * with unlabelled text (`answerOf`). Only a gateway's ceiling is sized that + * way; `api.openai.com` never inlines its reasoning. + */ + private separateReasoning( + choice: OpenAIChoice | undefined, + toolCalls: AiToolCall[] | undefined, + thinkingTokens: number, + ): { content: string; reasoning: string | null; hadReasoning: boolean } { + const message = (choice?.message ?? {}) as Record; + const fielded = [message.reasoning, message.reasoning_content].find( + (v): v is string => typeof v === "string" && v.trim().length > 0, + ); + const raw = choice?.message?.content ?? ""; + const separated = answerOf(raw, { + fieldedReasoning: fielded !== undefined, + finishReason: choice?.finish_reason, + hasToolCalls: toolCalls !== undefined, + reasoningRoom: this.isGateway && thinkingTokens > 0, + }); + if (separated.unseparated) { + emitSignal({ + action: "ai.gateway.reasoning_unseparated", + level: "warn", + meta: { provider: this.type }, + }); + } + return { + content: separated.content, + reasoning: fielded ?? separated.reasoning, + hadReasoning: fielded !== undefined || separated.content !== raw, + }; + } + private reasoningDialectKey() { return { provider: "openai",