blob: 27beaabb63b6db08d4525b88e2fdc6ebfae2b13a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* 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 { ModelInfo, 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).
*/
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));
}
/** A connection-scoped user model record. An empty record preserves a manually added id. */
export interface ModelOverride {
readonly knowledgeCutoff?: string;
readonly capabilities?: Omit<NonNullable<ModelInfo['capabilities']>, 'vision'>;
readonly modalities?: ModelInfo['modalities'];
readonly thinkingLevels?: readonly ThinkingLevel[];
readonly vision?: boolean;
readonly contextWindow?: number;
readonly compactionThreshold?: number;
readonly inputLimit?: number;
/** Per-request output budget, including thinking tokens; not model capacity. */
readonly maxOutputTokens?: number;
readonly displayName?: string;
readonly description?: string;
readonly apiProtocol?: 'openai-chat' | 'openai-responses' | 'anthropic-messages';
/** Use OpenAI's low-latency service tier for this relay model. */
readonly serviceTier?: 'fast';
}
export type ModelOverrides = Readonly<Record<string, ModelOverride>>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeModelOverride(entry: unknown): ModelOverride | undefined {
if (!isRecord(entry)) return undefined;
const declared: {
knowledgeCutoff?: string;
capabilities?: ModelOverride['capabilities'];
modalities?: ModelOverride['modalities'];
thinkingLevels?: readonly ThinkingLevel[];
vision?: boolean;
contextWindow?: number;
compactionThreshold?: number;
inputLimit?: number;
maxOutputTokens?: number;
displayName?: string;
description?: string;
apiProtocol?: 'openai-chat' | 'openai-responses' | 'anthropic-messages';
serviceTier?: 'fast';
} = {};
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;
for (const field of [
'contextWindow',
'compactionThreshold',
'inputLimit',
'maxOutputTokens',
] as const) {
const value = entry[field];
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
declared[field] = value;
}
for (const field of ['displayName', 'description', 'knowledgeCutoff'] as const) {
if (typeof entry[field] === 'string') declared[field] = entry[field];
}
if (isRecord(entry.capabilities)) declared.capabilities = entry.capabilities;
if (isRecord(entry.modalities))
declared.modalities = entry.modalities as unknown as ModelOverride['modalities'];
if (
entry.apiProtocol === 'openai-chat' ||
entry.apiProtocol === 'openai-responses' ||
entry.apiProtocol === 'anthropic-messages'
)
declared.apiProtocol = entry.apiProtocol;
if (entry.serviceTier === 'fast') declared.serviceTier = 'fast';
return declared;
}
/**
* 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 normalizeModelOverrides(table: unknown): Record<string, ModelOverride> | undefined {
if (!isRecord(table)) return undefined;
const parsed: [string, ModelOverride][] = [];
for (const [modelId, entry] of Object.entries(table)) {
if (modelId.length === 0 || modelId.length > 512) continue;
const declared = normalizeModelOverride(entry);
if (declared) parsed.push([modelId, declared]);
}
return parsed.length > 0 ? Object.fromEntries(parsed) : undefined;
}
/** Remove profiles for models explicitly retired from a provider. */
export function pruneModelOverrides(
table: ModelOverrides | undefined,
retainedModelIds: readonly string[],
): ModelOverrides | undefined {
if (table === undefined) return undefined;
const kept = Object.fromEntries(
Object.entries(table).filter(([modelId]) => retainedModelIds.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 modelOverrides?: ModelOverrides;
}
/**
* The one read seam for user declarations: thinking, vision, and context
* window reads all enter through here, so the precedence of a declaration over
* the metadata chain is decided in exactly one place. Entries ride through
* `normalizeModelOverride` so even a hand-edited local file degrades to
* Auto instead of trusting a malformed field.
*/
export function modelOverride(
connection: ConnectionThinkingContext,
modelId: string,
): ModelOverride | undefined {
return normalizeModelOverride(connection.modelOverrides?.[modelId]);
}
/** The user's proactive compaction target; model capacity never supplies a default. */
export function declaredContextWindow(
connection: ConnectionThinkingContext,
modelId: string,
): number | undefined {
return modelOverride(connection, modelId)?.compactionThreshold;
}
/**
* Mirrors @ai-sdk/openai@4.0.42 priority-processing detection. The UI and
* runtime share this gate so a saved Fast declaration always reaches the wire.
*/
export function supportsRelayFastServiceTier(providerType: ProviderType, modelId: string): boolean {
if (providerType !== 'openai-responses-compatible') return false;
const oSeriesVersion = /^o(\d+)(?:-|$)/.exec(modelId)?.[1];
const gptMatch = /^gpt-(\d+)(?:\.(\d+))?(?:-(.+))?$/.exec(modelId);
const gptMajor = gptMatch?.[1] === undefined ? undefined : Number(gptMatch[1]);
const gptVariant = gptMatch?.[3];
const isGptNanoModel = gptVariant?.startsWith('nano') ?? false;
const isGptChatModel = gptVariant?.startsWith('chat') ?? false;
return (
modelId.startsWith('gpt-4') ||
(gptMajor !== undefined && gptMajor >= 5 && !isGptNanoModel && !isGptChatModel) ||
(oSeriesVersion !== undefined && Number(oSeriesVersion) >= 3)
);
}
/**
* OpenAI-compatible relay connections declare thinking support **per model** via
* `modelOverrides[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 = modelOverride(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));
}
export function applyModelOverride(
model: ModelInfo,
override: ModelOverride | undefined,
): ModelInfo {
if (!override) return model;
const {
thinkingLevels: _thinking,
serviceTier: _tier,
compactionThreshold: _threshold,
maxOutputTokens: _outputBudget,
vision,
capabilities,
...facts
} = override;
return {
...model,
...facts,
capabilities: {
...model.capabilities,
...capabilities,
...(vision === undefined ? {} : { vision }),
},
};
}
export type ModelLimits = Pick<ModelInfo, 'contextWindow' | 'inputLimit'>;
export function resolveModelLimits(
providerType: ProviderType,
model: ModelInfo,
override?: ModelOverride,
): ModelLimits {
const metadata = lookupModelMetadata(providerType, model.id);
const contextWindow = override?.contextWindow ?? model.contextWindow ?? metadata.contextWindow;
const inputLimit = override?.inputLimit ?? model.inputLimit ?? metadata.inputLimit;
return {
...(contextWindow === undefined ? {} : { contextWindow }),
...(inputLimit === undefined ? {} : { inputLimit }),
};
}
export function modelLimitsConflict(limits: ModelLimits): boolean {
return (
limits.contextWindow !== undefined &&
limits.inputLimit !== undefined &&
limits.inputLimit > limits.contextWindow
);
}
export function applyConnectionModelOverrides<
T extends { readonly models?: readonly ModelInfo[]; readonly modelOverrides?: ModelOverrides },
>(connection: T): T {
const models = new Map((connection.models ?? []).map((model) => [model.id, model]));
for (const [id, override] of Object.entries(connection.modelOverrides ?? {})) {
models.set(id, applyModelOverride(models.get(id) ?? { id }, override));
}
return { ...connection, models: [...models.values()] };
}