f9f6ed9e8d
- Replaced legacy `pi/` role alias prefix with canonical `@` syntax across model resolution, documentation, and tests. - Added support for bare `*` default alias and multiple alias prefix detection with custom role resolution in `resolveConfiguredRolePattern()`. - Enhanced thinking suffix parsing to accept unambiguous abbreviations (minimum 2 characters) for effort and level selectors. - Extended `resolveCliModel()` and `filterAvailableModelsByEnabledPatterns()` to accept settings parameter for role alias resolution from `--model` flag.
236 lines
9.4 KiB
TypeScript
236 lines
9.4 KiB
TypeScript
import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||
import { Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
|
||
import { clampThinkingLevelForModel, getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
||
|
||
/**
|
||
* Metadata used to render thinking selector values in the coding-agent UI.
|
||
*/
|
||
export interface ThinkingLevelMetadata {
|
||
value: ThinkingLevel;
|
||
label: string;
|
||
description: string;
|
||
}
|
||
|
||
const THINKING_LEVEL_METADATA: Record<ThinkingLevel, ThinkingLevelMetadata> = {
|
||
[ThinkingLevel.Inherit]: {
|
||
value: ThinkingLevel.Inherit,
|
||
label: "inherit",
|
||
description: "Inherit session default",
|
||
},
|
||
[ThinkingLevel.Off]: { value: ThinkingLevel.Off, label: "off", description: "No reasoning" },
|
||
[ThinkingLevel.Minimal]: {
|
||
value: ThinkingLevel.Minimal,
|
||
label: "min",
|
||
description: "Very brief reasoning (~1k tokens)",
|
||
},
|
||
[ThinkingLevel.Low]: { value: ThinkingLevel.Low, label: "low", description: "Light reasoning (~2k tokens)" },
|
||
[ThinkingLevel.Medium]: {
|
||
value: ThinkingLevel.Medium,
|
||
label: "medium",
|
||
description: "Moderate reasoning (~8k tokens)",
|
||
},
|
||
[ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" },
|
||
[ThinkingLevel.XHigh]: {
|
||
value: ThinkingLevel.XHigh,
|
||
label: "xhigh",
|
||
description: "Extended reasoning (~32k tokens)",
|
||
},
|
||
[ThinkingLevel.Max]: {
|
||
value: ThinkingLevel.Max,
|
||
label: "max",
|
||
description: "Maximum reasoning the model supports",
|
||
},
|
||
};
|
||
|
||
const EFFORT_BY_SELECTOR: Readonly<Record<string, Effort>> = {
|
||
[Effort.Minimal]: Effort.Minimal,
|
||
[Effort.Low]: Effort.Low,
|
||
[Effort.Medium]: Effort.Medium,
|
||
[Effort.High]: Effort.High,
|
||
[Effort.XHigh]: Effort.XHigh,
|
||
[Effort.Max]: Effort.Max,
|
||
};
|
||
const THINKING_LEVEL_BY_SELECTOR: Readonly<Record<string, ThinkingLevel>> = {
|
||
[ThinkingLevel.Inherit]: ThinkingLevel.Inherit,
|
||
[ThinkingLevel.Off]: ThinkingLevel.Off,
|
||
[ThinkingLevel.Minimal]: ThinkingLevel.Minimal,
|
||
[ThinkingLevel.Low]: ThinkingLevel.Low,
|
||
[ThinkingLevel.Medium]: ThinkingLevel.Medium,
|
||
[ThinkingLevel.High]: ThinkingLevel.High,
|
||
[ThinkingLevel.XHigh]: ThinkingLevel.XHigh,
|
||
[ThinkingLevel.Max]: ThinkingLevel.Max,
|
||
};
|
||
|
||
function getOwnSelector<T>(selectors: Readonly<Record<string, T>>, value: string | null | undefined): T | undefined {
|
||
if (value === undefined || value === null) return undefined;
|
||
if (Object.hasOwn(selectors, value)) return selectors[value];
|
||
// Accept unambiguous abbreviations (`xhi` → xhigh, `med` → medium) so every
|
||
// selector surface (`--thinking`, `:suffix`, role values) parses alike.
|
||
// Two-character minimum keeps single letters (`m`) from guessing.
|
||
if (value.length < 2) return undefined;
|
||
const matches = Object.keys(selectors).filter(selector => selector.startsWith(value));
|
||
return matches.length === 1 ? selectors[matches[0]] : undefined;
|
||
}
|
||
|
||
/**
|
||
* Parses a provider-facing effort value. Accepts unambiguous abbreviations.
|
||
*/
|
||
export function parseEffort(value: string | null | undefined): Effort | undefined {
|
||
return getOwnSelector(EFFORT_BY_SELECTOR, value);
|
||
}
|
||
|
||
/**
|
||
* Parses an agent-local thinking selector. Accepts unambiguous abbreviations.
|
||
*/
|
||
export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined {
|
||
return getOwnSelector(THINKING_LEVEL_BY_SELECTOR, value);
|
||
}
|
||
|
||
/**
|
||
* Returns display metadata for a thinking selector.
|
||
*/
|
||
export function getThinkingLevelMetadata(level: ThinkingLevel): ThinkingLevelMetadata {
|
||
return THINKING_LEVEL_METADATA[level];
|
||
}
|
||
|
||
/**
|
||
* Converts an agent-local selector into the effort sent to providers.
|
||
*/
|
||
export function toReasoningEffort(level: ThinkingLevel | undefined): Effort | undefined {
|
||
if (level === undefined || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) {
|
||
return undefined;
|
||
}
|
||
return level;
|
||
}
|
||
|
||
/**
|
||
* True when a selector explicitly requests provider-side reasoning disablement.
|
||
*/
|
||
export function shouldDisableReasoning(level: ThinkingLevel | undefined): boolean {
|
||
return level === ThinkingLevel.Off;
|
||
}
|
||
|
||
/**
|
||
* Resolves a selector against the current model while preserving explicit "off".
|
||
*/
|
||
export function resolveThinkingLevelForModel(
|
||
model: Model | undefined,
|
||
level: ThinkingLevel | undefined,
|
||
): ResolvedThinkingLevel | undefined {
|
||
if (level === undefined || level === ThinkingLevel.Inherit) {
|
||
return undefined;
|
||
}
|
||
if (level === ThinkingLevel.Off) {
|
||
return ThinkingLevel.Off;
|
||
}
|
||
return clampThinkingLevelForModel(model, level);
|
||
}
|
||
|
||
/**
|
||
* Sentinel selector for the coding-agent "auto" thinking mode. Kept entirely
|
||
* inside the coding-agent layer: it is never an {@link Effort} or
|
||
* {@link ThinkingLevel}, so provider mapping/clamping keeps seeing concrete
|
||
* efforts. The session resolves `auto` to a concrete effort each turn.
|
||
*/
|
||
export const AUTO_THINKING = "auto" as const;
|
||
|
||
/** A thinking selector as configured by the user — a concrete level or `auto`. */
|
||
export type ConfiguredThinkingLevel = ThinkingLevel | typeof AUTO_THINKING;
|
||
|
||
/** Maps the session-level `auto` sentinel to `undefined`; concrete levels pass through. */
|
||
export function concreteThinkingLevel(level: ConfiguredThinkingLevel | undefined): ThinkingLevel | undefined {
|
||
return level === AUTO_THINKING ? undefined : level;
|
||
}
|
||
|
||
/** Metadata used to render the `auto` selector value alongside concrete levels. */
|
||
export interface ConfiguredThinkingLevelMetadata {
|
||
value: ConfiguredThinkingLevel;
|
||
label: string;
|
||
description: string;
|
||
}
|
||
|
||
const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = {
|
||
value: AUTO_THINKING,
|
||
label: "auto",
|
||
description: "Auto-detect per prompt (low–xhigh)",
|
||
};
|
||
|
||
/**
|
||
* Parses a configured thinking selector, accepting `auto` in addition to every
|
||
* value {@link parseThinkingLevel} accepts. {@link parseThinkingLevel} itself
|
||
* stays strict so model-suffix parsing (`model:high`) keeps rejecting `auto`.
|
||
*/
|
||
export function parseConfiguredThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
|
||
if (value === AUTO_THINKING) return AUTO_THINKING;
|
||
return parseThinkingLevel(value);
|
||
}
|
||
|
||
/** Returns display metadata for a configured selector, including `auto`. */
|
||
export function getConfiguredThinkingLevelMetadata(level: ConfiguredThinkingLevel): ConfiguredThinkingLevelMetadata {
|
||
return level === AUTO_THINKING ? AUTO_THINKING_METADATA : getThinkingLevelMetadata(level);
|
||
}
|
||
|
||
/**
|
||
* Thinking selectors accepted by the `--thinking` CLI flag, in display order:
|
||
* `off`, every concrete effort (`minimal`..`max`), then `auto`. Single source
|
||
* for the flag's `options` list, shell completions, and the "invalid level"
|
||
* warning so all three stay in sync.
|
||
*/
|
||
export const CLI_THINKING_LEVELS: readonly string[] = [ThinkingLevel.Off, ...THINKING_EFFORTS, AUTO_THINKING];
|
||
|
||
/**
|
||
* Parses a `--thinking` CLI value. Accepts every {@link parseConfiguredThinkingLevel}
|
||
* selector (`off`, `auto`, `minimal`..`max`) but rejects
|
||
* `inherit`: an explicit `inherit` on the command line would suppress the
|
||
* settings/scoped-model fallback during startup resolution only to resolve back
|
||
* to the provider default, which is never what the user means.
|
||
*/
|
||
export function parseCliThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
|
||
const level = parseConfiguredThinkingLevel(value);
|
||
return level === ThinkingLevel.Inherit ? undefined : level;
|
||
}
|
||
|
||
/**
|
||
* Resolves an auto-classified effort against the active model's supported
|
||
* range. Unlike {@link clampThinkingLevelForModel}, `auto` never resolves below
|
||
* {@link Effort.Low}: the eligible pool is the model's supported efforts at or
|
||
* above Low (falling back to the full supported set only when the model maxes
|
||
* out below Low). Within that pool the request snaps to the highest level not
|
||
* exceeding it, or the pool minimum when the request is below the pool.
|
||
*
|
||
* Returns `undefined` for reasoning-capable models without a controllable
|
||
* effort surface (`thinking.efforts` empty — e.g. devin-agent models, where
|
||
* Cascade selects effort by routing to sibling model ids). Matches
|
||
* {@link clampThinkingLevelForModel}: with no effort to pick, `auto` must not
|
||
* forward a concrete effort that would then trip {@link requireSupportedEffort}
|
||
* downstream.
|
||
*/
|
||
export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort | undefined {
|
||
const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
|
||
if (supported.length === 0) return undefined;
|
||
const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low);
|
||
const eligible = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
|
||
const pool = eligible.length > 0 ? eligible : supported;
|
||
const requestedIndex = THINKING_EFFORTS.indexOf(effort);
|
||
let chosen = pool[0];
|
||
for (const candidate of pool) {
|
||
if (THINKING_EFFORTS.indexOf(candidate) > requestedIndex) break;
|
||
chosen = candidate;
|
||
}
|
||
return chosen;
|
||
}
|
||
|
||
/**
|
||
* The provisional concrete level shown while `auto` is configured but before a
|
||
* turn has been classified. Prefers the model's `defaultLevel`, otherwise High,
|
||
* clamped into the auto range. Auto never provisions {@link Effort.Max} (the
|
||
* classifier ceiling is XHigh; only an explicit user request reaches Max), so a
|
||
* `defaultLevel` of `max` is capped at XHigh before clamping. Returns
|
||
* `undefined` for non-reasoning models.
|
||
*/
|
||
export function resolveProvisionalAutoLevel(model: Model | undefined): Effort | undefined {
|
||
if (!model?.reasoning) return undefined;
|
||
const preferred = model.thinking?.defaultLevel ?? Effort.High;
|
||
return clampAutoThinkingEffort(model, preferred === Effort.Max ? Effort.XHigh : preferred);
|
||
}
|