blob: 36f7938ae36f8417250f895597e138baeb3ccb6d [file]
/**
* Controllable thinking level for reasoning-capable models.
*
* A `ThinkingLevel` is a user-facing reasoning-depth knob. It is a per-model
* variant: each model supports a subset of levels (declared here by
* `thinkingVariantsForModel`), and switching models clears the choice so a
* level is never sent to a model that does not understand it. `undefined`
* means "no override" (the model's default behaviour) and is the only value
* persisted-absent — the UI shows it as "默认". `'off'` explicitly disables
* reasoning for providers that expose a true off switch (`reasoningEffort:
* 'none'` for OpenAI gpt-5 / codex, `thinking: { type: 'disabled' }` for
* Anthropic-protocol); providers without a clean off switch do not list it.
*
* The runtime maps a chosen level to the ai-sdk provider option
* (`reasoningEffort` / `thinking.budgetTokens` / `thinkingConfig`) in
* `buildProviderOptions`; this module owns only the vocabulary and the
* per-model supported set, so the UI and runtime share one source of truth.
*/
import type { ProviderType } from './llm-connections.js';
import { lookupModelMetadata } from './model-metadata.js';
/**
* Reasoning-depth variants. Ordered from shallowest to deepest for display.
* Not every model supports every level — call `thinkingVariantsForModel` for
* the model-specific subset.
*/
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
'off',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
];
/**
* The levels a generic-relay declaration may hold — the vocabulary the
* settings surfaces offer and the one the data layer admits. `off` is the
* sole exclusion: it is not an intensity tier but a *disable* wire
* (`reasoning_effort: 'none'`), and no generic relay is presumed to honor
* that encoding; built-in providers that support it get `off` from their own
* metadata instead. `minimal` and every effort tier above are pure
* intensity values — the user declaring them is the authority on what the
* relay accepts.
*/
export const DECLARABLE_RELAY_THINKING_LEVELS: readonly ThinkingLevel[] = THINKING_LEVELS.filter(
(level) => level !== 'off',
);
export function isThinkingLevel(value: unknown): value is ThinkingLevel {
return typeof value === 'string' && (THINKING_LEVELS as readonly string[]).includes(value);
}
/**
* Per-model reasoning controls, mirroring models.dev `reasoning_options` plus
* Maka's adapter knowledge for real disabled wires. `efforts` are provider
* native effort enum values (e.g. `none`, `low`, `high`, `xhigh`, `max`);
* `toggle` records the catalog fact that the model has an on/off switch, but
* UI only exposes `off` when `offBehavior` (or effort `none`) says this adapter
* can actually send a disabled/none/budget-zero request.
*/
export type ThinkingOffBehavior =
| 'anthropic-thinking-disabled'
| 'cohere-thinking-disabled'
| 'cloudflare-chat-template-thinking-false'
| 'google-thinking-budget-zero'
| 'volcengine-thinking-disabled';
export interface ThinkingOptions {
readonly efforts?: readonly string[];
readonly toggle?: boolean;
readonly offBehavior?: ThinkingOffBehavior;
}
/**
* Derive the user-facing thinking-level choices from a model's declared
* `ThinkingOptions`. `none` (OpenAI's off effort) and declared `offBehavior`
* surface as `'off'`; other effort values map to the same-named
* `ThinkingLevel`. Raw `toggle` alone is intentionally not enough because some
* adapters have no real disabled wire. Unknown effort values (not in
* `ThinkingLevel`) are dropped. Returns `[]` for models with no declared
* options (miss → no thinking menu, fallback default).
*/
export function deriveThinkingChoices(
options: ThinkingOptions | undefined,
): readonly ThinkingLevel[] {
if (!options) return [];
const choices = new Set<ThinkingLevel>();
if (options.offBehavior) choices.add('off');
for (const effort of options.efforts ?? []) {
if (effort === 'none') choices.add('off');
else if (isThinkingLevel(effort)) choices.add(effort);
// Unknown effort values (not in ThinkingLevel) are dropped — add the
// level to THINKING_LEVELS if a provider introduces a new effort tier.
}
return THINKING_LEVELS.filter((level) => choices.has(level));
}
/**
* One model behind an `openai-compatible` relay, as declared by the user:
* the facts neither the relay's /models report nor built-in metadata can be
* trusted to know. Every field is independent, and every ABSENT field means
* "Auto" — the /models report and the metadata chain decide. The single
* exception in shape, not spirit, is `vision: false`: an explicit DISABLE
* that overrides Auto, because the runtime would otherwise believe a vision
* report the relay may emit regardless.
*
* Profiles live on the connection as a first-class typed field
* (`relayModelProfiles`, keyed by model id): relay models are unknown to
* `model-metadata.ts` and a catalog refresh rewrites `models[]` rows, so
* declarations sit next to the user-edited connection fields. Two invariants
* are enforced at the store boundaries — profiles exist only for
* `openai-compatible` connections, and only for models in `enabledModelIds`
* (disabling a model deletes its profile).
*/
export interface RelayModelProfile {
readonly thinkingLevels?: readonly ThinkingLevel[];
readonly vision?: boolean;
readonly contextWindow?: number;
}
export type RelayModelProfiles = Readonly<Record<string, RelayModelProfile>>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefined {
if (!isRecord(entry)) return undefined;
const declared: {
thinkingLevels?: readonly ThinkingLevel[];
vision?: boolean;
contextWindow?: number;
} = {};
if (Array.isArray(entry.thinkingLevels)) {
// Declared levels are filtered to the declarable vocabulary, not merely
// the level vocabulary: `off` is a disable-wire encoding no generic
// relay is presumed to speak, and a declaration table has no business
// carrying it. The codec rejects it in persisted documents for the same
// reason; normalize silently drops it because it also sanitizes input
// that never passed a validator (settings drafts, hand-edited tables).
const declaredSet = new Set(
entry.thinkingLevels.filter(
(level): level is ThinkingLevel =>
isThinkingLevel(level) &&
(DECLARABLE_RELAY_THINKING_LEVELS as readonly ThinkingLevel[]).includes(level),
),
);
if (declaredSet.size > 0) {
declared.thinkingLevels = DECLARABLE_RELAY_THINKING_LEVELS.filter((level) =>
declaredSet.has(level),
);
}
}
if (typeof entry.vision === 'boolean') declared.vision = entry.vision;
// Safe-integer, matching the store codec's 1..MAX_SAFE_INTEGER bound: a
// value that normalizes here must never be rejected by the write it feeds.
if (
typeof entry.contextWindow === 'number' &&
Number.isSafeInteger(entry.contextWindow) &&
entry.contextWindow > 0
) {
declared.contextWindow = entry.contextWindow;
}
return Object.keys(declared).length > 0 ? (declared as RelayModelProfile) : undefined;
}
/**
* Write-side sanitation for a whole profiles table (settings drafts, config
* imports): every entry passes the same filter the read seam applies,
* over-long/empty model ids are dropped (the codec bounds ids the same way),
* and the result is `undefined` when nothing usable remains so callers can
* omit the field instead of storing an empty table. fromEntries, not
* `table[modelId] = profile` on a `{}`: relay-supplied ids may be prototype
* keys, and literal assignment would poison the prototype instead of storing
* the entry.
*/
export function normalizeRelayModelProfiles(
table: unknown,
): Record<string, RelayModelProfile> | undefined {
if (!isRecord(table)) return undefined;
const parsed: [string, RelayModelProfile][] = [];
for (const [modelId, entry] of Object.entries(table)) {
if (modelId.length === 0 || modelId.length > 512) continue;
const declared = normalizeRelayModelProfile(entry);
if (declared) parsed.push([modelId, declared]);
}
return parsed.length > 0 ? Object.fromEntries(parsed) : undefined;
}
/**
* Restore the `keys(profiles) ⊆ enabledModelIds` invariant after a selection
* change that left the table untouched: profiles for models the user just
* disabled are dropped rather than kept stale. Returns `undefined` for an
* empty remainder so the recycled state reads as "no profiles", never "{}".
*/
export function pruneRelayModelProfiles(
table: RelayModelProfiles | undefined,
enabledModelIds: readonly string[],
): RelayModelProfiles | undefined {
if (table === undefined) return undefined;
const kept = Object.fromEntries(
Object.entries(table).filter(([modelId]) => enabledModelIds.includes(modelId)),
);
return Object.keys(kept).length > 0 ? kept : undefined;
}
/**
* Minimal connection shape the connection-aware helpers below need. Kept
* structural so callers holding either `LlmConnection` or a partial view can
* pass it through without widening runtime connection types.
*/
export interface ConnectionThinkingContext {
readonly providerType: ProviderType;
readonly relayModelProfiles?: RelayModelProfiles;
}
/**
* The one gated read seam for relay profiles. Profiles are an
* `openai-compatible` feature — on every other provider the metadata chain
* is the truth — and thinking, vision, and context window reads all enter
* through here so the gate cannot leak. Entries ride through
* `normalizeRelayModelProfile` so even a hand-edited local file degrades to
* Auto instead of trusting a malformed field.
*/
export function relayModelProfile(
connection: ConnectionThinkingContext,
modelId: string,
): RelayModelProfile | undefined {
if (connection.providerType !== 'openai-compatible') return undefined;
return normalizeRelayModelProfile(connection.relayModelProfiles?.[modelId]);
}
/**
* `openai-compatible` connections declare thinking support **per model** via
* `relayModelProfiles[modelId].thinkingLevels` — a relay may front a
* DeepSeek-family reasoner and a plain instruct model side by side, so the
* declaration granularity is the model, not the connection. Without a usable
* declaration for that model every provider (including relays) falls through
* to the metadata-derived variants.
*/
export function thinkingVariantsForConnection(
connection: ConnectionThinkingContext,
modelId: string,
): readonly ThinkingLevel[] {
const declared = relayModelProfile(connection, modelId)?.thinkingLevels;
if (declared) return declared;
return thinkingVariantsForModel(connection.providerType, modelId);
}
/**
* Discard-semantics gate: returns the level when the model offers it,
* `undefined` otherwise. Callers that must *reject* a bad level (IPC/session
* boundaries with an error channel) keep their own `includes` branch — the
* distinction between "silently drop" and "tell the caller" is the policy of
* the call site, not of this helper.
*/
export function resolveThinkingLevel(
connection: ConnectionThinkingContext,
modelId: string,
level: ThinkingLevel | undefined,
): ThinkingLevel | undefined {
return level !== undefined && thinkingVariantsForConnection(connection, modelId).includes(level)
? level
: undefined;
}
/**
* Per-model reasoning options declared in `model-metadata.ts`
* (mirroring models.dev `reasoning_options`). Returns `undefined` for models
* with no declared options (miss → `thinkingVariantsForModel` returns `[]`).
*/
export function thinkingOptionsForModel(
providerType: ProviderType,
modelId: string,
): ThinkingOptions | undefined {
return lookupModelMetadata(providerType, modelId).thinkingOptions;
}
/**
* Levels a model supports, in display order. Returns an empty list for
* non-reasoning models and for provider/model combinations whose reasoning
* support is not declarable from `providerType` + `modelId` alone (e.g.
* `openai-compatible`, where the backing model is user-configured and
* unknown). The UI hides the thinking switcher when this returns `[]`.
*
* Heuristics are intentionally conservative: only patterns known to accept the
* mapped provider option are listed. Refine here as provider support grows —
* this is the single place that decides which models expose the knob.
*/
export function thinkingVariantsForModel(
providerType: ProviderType,
modelId: string,
): readonly ThinkingLevel[] {
return deriveThinkingChoices(thinkingOptionsForModel(providerType, modelId));
}