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" }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user