Files
HealthLog/src/lib/ai/reasoning/support.ts
T
Marc-André Bombeck 8787d71d1c fix(ai): recognise the gpt-6 model family in every model gate
The ChatGPT Codex backend and the OpenAI API serve gpt-6-sol, gpt-6-luna,
gpt-6-astra and gpt-6.1-sol, but the reasoning, Chat Completions and vision
gates only knew the gpt-5 line. A gpt-6 model got no reasoning control and
no real "off", went out on the legacy wire with max_tokens and a
temperature that reasoning models refuse, and was denied image input.

The three gates now read one classifier, openai-model-family.ts, so a new
generation is taught in one place. Every GPT from 5.1 on, the gpt-6 line
included, is the family that takes effort "none"; every GPT from 5 on reads
images. The upstream catalog (codex-rs/models-manager/models.json) lists
image input for every gpt-6 slug and the same reasoning presets as the
gpt-5.6 line; the presets omit "none" for gpt-5.5 too, which accepts it.

The Codex slug ladder moves into codex-slug-chain.ts and gains gpt-6-sol,
gpt-6.1-sol and gpt-6-luna. gpt-5.6-terra stays first as the slug verified
against the backend; the catalog names gpt-6-sol as its upgrade, and a
rotation retires a whole line at once, so the gpt-6 rungs follow before
terra's own siblings. The ladder test now holds every rung to a recognised
reasoning family with a real off and image input. The OpenAI presets offer
gpt-6.1-sol and gpt-6-luna.
2026-10-24 10:11:16 +02:00

300 lines
11 KiB
TypeScript

/**
* v1.41 — what each provider can do with reasoning, read from the provider
* type and the model name alone.
*
* No admin keeps a model list. A name this file does not recognise is treated
* optimistically: the client sends the request, and if the endpoint refuses a
* parameter it retries without it and learns the refusal per endpoint and
* model (`dialect-cache.ts`). What is reported here is therefore the starting
* assumption, not a promise; the settings use `offIsReal` to label the lowest
* option "Off" or "Minimal".
*
* The wire facts behind each branch, with sources:
*
* - Codex backend: Responses API `reasoning: { effort, summary }` plus
* `include: ["reasoning.encrypted_content"]` for the stateless round trip.
* A live probe on 2026-10-06 against the current slug (`gpt-5.5`) answered
* `effort: "none"` with a non-reasoning reply and rejected `minimal` with
* "Supported values are: 'none', 'low', 'medium', 'high', and 'xhigh'",
* so "off" is real there. https://developers.openai.com/api/docs/guides/reasoning,
* https://developers.openai.com/cookbook/examples/responses_api/reasoning_items
* - OpenAI Chat Completions: `reasoning_effort`, no summaries and no
* reasoning items to hand back between tool rounds, so reasoning is only
* sent on rounds without tools. `none` exists from gpt-5.1 on (the gpt-6
* line included); gpt-5 knows `minimal`; the o-series starts at `low`.
* https://platform.openai.com/docs/api-reference/chat/create
* - Anthropic: manual `thinking: { type: "enabled", budget_tokens }` up to
* the 4.5 generation; adaptive `thinking: { type: "adaptive" }` with
* `output_config.effort` from 4.6 on (`budget_tokens` is deprecated on 4.6
* and refused from 4.7). From the 5 generation thinking is on whatever the
* request says, so "off" becomes the lowest effort. Sampling parameters
* are refused from 4.7 on.
* https://platform.claude.com/docs/en/build-with-claude/extended-thinking,
* https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking,
* https://platform.claude.com/docs/en/build-with-claude/effort
* - OpenRouter: `reasoning: { effort }` with `none` and `minimal` accepted,
* reply `reasoning` (text) and `reasoning_details[]`, which must go back
* unmodified on the assistant message of a tool round.
* https://openrouter.ai/docs/use-cases/reasoning-tokens
* - Other OpenAI-compatible gateways and local servers: `reasoning_effort`,
* reply `reasoning_content` (llama.cpp, LiteLLM) or `reasoning` (vLLM,
* Ollama, LM Studio), some models inline `<think>…</think>`.
* https://docs.litellm.ai/docs/reasoning_content,
* https://docs.vllm.ai/en/latest/features/reasoning_outputs.html,
* https://docs.ollama.com/api/openai-compatibility,
* https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
*
* Client-safe: no server import.
*/
import {
openAIModelFamily,
type OpenAIReasoningFamily,
} from "../openai-model-family";
import type { ProviderType } from "../types";
import type { ReasoningLevel } from "./levels";
export interface ReasoningSupport {
/** The provider takes an effort (or a budget) at all. */
effort: boolean;
/** It returns readable summaries of the reasoning. */
summaries: boolean;
/** Summaries arrive while the call runs, not only after it. */
liveSummaries: boolean;
/** Reasoning state has to travel between tool rounds (`providerState`). */
stateRoundTrip: boolean;
/** "Off" switches reasoning off; false means it falls to the lowest level. */
offIsReal: boolean;
}
const NONE: ReasoningSupport = {
effort: false,
summaries: false,
liveSummaries: false,
stateRoundTrip: false,
offIsReal: true,
};
// ── Anthropic ───────────────────────────────────────────────────────────────
export interface AnthropicModelVersion {
major: number;
minor: number;
}
/**
* The generation of a Claude model id: `claude-<tier>-<major>[-<minor>][-<date>]`
* from Claude 4 on (`claude-sonnet-4-6`, `claude-opus-5`, `claude-opus-4-20250514`),
* `claude-<major>[-<minor>]-<tier>` before (`claude-3-7-sonnet-latest`). A
* dotted minor (`claude-sonnet-4.5`, the gateway spelling) reads the same. An
* eight-digit segment is a snapshot date, not a minor. `null` for anything
* else (a proxy alias, a typo): the client then tries the current dialect and
* learns from the answer.
*/
export function anthropicModelVersion(
model: string,
): AnthropicModelVersion | null {
const m = model.toLowerCase().replace(/^anthropic\//, "");
const modern =
/^claude-(?:opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:[-.](\d{1,2}))?(?:$|[-@:])/.exec(
m,
);
if (modern) {
return { major: Number(modern[1]), minor: Number(modern[2] ?? 0) };
}
const legacy = /^claude-(\d)(?:[-.](\d))?-(?:opus|sonnet|haiku)/.exec(m);
if (legacy) {
return { major: Number(legacy[1]), minor: Number(legacy[2] ?? 0) };
}
return null;
}
function atLeast(
v: AnthropicModelVersion,
major: number,
minor: number,
): boolean {
return v.major > major || (v.major === major && v.minor >= minor);
}
/**
* How a Claude model is asked to think. `manual` is the budget form (up to
* 4.5, and 3.7), `adaptive` the effort form (4.6 on, and every name this
* function cannot read), `none` a model that cannot think (3.5 and older).
*/
export type AnthropicThinkingDialect = "adaptive" | "manual" | "none";
export function anthropicThinkingDialect(
model: string,
): AnthropicThinkingDialect {
const v = anthropicModelVersion(model);
if (!v) return "adaptive";
if (v.major < 3) return "none";
if (v.major === 3) return v.minor >= 7 ? "manual" : "none";
return atLeast(v, 4, 6) ? "adaptive" : "manual";
}
/**
* True when the model refuses `temperature` / `top_p` / `top_k` (4.7 on; the
* 5 generation refuses any value, including the default). Unknown names
* return false: the client keeps today's wire and learns from a refusal.
*/
export function anthropicRefusesSampling(model: string): boolean {
const v = anthropicModelVersion(model);
return v !== null && atLeast(v, 4, 7);
}
/** True from the 5 generation on: thinking runs whatever the request says. */
export function anthropicThinksAlways(model: string): boolean {
const v = anthropicModelVersion(model);
return v !== null && v.major >= 5;
}
// ── OpenAI ──────────────────────────────────────────────────────────────────
export type { OpenAIReasoningFamily };
/**
* The reasoning family of an OpenAI model id, or `null` for a model that does
* not reason (`gpt-4o`, `gpt-4.1`) or is not recognised. `gpt-5.1+` (every
* generation from 5.1 on, the gpt-6 line included) knows `none`, bare `gpt-5`
* (and its mini / nano) stops at `minimal`, the o-series at `low`. The
* reading itself lives in `openai-model-family.ts`.
*/
export function openAIReasoningFamily(model: string): OpenAIReasoningFamily {
return openAIModelFamily(model)?.reasoning ?? null;
}
/** The lowest `reasoning_effort` an OpenAI reasoning family accepts. */
export function openAIOffEffort(
family: Exclude<OpenAIReasoningFamily, null>,
): "none" | "minimal" | "low" {
return family === "gpt-5.1+"
? "none"
: family === "gpt-5"
? "minimal"
: "low";
}
/** True for an OpenRouter base URL, whose reasoning dialect differs. */
export function isOpenRouterEndpoint(baseUrl: string): boolean {
try {
const host = new URL(baseUrl).hostname.toLowerCase();
return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
} catch {
return false;
}
}
// ── Support matrix ──────────────────────────────────────────────────────────
/**
* What `providerType` serving `model` can do with reasoning. `baseUrl` only
* matters for the gateway tag (OpenRouter has its own dialect) and the
* operator's OpenAI provider (a non-OpenAI base URL is a gateway in all but
* name).
*/
export function reasoningSupport(
providerType: ProviderType,
model: string,
opts: { baseUrl?: string } = {},
): ReasoningSupport {
switch (providerType) {
case "codex":
return {
effort: true,
summaries: true,
liveSummaries: true,
stateRoundTrip: true,
offIsReal: true,
};
case "anthropic": {
const dialect = anthropicThinkingDialect(model);
if (dialect === "none") return NONE;
return {
effort: true,
summaries: true,
liveSummaries: false,
stateRoundTrip: true,
offIsReal: !anthropicThinksAlways(model),
};
}
case "admin-key": {
const family = openAIReasoningFamily(model);
if (!family) return NONE;
return {
effort: true,
summaries: false,
liveSummaries: false,
stateRoundTrip: false,
offIsReal: family === "gpt-5.1+",
};
}
case "openai-compatible": {
const openRouter = opts.baseUrl
? isOpenRouterEndpoint(opts.baseUrl)
: false;
return {
effort: true,
summaries: true,
liveSummaries: false,
stateRoundTrip: openRouter,
offIsReal: true,
};
}
case "local":
return {
effort: true,
summaries: true,
liveSummaries: true,
stateRoundTrip: false,
offIsReal: true,
};
case "none":
return NONE;
}
}
// ── Shared parsing helpers ──────────────────────────────────────────────────
/**
* The title of a reasoning summary: its first line when that line is wholly
* bold (`**Checking the window**`), the convention the Responses API
* summaries follow. `null` when the summary has no such line yet or at all.
*/
export function reasoningTitleOf(text: string): string | null {
const m = /^\s*\*\*([^*\n]+?)\*\*\s*(?:\n|$)/.exec(text);
return m ? m[1].trim() : null;
}
/**
* Split a reply that inlines its reasoning as a leading `<think>…</think>`
* block (some local models do). Only a block at the very start counts; a
* mention later in the prose stays prose. An unclosed block means the answer
* budget ran out inside the reasoning: everything is reasoning, no answer.
*/
export function splitThinkTags(content: string): {
content: string;
reasoning: string | null;
} {
const m = /^\s*<think>([\s\S]*?)(<\/think>|$)/.exec(content);
if (!m) return { content, reasoning: null };
const reasoning = m[1].trim();
const rest = m[2] ? content.slice(m[0].length).replace(/^\s+/, "") : "";
return { content: rest, reasoning: reasoning.length > 0 ? reasoning : null };
}
/** The level a provider's wire effort corresponds to, for `downgradedTo`. */
export function levelOfWireEffort(effort: string | null): ReasoningLevel {
switch (effort) {
case "low":
case "medium":
case "high":
return effort;
case "xhigh":
case "max":
return "high";
default:
return "off";
}
}