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" }]);
});
});