lib/agent-reasoning.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<string, unknown> {
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<ReasoningLevel, number> = {
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.
*/