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

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getAiConfigModelIds, isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates";
import type { AiConfig } from "@/types/ai";
function config(overrides: Partial<AiConfig> = {}): AiConfig {
@@ -39,16 +39,3 @@ describe("isAiConfigModelCandidate", () => {
).toBe(true);
});
});
describe("getAiConfigModelIds", () => {
it("includes the default model once while preserving configured model order", () => {
expect(
getAiConfigModelIds(
config({
model: "gpt-default",
models: [{ name: "gpt-fast" }, { name: "gpt-default" }, { name: "gpt-default" }],
}),
),
).toEqual(["gpt-fast", "gpt-default"]);
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { effortPreferenceUpdateForCapability, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference";
import { normalizeAiConfig } from "@/stores/settingsStore";
import type { AiEffortCapability } from "@/types/ai";
const enumCapability: AiEffortCapability = {
kind: "enum",
options: [
{ id: "low", label: "Low", selection: { kind: "enum", value: "low" } },
{ id: "high", label: "High", selection: { kind: "enum", value: "high" } },
],
default: { kind: "enum", value: "low" },
source: "localCli",
};
describe("runtimeEffortFromPreference", () => {
it("leaves runtime effort absent so legacy effort settings remain available", () => {
const config = normalizeAiConfig({
provider: "codex-cli",
enableThinking: false,
reasoningLevel: "high",
runtimeEffort: runtimeEffortFromPreference(null),
});
expect(config.runtimeEffort).toBeUndefined();
expect(config.enableThinking).toBe(false);
expect(config.reasoningLevel).toBe("high");
expect(JSON.parse(JSON.stringify(config))).not.toHaveProperty("runtimeEffort");
});
it("preserves an explicitly selected provider default", () => {
expect(runtimeEffortFromPreference({ kind: "providerDefault" })).toEqual({ kind: "providerDefault" });
});
});
describe("effortPreferenceUpdateForCapability", () => {
it("does not create a preference while capability data loads", () => {
expect(effortPreferenceUpdateForCapability(enumCapability, null)).toBeUndefined();
});
it("preserves existing correction behavior for an unsupported explicit preference", () => {
expect(effortPreferenceUpdateForCapability(enumCapability, { kind: "enum", value: "future" })).toEqual({ kind: "enum", value: "low" });
});
});
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
import type { AiModelInfo } from "@/lib/backend/tauri";
describe("normalizeAiModelEffortLevels", () => {
it("preserves the CLI order while removing duplicates and unknown levels", () => {
expect(normalizeAiModelEffortLevels(["low", "high", "xhigh", "high", "future", null, "max"])).toEqual(["low", "high", "xhigh", "max"]);
});
it("returns no configurable levels when metadata is unavailable", () => {
expect(normalizeAiModelEffortLevels(undefined)).toEqual([]);
});
});
describe("normalizeClaudeCodeReasoningLevel", () => {
const model: AiModelInfo = {
id: "claude-sonnet-5",
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
};
it("keeps an effort level reported by the selected model", () => {
expect(normalizeClaudeCodeReasoningLevel("xhigh", model)).toBe("xhigh");
});
it("falls back to default for unsupported or missing model metadata", () => {
expect(normalizeClaudeCodeReasoningLevel("minimal", model)).toBe("default");
expect(normalizeClaudeCodeReasoningLevel("max", { id: "default" })).toBe("default");
});
});
@@ -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";
}
+3
View File
@@ -251,6 +251,9 @@ export const aiAgentStream = forward("aiAgentStream");
export const aiCancelStream = forward("aiCancelStream");
export const aiTestConnection = forward("aiTestConnection");
export const aiListModels = forward("aiListModels");
export const aiResolveModelEffort = forward("aiResolveModelEffort");
export const saveAiChatSelection = forward("saveAiChatSelection");
export const loadAiChatSelection = forward("loadAiChatSelection");
export const saveAiConfig = forward("saveAiConfig");
export const loadAiConfig = forward("loadAiConfig");
export const saveAiConfigs = forward("saveAiConfigs");
+13
View File
@@ -48,6 +48,7 @@ import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
import type { AiChatSelectionState, AiEffortCapability } from "@/types/ai";
import type {
AgentDriverInfo,
AiCompletionRequest,
@@ -1136,6 +1137,18 @@ export async function aiListModels(config: AiConfig): Promise<AiModelInfo[]> {
return post("/api/ai/models", { config });
}
export async function aiResolveModelEffort(config: AiConfig, modelId: string): Promise<AiEffortCapability> {
return post("/api/ai/model-effort", { config, modelId });
}
export async function saveAiChatSelection(selection: AiChatSelectionState): Promise<void> {
return post("/api/ai/chat-selection", { selection });
}
export async function loadAiChatSelection(): Promise<AiChatSelectionState | null> {
return get("/api/ai/chat-selection");
}
export type { AgentEvent } from "@/lib/backend/tauri";
function isAgentEvent(v: unknown): v is import("@/lib/backend/tauri").AgentEvent {
+14 -1
View File
@@ -47,7 +47,7 @@ import type {
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import type { AiConfig, AiConfigItem, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
import type { AiChatSelectionState, AiConfig, AiConfigItem, AiEffortCapability, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
import type {
@@ -358,6 +358,7 @@ export interface AiModelInfo {
id: string;
displayName?: string;
supportedEffortLevels?: AiEffortLevel[];
effortCapability?: AiEffortCapability;
}
export async function aiComplete(request: AiCompletionRequest): Promise<string> {
@@ -445,6 +446,18 @@ export async function aiListModels(config: AiConfig): Promise<AiModelInfo[]> {
return invoke("ai_list_models", { config });
}
export async function aiResolveModelEffort(config: AiConfig, modelId: string): Promise<AiEffortCapability> {
return invoke("ai_resolve_model_effort", { config, modelId });
}
export async function saveAiChatSelection(selection: AiChatSelectionState): Promise<void> {
return invoke("save_ai_chat_selection", { selection });
}
export async function loadAiChatSelection(): Promise<AiChatSelectionState | null> {
return invoke("load_ai_chat_selection");
}
export async function aiCancelStream(sessionId: string): Promise<boolean> {
return invoke("ai_cancel_stream", { sessionId });
}