Skip to content
Merged
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
28 changes: 19 additions & 9 deletions src/app/v1/_lib/models/available-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ 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";
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 @@ -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";
Expand Down Expand Up @@ -259,7 +269,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 @@ -301,26 +311,26 @@ async function getAvailableModels(
/**
* 格式化为 OpenAI 响应
*/
function formatOpenAIResponse(models: FetchedModel[]): object {
export function formatOpenAIResponse(models: FetchedModel[]): OpenAIModelsResponse {
const now = Math.floor(Date.now() / 1000);
const data = models.map((m) => ({
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,
}));
Expand All @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions src/types/models.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
231 changes: 231 additions & 0 deletions tests/unit/proxy/available-models.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading