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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user