feat(ai): unify model discovery and effort handling
This commit is contained in:
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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user