diff --git a/src/app/v1/_lib/models/available-models.ts b/src/app/v1/_lib/models/available-models.ts index 254905955..fd102b75e 100644 --- a/src/app/v1/_lib/models/available-models.ts +++ b/src/app/v1/_lib/models/available-models.ts @@ -3,6 +3,11 @@ import { request as undiciRequest } from "undici"; import { logger } from "@/lib/logger"; import { createProxyAgentForProvider } from "@/lib/proxy-agent"; import { validateApiKeyAndGetUser } from "@/repository/key"; +import type { + AnthropicModelsResponse, + GeminiModelsResponse, + OpenAIModelsResponse, +} from "@/types/models"; import type { Provider } from "@/types/provider"; import { extractApiKeyFromHeaders } from "../proxy/auth-guard"; import type { ClientFormat } from "../proxy/format-mapper"; @@ -10,7 +15,7 @@ import { ProxyProviderResolver } from "../proxy/provider-selector"; type ResponseFormat = "openai" | "anthropic" | "gemini" | "codex"; -interface FetchedModel { +export interface FetchedModel { id: string; displayName?: string; createdAt?: string; @@ -122,10 +127,15 @@ function mapResponseFormatToClientFormat(format: ResponseFormat): ClientFormat { } } +/** + * 模型所有者类型 + */ +export type ModelOwner = "anthropic" | "openai" | "google" | "deepseek" | "alibaba" | "unknown"; + /** * 根据模型 ID 推断所有者 */ -function inferOwner(modelId: string): string { +export function inferOwner(modelId: string): ModelOwner { if (modelId.startsWith("claude-")) return "anthropic"; if (modelId.startsWith("gpt-") || modelId.startsWith("o1") || modelId.startsWith("o3")) return "openai"; @@ -259,7 +269,7 @@ async function fetchModelsFromProvider(provider: Provider): Promise ({ id: m.id, - object: "model", + object: "model" as const, created: now, owned_by: inferOwner(m.id), })); - return { object: "list", data }; + return { object: "list" as const, data }; } /** * 格式化为 Anthropic 响应 */ -function formatAnthropicResponse(models: FetchedModel[]): object { +export function formatAnthropicResponse(models: FetchedModel[]): AnthropicModelsResponse { const now = new Date().toISOString(); const data = models.map((m) => ({ id: m.id, - type: "model", + type: "model" as const, display_name: m.displayName || m.id, created_at: m.createdAt || now, })); @@ -331,7 +341,7 @@ function formatAnthropicResponse(models: FetchedModel[]): object { /** * 格式化为 Gemini 响应 */ -function formatGeminiResponse(models: FetchedModel[]): object { +export function formatGeminiResponse(models: FetchedModel[]): GeminiModelsResponse { const geminiModels = models.map((m) => ({ name: `models/${m.id}`, displayName: m.displayName || m.id, diff --git a/src/types/models.ts b/src/types/models.ts new file mode 100644 index 000000000..5bdd170ea --- /dev/null +++ b/src/types/models.ts @@ -0,0 +1,41 @@ +/** + * 模型列表响应类型定义 + * 支持 OpenAI、Anthropic、Gemini 三种格式 + */ + +// OpenAI 模型列表响应 +export interface OpenAIModelsResponse { + object: "list"; + data: OpenAIModel[]; +} + +export interface OpenAIModel { + id: string; + object: "model"; + created: number; + owned_by: string; +} + +// Anthropic 模型列表响应 +export interface AnthropicModelsResponse { + data: AnthropicModel[]; + has_more: boolean; +} + +export interface AnthropicModel { + id: string; + type: "model"; + display_name: string; + created_at: string; +} + +// Gemini 模型列表响应 +export interface GeminiModelsResponse { + models: GeminiModel[]; +} + +export interface GeminiModel { + name: string; + displayName: string; + supportedGenerationMethods: string[]; +} diff --git a/tests/unit/proxy/available-models.test.ts b/tests/unit/proxy/available-models.test.ts new file mode 100644 index 000000000..034be2fb9 --- /dev/null +++ b/tests/unit/proxy/available-models.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test, vi } from "vitest"; + +// Mock dependencies that cause import issues +vi.mock("@/lib/proxy-agent", () => ({ + createProxyAgentForProvider: vi.fn(), +})); + +vi.mock("@/repository/key", () => ({ + validateApiKeyAndGetUser: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ + ProxyProviderResolver: { + selectProviderByType: vi.fn(), + }, +})); + +import { + formatAnthropicResponse, + formatGeminiResponse, + formatOpenAIResponse, + getProviderTypesForFormat, + inferOwner, + type FetchedModel, +} from "@/app/v1/_lib/models/available-models"; +import type { + AnthropicModelsResponse, + GeminiModelsResponse, + OpenAIModelsResponse, +} from "@/types/models"; + +describe("inferOwner - 根据模型 ID 推断所有者", () => { + describe("Anthropic 模型", () => { + test("claude-* 模型应返回 anthropic", () => { + expect(inferOwner("claude-3-opus-20240229")).toBe("anthropic"); + expect(inferOwner("claude-3-sonnet-20240229")).toBe("anthropic"); + expect(inferOwner("claude-3-haiku-20240307")).toBe("anthropic"); + expect(inferOwner("claude-2.1")).toBe("anthropic"); + expect(inferOwner("claude-instant-1.2")).toBe("anthropic"); + }); + }); + + describe("OpenAI 模型", () => { + test("gpt-* 模型应返回 openai", () => { + expect(inferOwner("gpt-4")).toBe("openai"); + expect(inferOwner("gpt-4-turbo")).toBe("openai"); + expect(inferOwner("gpt-3.5-turbo")).toBe("openai"); + expect(inferOwner("gpt-4o")).toBe("openai"); + }); + + test("o1* 模型应返回 openai", () => { + expect(inferOwner("o1-preview")).toBe("openai"); + expect(inferOwner("o1-mini")).toBe("openai"); + expect(inferOwner("o1")).toBe("openai"); + }); + + test("o3* 模型应返回 openai", () => { + expect(inferOwner("o3-mini")).toBe("openai"); + expect(inferOwner("o3")).toBe("openai"); + }); + }); + + describe("Google 模型", () => { + test("gemini-* 模型应返回 google", () => { + expect(inferOwner("gemini-pro")).toBe("google"); + expect(inferOwner("gemini-1.5-pro")).toBe("google"); + expect(inferOwner("gemini-1.5-flash")).toBe("google"); + expect(inferOwner("gemini-2.0-flash-exp")).toBe("google"); + }); + }); + + describe("DeepSeek 模型", () => { + test("deepseek* 模型应返回 deepseek", () => { + expect(inferOwner("deepseek-chat")).toBe("deepseek"); + expect(inferOwner("deepseek-coder")).toBe("deepseek"); + expect(inferOwner("deepseek-v3")).toBe("deepseek"); + }); + }); + + describe("Alibaba 模型", () => { + test("qwen* 模型应返回 alibaba", () => { + expect(inferOwner("qwen-turbo")).toBe("alibaba"); + expect(inferOwner("qwen-plus")).toBe("alibaba"); + expect(inferOwner("qwen-max")).toBe("alibaba"); + }); + }); + + describe("未知模型", () => { + test("无法识别的模型应返回 unknown", () => { + expect(inferOwner("llama-2-70b")).toBe("unknown"); + expect(inferOwner("mistral-7b")).toBe("unknown"); + expect(inferOwner("custom-model")).toBe("unknown"); + }); + }); +}); + +describe("getProviderTypesForFormat - 客户端格式到 Provider 类型映射", () => { + test("claude 格式应返回 claude 和 claude-auth 类型", () => { + expect(getProviderTypesForFormat("claude")).toEqual(["claude", "claude-auth"]); + }); + + test("openai 格式应返回 codex 和 openai-compatible 类型", () => { + expect(getProviderTypesForFormat("openai")).toEqual(["codex", "openai-compatible"]); + }); + + test("gemini 格式应返回 gemini 和 gemini-cli 类型", () => { + expect(getProviderTypesForFormat("gemini")).toEqual(["gemini", "gemini-cli"]); + }); + + test("gemini-cli 格式应返回 gemini 和 gemini-cli 类型", () => { + expect(getProviderTypesForFormat("gemini-cli")).toEqual(["gemini", "gemini-cli"]); + }); + + test("response 格式应仅返回 codex 类型", () => { + expect(getProviderTypesForFormat("response")).toEqual(["codex"]); + }); +}); + +describe("formatOpenAIResponse - OpenAI 格式响应", () => { + test("空模型列表应返回空 data 数组", () => { + const result: OpenAIModelsResponse = formatOpenAIResponse([]); + expect(result.object).toBe("list"); + expect(result.data).toEqual([]); + }); + + test("应正确格式化模型列表", () => { + const models: FetchedModel[] = [ + { id: "gpt-4" }, + { id: "claude-3-opus-20240229" }, + { id: "gemini-pro" }, + ]; + + const result: OpenAIModelsResponse = formatOpenAIResponse(models); + + expect(result.object).toBe("list"); + expect(result.data).toHaveLength(3); + + expect(result.data[0].id).toBe("gpt-4"); + expect(result.data[0].object).toBe("model"); + expect(result.data[0].owned_by).toBe("openai"); + expect(typeof result.data[0].created).toBe("number"); + + expect(result.data[1].id).toBe("claude-3-opus-20240229"); + expect(result.data[1].owned_by).toBe("anthropic"); + + expect(result.data[2].id).toBe("gemini-pro"); + expect(result.data[2].owned_by).toBe("google"); + }); + + test("created 时间戳应为当前时间(秒)", () => { + const before = Math.floor(Date.now() / 1000); + const result: OpenAIModelsResponse = formatOpenAIResponse([{ id: "test" }]); + const after = Math.floor(Date.now() / 1000); + + expect(result.data[0].created).toBeGreaterThanOrEqual(before); + expect(result.data[0].created).toBeLessThanOrEqual(after); + }); +}); + +describe("formatAnthropicResponse - Anthropic 格式响应", () => { + test("空模型列表应返回空 data 数组", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([]); + expect(result.data).toEqual([]); + expect(result.has_more).toBe(false); + }); + + test("应正确格式化模型列表", () => { + const models: FetchedModel[] = [ + { + id: "claude-3-opus-20240229", + displayName: "Claude 3 Opus", + createdAt: "2024-02-29T00:00:00Z", + }, + { id: "claude-3-sonnet-20240229" }, + ]; + + const result: AnthropicModelsResponse = formatAnthropicResponse(models); + + expect(result.has_more).toBe(false); + expect(result.data).toHaveLength(2); + + expect(result.data[0].id).toBe("claude-3-opus-20240229"); + expect(result.data[0].type).toBe("model"); + expect(result.data[0].display_name).toBe("Claude 3 Opus"); + expect(result.data[0].created_at).toBe("2024-02-29T00:00:00Z"); + + expect(result.data[1].id).toBe("claude-3-sonnet-20240229"); + expect(result.data[1].display_name).toBe("claude-3-sonnet-20240229"); + expect(result.data[1].created_at).toBeDefined(); + }); + + test("缺少 displayName 时应使用 id 作为 display_name", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([{ id: "test-model" }]); + expect(result.data[0].display_name).toBe("test-model"); + }); +}); + +describe("formatGeminiResponse - Gemini 格式响应", () => { + test("空模型列表应返回空 models 数组", () => { + const result: GeminiModelsResponse = formatGeminiResponse([]); + expect(result.models).toEqual([]); + }); + + test("应正确格式化模型列表", () => { + const models: FetchedModel[] = [ + { id: "gemini-pro", displayName: "Gemini Pro" }, + { id: "gemini-1.5-flash" }, + ]; + + const result: GeminiModelsResponse = formatGeminiResponse(models); + + expect(result.models).toHaveLength(2); + + expect(result.models[0].name).toBe("models/gemini-pro"); + expect(result.models[0].displayName).toBe("Gemini Pro"); + expect(result.models[0].supportedGenerationMethods).toEqual(["generateContent"]); + + expect(result.models[1].name).toBe("models/gemini-1.5-flash"); + expect(result.models[1].displayName).toBe("gemini-1.5-flash"); + }); + + test("模型名称应添加 models/ 前缀", () => { + const result: GeminiModelsResponse = formatGeminiResponse([{ id: "test-model" }]); + expect(result.models[0].name).toBe("models/test-model"); + }); + + test("缺少 displayName 时应使用 id", () => { + const result: GeminiModelsResponse = formatGeminiResponse([{ id: "test-model" }]); + expect(result.models[0].displayName).toBe("test-model"); + }); +});