Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/app/v1/_lib/models/available-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ProxyProviderResolver } from "../proxy/provider-selector";

type ResponseFormat = "openai" | "anthropic" | "gemini" | "codex";

interface FetchedModel {
export interface FetchedModel {
id: string;
displayName?: string;
createdAt?: string;
Expand Down Expand Up @@ -125,7 +125,7 @@ function mapResponseFormatToClientFormat(format: ResponseFormat): ClientFormat {
/**
* 根据模型 ID 推断所有者
*/
function inferOwner(modelId: string): string {
export function inferOwner(modelId: string): string {
if (modelId.startsWith("claude-")) return "anthropic";
if (modelId.startsWith("gpt-") || modelId.startsWith("o1") || modelId.startsWith("o3"))
return "openai";
Expand Down Expand Up @@ -256,7 +256,7 @@ async function fetchModelsFromProvider(provider: Provider): Promise<FetchedModel
/**
* 根据客户端格式获取需要决策的 providerType 列表
*/
function getProviderTypesForFormat(clientFormat: ClientFormat): Provider["providerType"][] {
export function getProviderTypesForFormat(clientFormat: ClientFormat): Provider["providerType"][] {
switch (clientFormat) {
case "claude":
return ["claude", "claude-auth"];
Expand Down Expand Up @@ -298,7 +298,7 @@ async function getAvailableModels(
/**
* 格式化为 OpenAI 响应
*/
function formatOpenAIResponse(models: FetchedModel[]): object {
export function formatOpenAIResponse(models: FetchedModel[]): object {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了增强类型安全并使代码更易于维护,建议为 formatOpenAIResponse 函数的返回值定义一个明确的类型,而不是使用通用的 object 类型。这可以避免在测试代码中进行类型断言,并使函数签名更具自述性。虽然使用内联类型会使签名变长,但它能立即提供类型安全。未来可以考虑将其重构为独立的接口。

Suggested change
export function formatOpenAIResponse(models: FetchedModel[]): object {
export function formatOpenAIResponse(models: FetchedModel[]): { object: "list"; data: { id: string; object: "model"; created: number; owned_by: string }[] } {

const now = Math.floor(Date.now() / 1000);
const data = models.map((m) => ({
id: m.id,
Expand All @@ -313,7 +313,7 @@ function formatOpenAIResponse(models: FetchedModel[]): object {
/**
* 格式化为 Anthropic 响应
*/
function formatAnthropicResponse(models: FetchedModel[]): object {
export function formatAnthropicResponse(models: FetchedModel[]): object {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

formatOpenAIResponse 类似,建议为 formatAnthropicResponse 函数的返回值定义一个明确的类型,以增强类型安全。这有助于在编译时捕获潜在错误,并使代码更清晰。

Suggested change
export function formatAnthropicResponse(models: FetchedModel[]): object {
export function formatAnthropicResponse(models: FetchedModel[]): { data: { id: string; type: "model"; display_name: string; created_at: string }[]; has_more: false } {

const now = new Date().toISOString();
const data = models.map((m) => ({
id: m.id,
Expand All @@ -328,7 +328,7 @@ function formatAnthropicResponse(models: FetchedModel[]): object {
/**
* 格式化为 Gemini 响应
*/
function formatGeminiResponse(models: FetchedModel[]): object {
export function formatGeminiResponse(models: FetchedModel[]): object {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

同样,为 formatGeminiResponse 函数的返回值指定一个明确的类型,可以提高代码的健壮性和可读性。

Suggested change
export function formatGeminiResponse(models: FetchedModel[]): object {
export function formatGeminiResponse(models: FetchedModel[]): { models: { name: string; displayName: string; supportedGenerationMethods: string[] }[] } {

const geminiModels = models.map((m) => ({
name: `models/${m.id}`,
displayName: m.displayName || m.id,
Expand Down
226 changes: 226 additions & 0 deletions tests/unit/proxy/available-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { describe, expect, test } from "vitest";
import {
formatAnthropicResponse,
formatGeminiResponse,
formatOpenAIResponse,
getProviderTypesForFormat,
inferOwner,
type FetchedModel,
} from "@/app/v1/_lib/models/available-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");
});
Comment on lines +13 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这个测试用例可以通过 test.each 来简化,使其更简洁且易于扩展。这种数据驱动的方法可以将测试数据与测试逻辑分离。这个方法也可以应用于此文件中的其他类似测试。

    test.each([
      "claude-3-opus-20240229",
      "claude-3-sonnet-20240229",
      "claude-3-haiku-20240307",
      "claude-2.1",
      "claude-instant-1.2",
    ])("claude-* 模型 '%s' 应返回 anthropic", (modelId) => {
      expect(inferOwner(modelId)).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 = formatOpenAIResponse([]) as { object: string; data: unknown[] };
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 = formatOpenAIResponse(models) as {
object: string;
data: Array<{ id: string; object: string; created: number; owned_by: string }>;
};

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 = formatOpenAIResponse([{ id: "test" }]) as {
data: Array<{ created: number }>;
};
const after = Math.floor(Date.now() / 1000);

expect(result.data[0].created).toBeGreaterThanOrEqual(before);
expect(result.data[0].created).toBeLessThanOrEqual(after);
});
Comment on lines +132 to +141
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这个时间戳测试依赖于实际执行时间,可能会变得不稳定。使用 vitest 的模拟计时器 (vi.useFakeTimers()) 可以使此测试更加健壮和确定。您可以设置一个特定的时间,然后断言 created 时间戳完全匹配。

要使用 vi,请确保从 vitest 中导入它:
import { describe, expect, test, vi } from "vitest";

Suggested change
test("created 时间戳应为当前时间(秒)", () => {
const before = Math.floor(Date.now() / 1000);
const result = formatOpenAIResponse([{ id: "test" }]) as {
data: Array<{ created: number }>;
};
const after = Math.floor(Date.now() / 1000);
expect(result.data[0].created).toBeGreaterThanOrEqual(before);
expect(result.data[0].created).toBeLessThanOrEqual(after);
});
test("created 时间戳应为当前时间(秒)", () => {
vi.useFakeTimers();
const now = new Date();
vi.setSystemTime(now);
const expectedTimestamp = Math.floor(now.getTime() / 1000);
const result = formatOpenAIResponse([{ id: "test" }]) as {
data: Array<{ created: number }>;
};
expect(result.data[0].created).toBe(expectedTimestamp);
vi.useRealTimers();
});

});

describe("formatAnthropicResponse - Anthropic 格式响应", () => {
test("空模型列表应返回空 data 数组", () => {
const result = formatAnthropicResponse([]) as { data: unknown[]; has_more: boolean };
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 = formatAnthropicResponse(models) as {
data: Array<{ id: string; type: string; display_name: string; created_at: string }>;
has_more: boolean;
};

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 = formatAnthropicResponse([{ id: "test-model" }]) as {
data: Array<{ display_name: string }>;
};
expect(result.data[0].display_name).toBe("test-model");
});
});

describe("formatGeminiResponse - Gemini 格式响应", () => {
test("空模型列表应返回空 models 数组", () => {
const result = formatGeminiResponse([]) as { models: unknown[] };
expect(result.models).toEqual([]);
});

test("应正确格式化模型列表", () => {
const models: FetchedModel[] = [
{ id: "gemini-pro", displayName: "Gemini Pro" },
{ id: "gemini-1.5-flash" },
];

const result = formatGeminiResponse(models) as {
models: Array<{
name: string;
displayName: string;
supportedGenerationMethods: string[];
}>;
};

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 = formatGeminiResponse([{ id: "test-model" }]) as {
models: Array<{ name: string }>;
};
expect(result.models[0].name).toBe("models/test-model");
});

test("缺少 displayName 时应使用 id", () => {
const result = formatGeminiResponse([{ id: "test-model" }]) as {
models: Array<{ displayName: string }>;
};
expect(result.models[0].displayName).toBe("test-model");
});
});
Loading