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

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AiConfigItem, AiEffortCapability } from "@/types/ai";
const apiMock = vi.hoisted(() => ({
aiListModels: vi.fn(),
aiResolveModelEffort: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
import { useAiModelCatalog } from "@/composables/useAiModelCatalog";
function config(id = "config-1"): AiConfigItem {
return {
id,
name: "OpenAI",
isDefault: true,
provider: "openai",
apiKey: "secret",
authMethod: "bearer",
endpoint: "https://api.example.com/v1",
model: "",
apiStyle: "responses",
};
}
describe("useAiModelCatalog", () => {
const catalog = useAiModelCatalog();
beforeEach(() => {
catalog.catalogs.clear();
catalog.effortCatalogs.clear();
apiMock.aiListModels.mockReset();
apiMock.aiResolveModelEffort.mockReset();
});
it("deduplicates concurrent model requests and removes duplicate model IDs", async () => {
apiMock.aiListModels.mockResolvedValue([
{ id: "gpt-5.6", displayName: "GPT 5.6" },
{ id: "gpt-5.6", displayName: "Duplicate" },
]);
const [first, second] = await Promise.all([catalog.loadModels(config()), catalog.loadModels(config())]);
expect(apiMock.aiListModels).toHaveBeenCalledTimes(1);
expect(first).toEqual([{ id: "gpt-5.6", displayName: "GPT 5.6" }]);
expect(second).toEqual(first);
expect(catalog.catalogs.get("config-1")?.status).toBe("ready");
});
it("reuses effort capability returned with the model catalog", async () => {
const capability: AiEffortCapability = {
kind: "enum",
options: [{ id: "low", label: "Low", selection: { kind: "enum", value: "low" } }],
default: { kind: "enum", value: "low" },
source: "providerApi",
};
apiMock.aiListModels.mockResolvedValue([{ id: "claude-model", effortCapability: capability }]);
await catalog.loadModels(config());
const resolved = await catalog.resolveEffort(config(), "claude-model");
expect(resolved).toEqual(capability);
expect(apiMock.aiResolveModelEffort).not.toHaveBeenCalled();
});
it("keeps a provider failure scoped and allows an explicit retry", async () => {
apiMock.aiListModels.mockRejectedValueOnce(new Error("temporary failure")).mockResolvedValueOnce([{ id: "recovered" }]);
await expect(catalog.loadModels(config())).rejects.toThrow("temporary failure");
expect(catalog.catalogs.get("config-1")).toMatchObject({ status: "error", error: "temporary failure" });
await expect(catalog.loadModels(config(), true)).resolves.toEqual([{ id: "recovered" }]);
expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
});
it("invalidates model and effort caches when provider runtime configuration changes", async () => {
const initial = config();
const updated = { ...initial, endpoint: "https://api.changed.example.com/v1" };
apiMock.aiListModels.mockResolvedValueOnce([{ id: "old-model" }]).mockResolvedValueOnce([{ id: "new-model" }]);
apiMock.aiResolveModelEffort.mockResolvedValueOnce({ kind: "unsupported" }).mockResolvedValueOnce({
kind: "enum",
options: [{ id: "high", label: "High", selection: { kind: "enum", value: "high" } }],
default: { kind: "enum", value: "high" },
source: "providerApi",
});
await expect(catalog.loadModels(initial)).resolves.toEqual([{ id: "old-model" }]);
await expect(catalog.resolveEffort(initial, "manual-model")).resolves.toEqual({ kind: "unsupported" });
await expect(catalog.loadModels(updated)).resolves.toEqual([{ id: "new-model" }]);
await expect(catalog.resolveEffort(updated, "manual-model")).resolves.toMatchObject({ kind: "enum" });
expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
expect(apiMock.aiResolveModelEffort).toHaveBeenCalledTimes(2);
});
it("does not let a stale request overwrite a newer provider catalog", async () => {
let resolveInitial: ((models: { id: string }[]) => void) | undefined;
apiMock.aiListModels
.mockReturnValueOnce(
new Promise((resolve) => {
resolveInitial = resolve;
}),
)
.mockResolvedValueOnce([{ id: "new-model" }]);
const initialRequest = catalog.loadModels(config());
const updatedRequest = catalog.loadModels({ ...config(), endpoint: "https://api.changed.example.com/v1" });
await expect(updatedRequest).resolves.toEqual([{ id: "new-model" }]);
resolveInitial?.([{ id: "old-model" }]);
await expect(initialRequest).resolves.toEqual([{ id: "old-model" }]);
expect(catalog.catalogs.get("config-1")?.models).toEqual([{ id: "new-model" }]);
});
});
@@ -0,0 +1,176 @@
import { reactive } from "vue";
import * as api from "@/lib/backend/api";
import type { AiModelInfo } from "@/lib/backend/tauri";
import type { AiConfigItem, AiEffortCapability } from "@/types/ai";
const CATALOG_TTL_MS = 5 * 60 * 1000;
export interface AiModelCatalogEntry {
status: "idle" | "loading" | "ready" | "error";
models: AiModelInfo[];
signature?: string;
error?: string;
loadedAt?: number;
}
interface AiEffortCatalogEntry {
status: "idle" | "loading" | "ready" | "error";
capability?: AiEffortCapability;
signature?: string;
error?: string;
loadedAt?: number;
}
const catalogs = reactive(new Map<string, AiModelCatalogEntry>());
const effortCatalogs = reactive(new Map<string, AiEffortCatalogEntry>());
const modelRequests = new Map<string, Promise<AiModelInfo[]>>();
const effortRequests = new Map<string, Promise<AiEffortCapability>>();
function effortKey(configId: string, modelId: string): string {
return JSON.stringify([configId, modelId]);
}
function sortedRecord(record: Record<string, string> | undefined): [string, string][] {
return Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right));
}
function fingerprint(value: string): string {
let first = 0xdeadbeef;
let second = 0x41c6ce57;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
first = Math.imul(first ^ code, 2654435761);
second = Math.imul(second ^ code, 1597334677);
}
first = Math.imul(first ^ (first >>> 16), 2246822507) ^ Math.imul(second ^ (second >>> 13), 3266489909);
second = Math.imul(second ^ (second >>> 16), 2246822507) ^ Math.imul(first ^ (first >>> 13), 3266489909);
return `${(second >>> 0).toString(16).padStart(8, "0")}${(first >>> 0).toString(16).padStart(8, "0")}`;
}
function configSignature(config: AiConfigItem): string {
return JSON.stringify({
provider: config.provider,
authMethod: config.authMethod,
apiStyle: config.apiStyle,
proxyEnabled: config.proxyEnabled ?? false,
contextWindow: config.contextWindow ?? null,
codexCliPath: config.codexCliPath ?? null,
claudeCodeCliPath: config.claudeCodeCliPath ?? null,
connectionFingerprint: fingerprint(
JSON.stringify({
apiKey: config.apiKey,
endpoint: config.endpoint,
proxyUrl: config.proxyUrl ?? "",
codexCliEnv: sortedRecord(config.codexCliEnv),
claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv),
}),
),
});
}
function fresh(loadedAt: number | undefined): boolean {
return typeof loadedAt === "number" && Date.now() - loadedAt < CATALOG_TTL_MS;
}
function configPayload(config: AiConfigItem, modelId = config.model): AiConfigItem {
return { ...config, model: modelId, runtimeEffort: null };
}
async function loadModels(config: AiConfigItem, force = false): Promise<AiModelInfo[]> {
const signature = configSignature(config);
const current = catalogs.get(config.id);
if (!force && current?.status === "ready" && current.signature === signature && fresh(current.loadedAt)) {
return current.models;
}
const requestKey = JSON.stringify([config.id, signature]);
const pending = modelRequests.get(requestKey);
if (pending) return pending;
const previousModels = current?.signature === signature ? current.models : [];
catalogs.set(config.id, { status: "loading", models: previousModels, signature });
const request = api
.aiListModels(configPayload(config))
.then((models) => {
const seen = new Set<string>();
const uniqueModels = models.filter((model) => {
const id = model.id.trim();
if (!id || seen.has(id)) return false;
seen.add(id);
return true;
});
if (catalogs.get(config.id)?.signature !== signature) return uniqueModels;
catalogs.set(config.id, { status: "ready", models: uniqueModels, signature, loadedAt: Date.now() });
for (const model of uniqueModels) {
if (model.effortCapability) {
effortCatalogs.set(effortKey(config.id, model.id), {
status: "ready",
capability: model.effortCapability,
signature,
loadedAt: Date.now(),
});
}
}
return uniqueModels;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (catalogs.get(config.id)?.signature === signature) {
catalogs.set(config.id, { status: "error", models: previousModels, signature, error: message });
}
throw error;
})
.finally(() => {
modelRequests.delete(requestKey);
});
modelRequests.set(requestKey, request);
return request;
}
async function resolveEffort(config: AiConfigItem, modelId: string, force = false): Promise<AiEffortCapability> {
const key = effortKey(config.id, modelId);
const signature = configSignature(config);
const current = effortCatalogs.get(key);
if (!force && current?.status === "ready" && current.signature === signature && current.capability && fresh(current.loadedAt)) {
return current.capability;
}
const requestKey = JSON.stringify([key, signature]);
const pending = effortRequests.get(requestKey);
if (pending) return pending;
const previousCapability = current?.signature === signature ? current.capability : undefined;
effortCatalogs.set(key, { status: "loading", capability: previousCapability, signature });
const request = api
.aiResolveModelEffort(configPayload(config, modelId), modelId)
.then((capability) => {
if (effortCatalogs.get(key)?.signature === signature) {
effortCatalogs.set(key, { status: "ready", capability, signature, loadedAt: Date.now() });
}
return capability;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (effortCatalogs.get(key)?.signature === signature) {
effortCatalogs.set(key, { status: "error", capability: previousCapability, signature, error: message });
}
throw error;
})
.finally(() => {
effortRequests.delete(requestKey);
});
effortRequests.set(requestKey, request);
return request;
}
export function useAiModelCatalog() {
return {
catalogs,
effortCatalogs,
loadModels,
resolveEffort,
effortKey,
};
}