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",