feat(ai): unify model discovery and effort handling

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-07-28 21:37:55 +08:00
1 parent c54b95e274
commit c7e7b7c9d9
41 files changed
+3291 -634

No files matched your search

@@ -2,16 +2,8 @@ import type { AiConfig } from "@/types/ai";
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli"]);
export function getAiConfigModelIds(config: Pick<AiConfig, "model" | "models">): string[] {
const configuredModels = [...new Set(config.models?.map((model) => model.name) ?? [])];
return config.model && !configuredModels.includes(config.model) ? [config.model, ...configuredModels] : configuredModels;
}
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean {
// CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass.
if (CLI_PROVIDERS.has(config.provider)) return true;
if (!config.endpoint?.trim() || (requiresApiKey && !config.apiKey?.trim())) return false;
// A discovered model list is sufficient even before the user chooses a default model.
return getAiConfigModelIds(config).some((model) => !!model.trim());
return !!config.endpoint?.trim() && (!requiresApiKey || !!config.apiKey?.trim());
}
@@ -0,0 +1,32 @@
import type { AiEffortCapability, AiEffortSelection } from "@/types/ai";
export function runtimeEffortFromPreference(selection: AiEffortSelection | null): AiEffortSelection | undefined {
return selection ?? undefined;
}
export function effortSelectionEquals(left: AiEffortSelection | null, right: AiEffortSelection): boolean {
return !!left && left.kind === right.kind && ("value" in left ? left.value === ("value" in right ? right.value : undefined) : !("value" in right));
}
function effortSelectionSupported(capability: AiEffortCapability, selection: AiEffortSelection): boolean {
if (selection.kind === "providerDefault") return true;
if (capability.kind === "enum") {
return capability.options.some((option) => effortSelectionEquals(option.selection, selection));
}
if (capability.kind === "integer") {
if (selection.kind === "integer") {
const isSteppedValue = selection.value >= capability.min && selection.value <= capability.max && (selection.value - capability.min) % capability.step === 0;
return isSteppedValue || !!capability.specialValues?.some((option) => effortSelectionEquals(option.selection, selection));
}
return !!capability.specialValues?.some((option) => effortSelectionEquals(option.selection, selection));
}
if (capability.kind === "boolean") return selection.kind === "boolean" || selection.kind === "disabled";
if (capability.kind === "freeText") return selection.kind === "text";
return false;
}
export function effortPreferenceUpdateForCapability(capability: AiEffortCapability, selection: AiEffortSelection | null): AiEffortSelection | null | undefined {
if (!selection || effortSelectionSupported(capability, selection)) return undefined;
if (capability.kind === "unsupported" || capability.kind === "freeText") return null;
return capability.default;
}
-28
View File
@@ -1,28 +0,0 @@
import type { AiEffortLevel, AiReasoningLevel } from "@/types/ai";
interface AiModelEffortMetadata {
supportedEffortLevels?: unknown;
}
const AI_EFFORT_LEVELS = new Set<AiEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
export function normalizeAiModelEffortLevels(value: unknown): AiEffortLevel[] {
if (!Array.isArray(value)) return [];
const seen = new Set<AiEffortLevel>();
const levels: AiEffortLevel[] = [];
for (const level of value) {
if (typeof level !== "string" || !AI_EFFORT_LEVELS.has(level as AiEffortLevel)) continue;
const normalized = level as AiEffortLevel;
if (seen.has(normalized)) continue;
seen.add(normalized);
levels.push(normalized);
}
return levels;
}
export function normalizeClaudeCodeReasoningLevel(reasoningLevel: AiReasoningLevel | undefined, model: AiModelEffortMetadata | undefined): AiReasoningLevel {
if (!reasoningLevel || reasoningLevel === "default") return "default";
const supported = normalizeAiModelEffortLevels(model?.supportedEffortLevels);
return supported.includes(reasoningLevel as AiEffortLevel) ? reasoningLevel : "default";
}