feat(coding-agent): refactored model role system and extracted model resolution utilities
- Added 'commit' model role for dedicated commit message generation. - Exported 'resolveModelOverride' function from model-resolver module for public use. - Refactored model role system to centralize role configuration in MODEL_ROLES registry. - Extracted model resolution utilities into dedicated model-resolver module for improved code organization. - Made 'tag' and 'color' properties optional in ModelRoleInfo interface to support roles without visual indicators. - Enhanced model selector with defensive null/undefined checks for optional role properties.
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `commit` model role for dedicated commit message generation
|
||||
- Exported `resolveModelOverride` function from model resolver for external use
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated model role resolution to accept optional `roleOrder` parameter for custom role priority
|
||||
- Made `tag` and `color` properties optional in `ModelRoleInfo` interface
|
||||
- Updated model selector to safely handle roles without tag or color definitions
|
||||
- Refactored role label display to use centralized `MODEL_ROLES` registry instead of hardcoded strings
|
||||
- Refactored model role system to use centralized `MODEL_ROLES` registry with consistent tag, name, and color definitions
|
||||
- Simplified model role resolution to use `MODEL_ROLE_IDS` array instead of hardcoded role checks
|
||||
- Updated model selector to dynamically generate menu actions from `MODEL_ROLES` registry
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import { parseModelPattern, parseModelString, SMOL_MODEL_PRIORITY } from "../config/model-resolver";
|
||||
import { MODEL_ROLE_IDS } from "../config/model-registry";
|
||||
import {
|
||||
expandRoleAlias,
|
||||
parseModelPattern,
|
||||
resolveModelFromSettings,
|
||||
resolveModelFromString,
|
||||
SMOL_MODEL_PRIORITY,
|
||||
} from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
|
||||
export async function resolvePrimaryModel(
|
||||
@@ -12,9 +19,15 @@ export async function resolvePrimaryModel(
|
||||
): Promise<{ model: Model<Api>; apiKey: string }> {
|
||||
const available = modelRegistry.getAvailable();
|
||||
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
|
||||
const roleOrder = ["commit", "smol", ...MODEL_ROLE_IDS] as const;
|
||||
const model = override
|
||||
? resolveModelFromString(expandRoleAlias(override, settings), available, matchPreferences)
|
||||
: resolveModelFromSettings(settings, available, matchPreferences);
|
||||
: resolveModelFromSettings({
|
||||
settings,
|
||||
availableModels: available,
|
||||
matchPreferences,
|
||||
roleOrder,
|
||||
});
|
||||
if (!model) {
|
||||
throw new Error("No model available for commit generation");
|
||||
}
|
||||
@@ -37,7 +50,9 @@ export async function resolveSmolModel(
|
||||
const available = modelRegistry.getAvailable();
|
||||
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
|
||||
const role = settings.getModelRole("smol");
|
||||
const roleModel = role ? resolveModelFromString(role, available, matchPreferences) : undefined;
|
||||
const roleModel = role
|
||||
? resolveModelFromString(expandRoleAlias(role, settings), available, matchPreferences)
|
||||
: undefined;
|
||||
if (roleModel) {
|
||||
const apiKey = await modelRegistry.getApiKey(roleModel);
|
||||
if (apiKey) return { model: roleModel, apiKey };
|
||||
@@ -52,39 +67,3 @@ export async function resolveSmolModel(
|
||||
|
||||
return { model: fallbackModel, apiKey: fallbackApiKey };
|
||||
}
|
||||
|
||||
function resolveModelFromSettings(
|
||||
settings: Settings,
|
||||
available: Model<Api>[],
|
||||
matchPreferences: { usageOrder?: string[] },
|
||||
): Model<Api> | undefined {
|
||||
const roles = ["commit", "smol", "default"];
|
||||
for (const role of roles) {
|
||||
const configured = settings.getModelRole(role);
|
||||
if (!configured) continue;
|
||||
const resolved = resolveModelFromString(expandRoleAlias(configured, settings), available, matchPreferences);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return available[0];
|
||||
}
|
||||
|
||||
function resolveModelFromString(
|
||||
value: string,
|
||||
available: Model<Api>[],
|
||||
matchPreferences: { usageOrder?: string[] },
|
||||
): Model<Api> | undefined {
|
||||
const parsed = parseModelString(value);
|
||||
if (parsed) {
|
||||
return available.find(model => model.provider === parsed.provider && model.id === parsed.id);
|
||||
}
|
||||
return parseModelPattern(value, available, matchPreferences).model;
|
||||
}
|
||||
|
||||
function expandRoleAlias(value: string, settings: Settings): string {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower.startsWith("pi/")) {
|
||||
const role = value.slice(3);
|
||||
return settings.getModelRole(role) ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ import { YAML } from "bun";
|
||||
import type { ThemeColor } from "../modes/theme/theme";
|
||||
import type { AuthStorage } from "../session/auth-storage";
|
||||
|
||||
export type ModelRole = "default" | "smol" | "slow" | "plan";
|
||||
export type ModelRole = "default" | "smol" | "slow" | "plan" | "commit";
|
||||
|
||||
export interface ModelRoleInfo {
|
||||
tag: string;
|
||||
tag?: string;
|
||||
name: string;
|
||||
color: ThemeColor;
|
||||
color?: ThemeColor;
|
||||
}
|
||||
|
||||
export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
||||
@@ -31,9 +31,10 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
|
||||
smol: { tag: "SMOL", name: "Fast", color: "warning" },
|
||||
slow: { tag: "SLOW", name: "Thinking", color: "accent" },
|
||||
plan: { tag: "PLAN", name: "Architect", color: "muted" },
|
||||
commit: { name: "Commit" },
|
||||
};
|
||||
|
||||
export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "plan"];
|
||||
export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "plan", "commit"];
|
||||
|
||||
const Ajv = (AjvModule as any).default || AjvModule;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@oh-my
|
||||
import chalk from "chalk";
|
||||
import { isValidThinkingLevel } from "../cli/args";
|
||||
import { fuzzyMatch } from "../utils/fuzzy";
|
||||
import { MODEL_ROLE_IDS, type ModelRegistry } from "./model-registry";
|
||||
import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
/** Default model IDs for each known provider */
|
||||
@@ -311,28 +311,30 @@ export function parseModelPattern(
|
||||
return parseModelPatternWithContext(pattern, availableModels, context);
|
||||
}
|
||||
|
||||
const MODEL_ROLE_ALIAS_PREFIX = "pi/";
|
||||
const PREFIX_MODEL_ROLE = "pi/";
|
||||
const DEFAULT_MODEL_ROLE = "default";
|
||||
const DEFAULT_MODEL_ALIASES = new Set([DEFAULT_MODEL_ROLE, `${MODEL_ROLE_ALIAS_PREFIX}${DEFAULT_MODEL_ROLE}`]);
|
||||
|
||||
/**
|
||||
* Check if a model override value is effectively the default role.
|
||||
*/
|
||||
export function isDefaultModelAlias(value: string | string[] | undefined): boolean {
|
||||
if (!value) return true;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (values.length === 0) return true;
|
||||
return values.every(entry => DEFAULT_MODEL_ALIASES.has(entry.trim().toLowerCase()));
|
||||
if (Array.isArray(value)) return value.every(entry => isDefaultModelAlias(entry));
|
||||
if (value.startsWith(PREFIX_MODEL_ROLE)) {
|
||||
value = value.slice(PREFIX_MODEL_ROLE.length);
|
||||
}
|
||||
return value === DEFAULT_MODEL_ROLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a role alias like "pi/smol" to the configured model string.
|
||||
*/
|
||||
export function expandRoleAlias(value: string, settings?: Settings): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized.startsWith(MODEL_ROLE_ALIAS_PREFIX)) return value;
|
||||
const role = normalized.slice(MODEL_ROLE_ALIAS_PREFIX.length);
|
||||
if (!MODEL_ROLE_IDS.includes(role as (typeof MODEL_ROLE_IDS)[number])) return value;
|
||||
const normalized = value.trim();
|
||||
if (normalized === "default") return settings?.getModelRole("default") ?? value;
|
||||
if (!normalized.startsWith(PREFIX_MODEL_ROLE)) return value;
|
||||
const role = normalized.slice(PREFIX_MODEL_ROLE.length) as ModelRole;
|
||||
if (!MODEL_ROLE_IDS.includes(role)) return value;
|
||||
return settings?.getModelRole(role) ?? value;
|
||||
}
|
||||
|
||||
@@ -358,7 +360,7 @@ export function resolveModelFromSettings(options: {
|
||||
settings: Settings;
|
||||
availableModels: Model<Api>[];
|
||||
matchPreferences?: ModelMatchPreferences;
|
||||
roleOrder?: string[];
|
||||
roleOrder?: readonly ModelRole[];
|
||||
}): Model<Api> | undefined {
|
||||
const { settings, availableModels, matchPreferences, roleOrder } = options;
|
||||
const roles = roleOrder ?? MODEL_ROLE_IDS;
|
||||
@@ -382,8 +384,8 @@ export function resolveModelOverride(
|
||||
if (modelPatterns.length === 0) return {};
|
||||
const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
|
||||
for (const pattern of modelPatterns) {
|
||||
const normalized = pattern.trim().toLowerCase();
|
||||
if (!normalized || DEFAULT_MODEL_ALIASES.has(normalized)) {
|
||||
const normalized = pattern.trim();
|
||||
if (!normalized || isDefaultModelAlias(normalized)) {
|
||||
continue;
|
||||
}
|
||||
const effectivePattern = expandRoleAlias(pattern, settings);
|
||||
|
||||
@@ -356,8 +356,8 @@ export class ModelSelectorComponent extends Container {
|
||||
const badges: string[] = [];
|
||||
for (const role of MODEL_ROLE_IDS) {
|
||||
const { tag, color } = MODEL_ROLES[role];
|
||||
if (modelsAreEqual(this.roles[role], item.model)) {
|
||||
badges.push(makeInvertedBadge(tag, color));
|
||||
if (tag && modelsAreEqual(this.roles[role], item.model)) {
|
||||
badges.push(makeInvertedBadge(tag, color ?? "success"));
|
||||
}
|
||||
}
|
||||
const badgeText = badges.length > 0 ? ` ${badges.join(" ")}` : "";
|
||||
|
||||
@@ -649,7 +649,7 @@ export class InputController {
|
||||
|
||||
async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
|
||||
try {
|
||||
const roleOrder = ["slow", "default", "smol"];
|
||||
const roleOrder = ["slow", "default", "smol"] as const;
|
||||
const result = await this.ctx.session.cycleRoleModels(roleOrder, options);
|
||||
if (!result) {
|
||||
this.ctx.showStatus("Only one role model available");
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OAuthProvider } from "@oh-my-pi/pi-ai";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
import { getAgentDbPath } from "../../config";
|
||||
import { MODEL_ROLES } from "../../config/model-registry";
|
||||
import { settings } from "../../config/settings";
|
||||
import { DebugSelectorComponent } from "../../debug";
|
||||
import { disableProvider, enableProvider } from "../../discovery";
|
||||
@@ -277,7 +278,7 @@ export class SelectorController {
|
||||
this.ctx.session.scopedModels,
|
||||
async (model, role) => {
|
||||
try {
|
||||
if (role === "temporary") {
|
||||
if (role === null) {
|
||||
// Temporary: update agent state but don't persist to settings
|
||||
await this.ctx.session.setModelTemporary(model);
|
||||
this.ctx.statusLine.invalidate();
|
||||
@@ -294,7 +295,8 @@ export class SelectorController {
|
||||
// Don't call done() - selector stays open for role assignment
|
||||
} else {
|
||||
// Other roles (smol, slow): just update settings, not current model
|
||||
const roleLabel = role === "smol" ? "Smol" : role;
|
||||
const roleInfo = MODEL_ROLES[role];
|
||||
const roleLabel = roleInfo?.name ?? role;
|
||||
this.ctx.showStatus(`${roleLabel} model: ${model.id}`);
|
||||
// Don't call done() - selector stays open
|
||||
}
|
||||
|
||||
@@ -1854,7 +1854,7 @@ export class AgentSession {
|
||||
* @param options - Optional settings: `temporary` to not persist to settings
|
||||
*/
|
||||
async cycleRoleModels(
|
||||
roleOrder: ModelRole[],
|
||||
roleOrder: readonly ModelRole[],
|
||||
options?: { temporary?: boolean },
|
||||
): Promise<RoleModelCycleResult | undefined> {
|
||||
const availableModels = this._modelRegistry.getAvailable();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
import Ajv, { type ValidateFunction } from "ajv";
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { parseModelPattern } from "../config/model-resolver";
|
||||
import { resolveModelOverride } from "../config/model-resolver";
|
||||
import { type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
|
||||
import { Settings } from "../config/settings";
|
||||
import type { CustomTool } from "../extensibility/custom-tools/types";
|
||||
@@ -128,38 +128,6 @@ function getReportFindingKey(value: unknown): string | null {
|
||||
return `${filePath}:${lineStart}:${lineEnd}:${priority ?? ""}:${title}`;
|
||||
}
|
||||
|
||||
function resolveModelOverride(
|
||||
modelPatterns: string[],
|
||||
modelRegistry: ModelRegistry,
|
||||
settings?: Settings,
|
||||
): { model?: Model<Api>; thinkingLevel?: ThinkingLevel } {
|
||||
if (modelPatterns.length === 0) return {};
|
||||
const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
|
||||
for (const pattern of modelPatterns) {
|
||||
const normalized = pattern.trim().toLowerCase();
|
||||
if (!normalized || normalized === "default" || normalized === "pi/default") {
|
||||
continue;
|
||||
}
|
||||
let effectivePattern = pattern;
|
||||
if (normalized.startsWith("pi/")) {
|
||||
const role = pattern.slice(3);
|
||||
const configured = settings?.getModelRole(role);
|
||||
if (configured) {
|
||||
effectivePattern = configured;
|
||||
}
|
||||
}
|
||||
const { model, thinkingLevel } = parseModelPattern(
|
||||
effectivePattern,
|
||||
modelRegistry.getAvailable(),
|
||||
matchPreferences,
|
||||
);
|
||||
if (model) {
|
||||
return { model, thinkingLevel: thinkingLevel !== "off" ? thinkingLevel : undefined };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildSubmitResultToolChoice(model?: Model<Api>): ToolChoice | undefined {
|
||||
if (!model) return undefined;
|
||||
if (
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { Usage } from "@oh-my-pi/pi-ai";
|
||||
import { $ } from "bun";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { ToolSession } from "..";
|
||||
import { isDefaultModelAlias } from "../config/model-resolver";
|
||||
import { renderPromptTemplate } from "../config/prompt-templates";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" with { type: "text" };
|
||||
@@ -165,16 +166,6 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
|
||||
const { agent: agentName, context, schema: outputSchema, isolated } = params;
|
||||
const isIsolated = isolated === true;
|
||||
|
||||
const isDefaultModelAlias = (value: string | string[] | undefined): boolean => {
|
||||
if (!value) return true;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (values.length === 0) return true;
|
||||
return values.every(entry => {
|
||||
const normalized = entry.trim().toLowerCase();
|
||||
return normalized === "default" || normalized === "pi/default";
|
||||
});
|
||||
};
|
||||
|
||||
// Validate agent exists
|
||||
const agent = getAgent(agents, agentName);
|
||||
if (!agent) {
|
||||
|
||||
Reference in New Issue
Block a user