/** * Niveau de raisonnement d'un run de l'agent de code (MIN-122). Logique PURE : * vocabulary (`off` / `minimal` / `low` / `medium` / `high` / `xhigh` / * `max`) and its translation into query fields, provider by provider. * * THIS VOCABULARY IS THAT OF THE MODELS, NOT OURS. He does not invent himself: he * is published model by model in `/models` of OpenRouter (object `reasoning`, * field `supported_efforts`), and that's where it goes down to the selector — * 128 out of 406 models publish one, and two of them rarely have the same * (`high|medium|low`, `xhigh|high|medium|low|minimal`, `max|high|low`…). Ce que * the screen suggests is therefore what the CHOSEN model accepts, and nothing else * (`reasoningLevelsFor`): three generic levels applied to all * n'affichaient ni ce qu'il savait faire de plus, ni ce qu'il ne savait pas * faire du tout. * * Shared client + server (NO server-only import), like * `lib/agent-providers.ts` whose capacity it reads: the launch selector has * need the level list, the launch route of `isReasoningLevel`, * and the `reasoningRequestFields` loop. One file rather than two * halves that could diverge. * * UN SEUL vocabulaire produit, traduit au dernier moment : `reasoning: { effort }` * (OpenRouter), `reasoning_effort` (OpenAI/Gemini) or `thinking` (the * compatibility Anthropic retains its native contract here). * * The gate is the register: a provider without `reasoningField` sends NOTHING. * This is the safe default — an unknown field sent to an OpenAI-compatible server * strict (BYOK generic) returns to 400, and a 400 kills the round. */ import { getAgentProvider, type AgentProviderId } from "./agent-providers"; export type ReasoningLevel = | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; /** * All the vocabulary, from least expensive to most expensive. It's OpenRouter's * (`minimal`, `low`, `medium`, `high`, `xhigh`, `max`), except for one word: their * `none` is our `off`, which means something a little stronger — * send NO reasoning fields, rather than sending one that asks * zero. This is the only safe fault outside OpenRouter, where an unknown field returns * 400 and kills the round. * * This list is NOT what the selector displays: a given model does not accept * only part of these values, and it is he who says it (`reasoningLevelsFor`). */ export const REASONING_LEVELS: ReasoningLevel[] = [ "off", "minimal", "low", "medium", "high", "xhigh", "max", ]; /** * What we propose about a model that publishes NOTHING about its reasoning — a BYOK * direct, a non-index model, a provider for which we do not have a capacity index. * * The four histories, and not the seven: propose `xhigh` to a model of which we * ignores everything, it is proposing a level which will be silently reduced (at * OpenRouter) or refused (elsewhere). A selector should only offer choices that * changent quelque chose. */ export const GENERIC_REASONING_LEVELS: ReasoningLevel[] = ["off", "low", "medium", "high"]; /** * What a model says about its own reasoning, such as the OpenRouter index * publishes (`/models`, object `reasoning`). Read server side * ([openrouter-index.ts](server/agent/openrouter-index.ts)), il voyage jusqu'au * selector with the catalog. * * We only keep two fields out of the five published, because they are the two * which change the screen. `default_effort` and `default_enabled` describe what the * model made when we ask for nothing — but we always ask for something. * `supports_max_tokens` (10 models out of 406) would open a SECOND form of * adjustment, a token budget, and a selector which changes nature depending on the * model is a selector that we no longer know how to read: OpenRouter translates everything * way our budget levels for models that only hear that. */ export interface ModelReasoning { /** * The tiers that THIS model accepts, from least expensive to most expensive. Empty = he * reasons, but does not publish any enumeration (this is the case of Claude): we * then falls back to `GENERIC_REASONING_LEVELS`. */ efforts: ReasoningLevel[]; /** * The model ALWAYS reasons (`mandatory`): “without reasoning” is not * an option to offer him — he refuses `none`, and sending it to him breaks the call. */ mandatory: boolean; } /** * What the selector displays for a given model. Without metadata (`undefined`: * model unknown, catalog not loaded), the four historical levels; with, those * the model publishes, plus `off` when it allows us not to reason. * * `null` = no reasoning control reaches this model (it does not reason, or * its endpoint drops every reasoning field): no level at all, so the caller * shows an inert selector rather than choices with no effect. */ export function reasoningLevelsFor( reasoning: ModelReasoning | null | undefined, ): ReasoningLevel[] { if (reasoning === null) return []; if (!reasoning) return GENERIC_REASONING_LEVELS; const efforts = reasoning.efforts.length > 0 ? reasoning.efforts : GENERIC_REASONING_LEVELS; const withoutOff = efforts.filter((l) => l !== "off"); return reasoning.mandatory ? withoutOff : ["off", ...withoutOff]; } /** * Drop a level on what the model REALLY accepts. Used for the selector (a * personal default at `xhigh` on a model which does not want it must be displayed on its * nearest neighbor, not in a vacuum) and at launch. * * “Closest” is read on the full scale, in both directions: first * downwards (cheaper than requested, never more), then upwards if the * model is nothing cheaper — a model that only accepts `high` must * receive `high`, even when asked for `low`. */ export function nearestReasoningLevel( level: ReasoningLevel, allowed: ReasoningLevel[], ): ReasoningLevel { if (allowed.includes(level)) return level; if (allowed.length === 0) return level; const rank = (l: ReasoningLevel) => REASONING_LEVELS.indexOf(l); const sorted = [...allowed].sort((a, b) => rank(a) - rank(b)); const below = [...sorted].reverse().find((l) => rank(l) < rank(level)); return below ?? sorted[0]; } /** * Default: `medium` (“Standard” in the UI). The agent thinks a little before * to act, because that's what we want from a code agent in the general case — * `off` was the MIN-122 landing fault, chosen to introduce no * change in behavior on delivery day, not because it served better * the user. The additional cost remains limited by the usage budget (`checkAgentQuota`), * and an endpoint which refuses the field is caught by the restart without field of * `streamCompletion` (cf. docs/reasoning-levels.md). */ export const DEFAULT_REASONING_LEVEL: ReasoningLevel = "medium"; /** Validates an API entry / a value read in base. */ export function isReasoningLevel(value: unknown): value is ReasoningLevel { return typeof value === "string" && (REASONING_LEVELS as string[]).includes(value); } /** Normalizes anything to valid level (default `DEFAULT_REASONING_LEVEL`). */ export function toReasoningLevel(value: unknown): ReasoningLevel { return isReasoningLevel(value) ? value : DEFAULT_REASONING_LEVEL; } /** What a flat `reasoning_effort` accepts: the OpenAI API vocabulary. */ const COMPAT_EFFORTS: ReasoningLevel[] = ["minimal", "low", "medium", "high"]; /** * Fields to merge into body `/chat/completions` to request this level. * `{}` (nothing to send) when: level `off`, unknown value, or provider without * capacity declared in the register (`generic`, and any future provider as long as we * has not found that it accepts the field). */ export function reasoningRequestFields( level: ReasoningLevel | null | undefined, provider: AgentProviderId, ): Record { if (!isReasoningLevel(level) || level === "off") return {}; const field = getAgentProvider(provider)?.requestProfile.reasoningField; if (!field) return {}; // `exclude: false`: we WANT to receive the trace to persist it folded into // the feed — what we don't want is the streamer (see the feed indicator). if (field === "reasoning") return { reasoning: { effort: level, exclude: false } }; // Anthropic depends on the model family (manual up to 4.6, adaptive to // from 4.7). The model-aware translator in lib/ai-chat.ts takes care of this. if (field === "thinking") return {}; /** * The compat layers (openai, anthropic, google) only know the * OpenAI API vocabulary. `xhigh` and `max` are OpenRouter tiers, * who himself reduces them to what the model accepts; sent directly, they * come back to 400 and kill the round. We therefore fold down BEFORE — lose a notch of * reflection is better than losing the turn. */ return { reasoning_effort: nearestReasoningLevel(level, COMPAT_EFFORTS) }; } /** * Reflection tokens to be provided IN ADDITION to the answer, by level. They serve * to the overall ceiling and, for Claudes who still accept manual mode, to * indicative budget in the model-aware adapter. */ const REASONING_HEADROOM: Record = { off: 0, minimal: 512, low: 1024, medium: 2048, high: 4096, xhigh: 6144, max: 8192, }; /** Indicative budget behind a level, used by the Anthropic adapter. */ export function reasoningTokenBudget(level: ReasoningLevel): number { return REASONING_HEADROOM[level]; } /** * Internal output ceiling to request when reasoning is active. THE * reflection tokens are counted IN this ceiling by the compat layers: at `high`, the * reflection would eat most of the 8192 in the profile and truncate the answer * **and the tool-calls** of the round. We therefore raise the ceiling on the expected additional cost. * `undefined` at the entrance (surface without ceiling) remains `undefined`. */ export function reasoningMaxTokens( base: number | undefined, level: ReasoningLevel | null | undefined, ): number | undefined { if (base === undefined) return undefined; if (!isReasoningLevel(level)) return base; return base + reasoningTokenBudget(level); } /** * The four levels are open to ALL, minddy quota included: the subscription is * paid, it must be usable in its entirety. A `high` consumes the usage budget * monthly faster, but cannot exceed it — `checkAgentQuota` refuses the * launch and the loop stops by itself when the budget is exhausted. Ceiling * the extra level would not have protected anything, at the cost of a rule to explain. */