Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
266 lines
8.9 KiB
TypeScript
266 lines
8.9 KiB
TypeScript
import type { AgentModelPreset, ProviderConfig } from './types';
|
|
import { formatThinkingLabel, PROVIDER_PRESETS, resolveAgentModelSelection } from './types';
|
|
|
|
export const CATTY_REASONING_LEVELS = ['off', 'low', 'medium', 'high'] as const;
|
|
export type CattyReasoningLevel = (typeof CATTY_REASONING_LEVELS)[number];
|
|
|
|
export const CLAUDE_REASONING_LEVELS = ['low', 'medium', 'high', 'max'] as const;
|
|
|
|
export const COMPOSER_RECENT_MODEL_LIMIT = 6;
|
|
export const COMPOSER_PINNED_MODEL_LIMIT = 8;
|
|
|
|
export interface ComposerPickerModel {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
contextWindow?: number;
|
|
}
|
|
|
|
export interface ComposerModelPrefEntry {
|
|
providerId?: string;
|
|
modelId: string;
|
|
}
|
|
|
|
export interface ComposerModelPrefs {
|
|
recent: ComposerModelPrefEntry[];
|
|
pinned: ComposerModelPrefEntry[];
|
|
}
|
|
|
|
export function isCattyReasoningLevel(value: string | null | undefined): value is CattyReasoningLevel {
|
|
return CATTY_REASONING_LEVELS.some((level) => level === value);
|
|
}
|
|
|
|
export function normalizeCattyReasoningLevel(
|
|
value: string | null | undefined,
|
|
): CattyReasoningLevel {
|
|
return isCattyReasoningLevel(value) ? value : 'off';
|
|
}
|
|
|
|
export function composerModelPrefKey(entry: ComposerModelPrefEntry): string {
|
|
return entry.providerId ? `${entry.providerId}::${entry.modelId}` : entry.modelId;
|
|
}
|
|
|
|
export function sameComposerModelPref(
|
|
left: ComposerModelPrefEntry,
|
|
right: ComposerModelPrefEntry,
|
|
): boolean {
|
|
return left.modelId === right.modelId && (left.providerId ?? '') === (right.providerId ?? '');
|
|
}
|
|
|
|
export function upsertComposerPrefFront(
|
|
entries: ComposerModelPrefEntry[],
|
|
next: ComposerModelPrefEntry,
|
|
limit: number,
|
|
): ComposerModelPrefEntry[] {
|
|
const filtered = entries.filter((entry) => !sameComposerModelPref(entry, next));
|
|
return [next, ...filtered].slice(0, limit);
|
|
}
|
|
|
|
export function toggleComposerPinnedPref(
|
|
entries: ComposerModelPrefEntry[],
|
|
target: ComposerModelPrefEntry,
|
|
limit = COMPOSER_PINNED_MODEL_LIMIT,
|
|
): ComposerModelPrefEntry[] {
|
|
if (entries.some((entry) => sameComposerModelPref(entry, target))) {
|
|
return entries.filter((entry) => !sameComposerModelPref(entry, target));
|
|
}
|
|
return upsertComposerPrefFront(entries, target, limit);
|
|
}
|
|
|
|
export function parseComposerModelPrefs(value: unknown): ComposerModelPrefs {
|
|
if (!value || typeof value !== 'object') {
|
|
return { recent: [], pinned: [] };
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
return {
|
|
recent: parsePrefEntries(record.recent).slice(0, COMPOSER_RECENT_MODEL_LIMIT),
|
|
pinned: parsePrefEntries(record.pinned).slice(0, COMPOSER_PINNED_MODEL_LIMIT),
|
|
};
|
|
}
|
|
|
|
function parsePrefEntries(value: unknown): ComposerModelPrefEntry[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const seen = new Set<string>();
|
|
const entries: ComposerModelPrefEntry[] = [];
|
|
for (const item of value) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const modelId = typeof (item as { modelId?: unknown }).modelId === 'string'
|
|
? (item as { modelId: string }).modelId.trim()
|
|
: '';
|
|
if (!modelId) continue;
|
|
const providerId = typeof (item as { providerId?: unknown }).providerId === 'string'
|
|
? (item as { providerId: string }).providerId.trim()
|
|
: undefined;
|
|
const entry: ComposerModelPrefEntry = providerId ? { providerId, modelId } : { modelId };
|
|
const key = composerModelPrefKey(entry);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
entries.push(entry);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
export function resolveComposerEnterModelId(input: {
|
|
query: string;
|
|
models: ComposerPickerModel[];
|
|
grouped: {
|
|
pinned: ComposerPickerModel[];
|
|
recent: ComposerPickerModel[];
|
|
rest: ComposerPickerModel[];
|
|
};
|
|
filtered: ComposerPickerModel[];
|
|
showCustom: boolean;
|
|
}): string | undefined {
|
|
const trimmed = input.query.trim();
|
|
if (!trimmed) return undefined;
|
|
const exact = input.models.find((model) => model.id.toLowerCase() === trimmed.toLowerCase());
|
|
if (exact) return exact.id;
|
|
if (input.showCustom) return trimmed;
|
|
return input.grouped.pinned[0]?.id
|
|
?? input.grouped.recent[0]?.id
|
|
?? input.grouped.rest[0]?.id
|
|
?? input.filtered[0]?.id;
|
|
}
|
|
|
|
export function filterComposerModels(
|
|
models: ComposerPickerModel[],
|
|
query: string,
|
|
): ComposerPickerModel[] {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return models;
|
|
return models.filter((model) => (
|
|
model.id.toLowerCase().includes(q)
|
|
|| model.name.toLowerCase().includes(q)
|
|
|| (model.description ?? '').toLowerCase().includes(q)
|
|
));
|
|
}
|
|
|
|
export function buildProviderSeedModels(provider: ProviderConfig): ComposerPickerModel[] {
|
|
const byId = new Map<string, ComposerPickerModel>();
|
|
const defaultModel = provider.defaultModel?.trim();
|
|
if (defaultModel) {
|
|
byId.set(defaultModel, { id: defaultModel, name: defaultModel });
|
|
}
|
|
const presetModels = PROVIDER_PRESETS[provider.providerId]?.defaultModels ?? [];
|
|
for (const modelId of presetModels) {
|
|
const id = modelId.trim();
|
|
if (!id || byId.has(id)) continue;
|
|
byId.set(id, { id, name: id });
|
|
}
|
|
return Array.from(byId.values());
|
|
}
|
|
|
|
export function mergeComposerModels(
|
|
...lists: Array<Iterable<ComposerPickerModel> | undefined>
|
|
): ComposerPickerModel[] {
|
|
const byId = new Map<string, ComposerPickerModel>();
|
|
for (const list of lists) {
|
|
if (!list) continue;
|
|
for (const model of list) {
|
|
const id = model.id.trim();
|
|
if (!id) continue;
|
|
const existing = byId.get(id);
|
|
if (!existing) {
|
|
byId.set(id, {
|
|
id,
|
|
name: model.name || id,
|
|
...(model.description ? { description: model.description } : {}),
|
|
...(model.contextWindow != null ? { contextWindow: model.contextWindow } : {}),
|
|
});
|
|
continue;
|
|
}
|
|
const contextWindow = existing.contextWindow ?? model.contextWindow;
|
|
byId.set(id, {
|
|
id,
|
|
name: existing.name === existing.id && model.name ? model.name : existing.name,
|
|
...(existing.description || model.description
|
|
? { description: existing.description || model.description }
|
|
: {}),
|
|
...(contextWindow != null ? { contextWindow } : {}),
|
|
});
|
|
}
|
|
}
|
|
return Array.from(byId.values());
|
|
}
|
|
|
|
export function canonicalizeEffortEncodedModelId(modelId: string): string {
|
|
const queryIndex = modelId.indexOf('?');
|
|
if (queryIndex < 0) return modelId;
|
|
const id = modelId.slice(0, queryIndex);
|
|
const params = new URLSearchParams(modelId.slice(queryIndex + 1));
|
|
const keys = [...params.keys()];
|
|
const effort = params.get('effort');
|
|
if (keys.length === 1 && keys[0] === 'effort' && effort) {
|
|
return `${id}/${effort}`;
|
|
}
|
|
return modelId;
|
|
}
|
|
|
|
export function resolveThinkingSelection(
|
|
selectedModelId: string | undefined,
|
|
presets: AgentModelPreset[],
|
|
): { preset?: AgentModelPreset; thinking?: string } {
|
|
if (!selectedModelId) return {};
|
|
const canonical = canonicalizeEffortEncodedModelId(selectedModelId);
|
|
const direct = presets.find((preset) => preset.id === canonical);
|
|
if (direct) return { preset: direct };
|
|
const viaThinking = presets.find(
|
|
(preset) => preset.thinkingLevels?.some((level) => `${preset.id}/${level}` === canonical),
|
|
);
|
|
if (!viaThinking) return {};
|
|
return {
|
|
preset: viaThinking,
|
|
thinking: canonical.slice(viaThinking.id.length + 1),
|
|
};
|
|
}
|
|
|
|
export function resolveModelSelectionWithThinking(
|
|
preset: AgentModelPreset,
|
|
preferredThinking?: string,
|
|
): string {
|
|
const levels = preset.thinkingLevels;
|
|
if (!levels?.length) return preset.id;
|
|
if (preferredThinking && levels.includes(preferredThinking)) {
|
|
return `${preset.id}/${preferredThinking}`;
|
|
}
|
|
return resolveAgentModelSelection(preset);
|
|
}
|
|
|
|
export function formatComposerThinkingLabel(level: string): string {
|
|
if (level === 'off') return 'Off';
|
|
return formatThinkingLabel(level);
|
|
}
|
|
|
|
export function resolvePinnedAndRecentModels(input: {
|
|
models: ComposerPickerModel[];
|
|
prefs: ComposerModelPrefs;
|
|
providerId?: string;
|
|
/** Catty custom IDs only. External catalogs must not resurrect stale prefs. */
|
|
allowMissing?: boolean;
|
|
}): {
|
|
pinned: ComposerPickerModel[];
|
|
recent: ComposerPickerModel[];
|
|
rest: ComposerPickerModel[];
|
|
} {
|
|
const byId = new Map(input.models.map((model) => [model.id, model]));
|
|
const matchesScope = (entry: ComposerModelPrefEntry) => (
|
|
!input.providerId || !entry.providerId || entry.providerId === input.providerId
|
|
);
|
|
const allowMissing = input.allowMissing ?? false;
|
|
const resolve = (entries: ComposerModelPrefEntry[]) => (
|
|
entries
|
|
.filter(matchesScope)
|
|
.map((entry) => byId.get(entry.modelId) ?? (allowMissing ? { id: entry.modelId, name: entry.modelId } : undefined))
|
|
.filter((model): model is ComposerPickerModel => model != null)
|
|
);
|
|
const pinned = resolve(input.prefs.pinned);
|
|
const pinnedIds = new Set(pinned.map((model) => model.id));
|
|
const recent = resolve(input.prefs.recent).filter((model) => !pinnedIds.has(model.id));
|
|
const reservedIds = new Set([...pinnedIds, ...recent.map((model) => model.id)]);
|
|
return {
|
|
pinned,
|
|
recent,
|
|
rest: input.models.filter((model) => !reservedIds.has(model.id)),
|
|
};
|
|
}
|