mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-01-29 22:02:05 +00:00
move llms to core package
This commit is contained in:
48
packages/cli/src/types/inquirer.d.ts
vendored
Normal file
48
packages/cli/src/types/inquirer.d.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
// Type declarations for @inquirer packages
|
||||
declare module '@inquirer/input' {
|
||||
import { DistinctChoice } from '@inquirer/core';
|
||||
interface PromptConfig {
|
||||
message: string;
|
||||
default?: string;
|
||||
}
|
||||
export default function prompt<T = string>(config: PromptConfig): Promise<T>;
|
||||
}
|
||||
|
||||
declare module '@inquirer/confirm' {
|
||||
interface PromptConfig {
|
||||
message: string;
|
||||
default?: boolean;
|
||||
}
|
||||
export default function prompt(config: PromptConfig): Promise<boolean>;
|
||||
}
|
||||
|
||||
declare module '@inquirer/select' {
|
||||
export default function prompt<T = string>(config: {
|
||||
message: string;
|
||||
choices: Array<{ name: string; value: T; description?: string }>;
|
||||
default?: T;
|
||||
}): Promise<T>;
|
||||
}
|
||||
|
||||
declare module '@inquirer/password' {
|
||||
interface PromptConfig {
|
||||
message: string;
|
||||
mask?: string;
|
||||
}
|
||||
export default function prompt(config: PromptConfig): Promise<string>;
|
||||
}
|
||||
|
||||
declare module '@inquirer/checkbox' {
|
||||
export default function prompt<T = string>(config: {
|
||||
message: string;
|
||||
choices: Array<{ name: string; value: T; checked?: boolean }>;
|
||||
}): Promise<T[]>;
|
||||
}
|
||||
|
||||
declare module '@inquirer/editor' {
|
||||
interface PromptConfig {
|
||||
message: string;
|
||||
default?: string;
|
||||
}
|
||||
export default function prompt(config: PromptConfig): Promise<string>;
|
||||
}
|
||||
17
packages/core/.npmignore
Normal file
17
packages/core/.npmignore
Normal file
@@ -0,0 +1,17 @@
|
||||
src
|
||||
node_modules
|
||||
.claude
|
||||
CLAUDE.md
|
||||
screenshoots
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.blog
|
||||
docs
|
||||
scripts
|
||||
eslint.config.cjs
|
||||
*.log
|
||||
config.json
|
||||
tsconfig.json
|
||||
dist
|
||||
52
packages/core/package.json
Normal file
52
packages/core/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@musistudio/llms",
|
||||
"version": "1.0.51",
|
||||
"description": "A universal LLM API transformation server",
|
||||
"main": "dist/cjs/server.cjs",
|
||||
"module": "dist/esm/server.mjs",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/esm/server.mjs",
|
||||
"require": "./dist/cjs/server.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"tsx": "tsx",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"build:watch": "tsx scripts/build.ts --watch",
|
||||
"dev": "nodemon",
|
||||
"start": "node dist/cjs/server.cjs",
|
||||
"start:esm": "node dist/esm/server.mjs",
|
||||
"lint": "eslint src --ext .ts,.tsx"
|
||||
},
|
||||
"keywords": [
|
||||
"llm",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"gemini",
|
||||
"transformer",
|
||||
"api"
|
||||
],
|
||||
"author": "musistudio",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.54.0",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@google/genai": "^1.7.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"fastify": "^5.4.0",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"json5": "^2.2.3",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"openai": "^5.6.0",
|
||||
"undici": "^7.10.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.15",
|
||||
"esbuild": "^0.25.1",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
62
packages/core/scripts/build.ts
Normal file
62
packages/core/scripts/build.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const baseConfig: esbuild.BuildOptions = {
|
||||
entryPoints: ["src/server.ts"],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
platform: "node",
|
||||
target: "node18",
|
||||
plugins: [],
|
||||
external: ["fastify", "dotenv", "@fastify/cors", "undici"],
|
||||
};
|
||||
|
||||
const cjsConfig: esbuild.BuildOptions = {
|
||||
...baseConfig,
|
||||
outdir: "dist/cjs",
|
||||
format: "cjs",
|
||||
outExtension: { ".js": ".cjs" },
|
||||
};
|
||||
|
||||
const esmConfig: esbuild.BuildOptions = {
|
||||
...baseConfig,
|
||||
outdir: "dist/esm",
|
||||
format: "esm",
|
||||
outExtension: { ".js": ".mjs" },
|
||||
};
|
||||
|
||||
async function build() {
|
||||
console.log("Building CJS and ESM versions...");
|
||||
|
||||
const cjsCtx = await esbuild.context(cjsConfig);
|
||||
const esmCtx = await esbuild.context(esmConfig);
|
||||
|
||||
if (watch) {
|
||||
console.log("Watching for changes...");
|
||||
await Promise.all([
|
||||
cjsCtx.watch(),
|
||||
esmCtx.watch(),
|
||||
]);
|
||||
} else {
|
||||
await Promise.all([
|
||||
cjsCtx.rebuild(),
|
||||
esmCtx.rebuild(),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
cjsCtx.dispose(),
|
||||
esmCtx.dispose(),
|
||||
]);
|
||||
|
||||
console.log("✅ Build completed successfully!");
|
||||
console.log(" - CJS: dist/cjs/server.cjs");
|
||||
console.log(" - ESM: dist/esm/server.mjs");
|
||||
}
|
||||
}
|
||||
|
||||
build().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
39
packages/core/src/api/middleware.ts
Normal file
39
packages/core/src/api/middleware.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
|
||||
export interface ApiError extends Error {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function createApiError(
|
||||
message: string,
|
||||
statusCode: number = 500,
|
||||
code: string = "internal_error",
|
||||
type: string = "api_error"
|
||||
): ApiError {
|
||||
const error = new Error(message) as ApiError;
|
||||
error.statusCode = statusCode;
|
||||
error.code = code;
|
||||
error.type = type;
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function errorHandler(
|
||||
error: ApiError,
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
request.log.error(error);
|
||||
|
||||
const statusCode = error.statusCode || 500;
|
||||
const response = {
|
||||
error: {
|
||||
message: error.message + error.stack || "Internal Server Error",
|
||||
type: error.type || "api_error",
|
||||
code: error.code || "internal_error",
|
||||
},
|
||||
};
|
||||
|
||||
return reply.code(statusCode).send(response);
|
||||
}
|
||||
571
packages/core/src/api/routes.ts
Normal file
571
packages/core/src/api/routes.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import {
|
||||
FastifyInstance,
|
||||
FastifyPluginAsync,
|
||||
FastifyRequest,
|
||||
FastifyReply,
|
||||
} from "fastify";
|
||||
import { RegisterProviderRequest, LLMProvider } from "@/types/llm";
|
||||
import { sendUnifiedRequest } from "@/utils/request";
|
||||
import { createApiError } from "./middleware";
|
||||
import { version } from "../../package.json";
|
||||
|
||||
/**
|
||||
* 处理transformer端点的主函数
|
||||
* 协调整个请求处理流程:验证提供者、处理请求转换器、发送请求、处理响应转换器、格式化响应
|
||||
*/
|
||||
async function handleTransformerEndpoint(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
fastify: FastifyInstance,
|
||||
transformer: any
|
||||
) {
|
||||
const body = req.body as any;
|
||||
const providerName = req.provider!;
|
||||
const provider = fastify._server!.providerService.getProvider(providerName);
|
||||
|
||||
// 验证提供者是否存在
|
||||
if (!provider) {
|
||||
throw createApiError(
|
||||
`Provider '${providerName}' not found`,
|
||||
404,
|
||||
"provider_not_found"
|
||||
);
|
||||
}
|
||||
|
||||
// 处理请求转换器链
|
||||
const { requestBody, config, bypass } = await processRequestTransformers(
|
||||
body,
|
||||
provider,
|
||||
transformer,
|
||||
req.headers,
|
||||
{
|
||||
req,
|
||||
}
|
||||
);
|
||||
|
||||
// 发送请求到LLM提供者
|
||||
const response = await sendRequestToProvider(
|
||||
requestBody,
|
||||
config,
|
||||
provider,
|
||||
fastify,
|
||||
bypass,
|
||||
transformer,
|
||||
{
|
||||
req,
|
||||
}
|
||||
);
|
||||
|
||||
// 处理响应转换器链
|
||||
const finalResponse = await processResponseTransformers(
|
||||
requestBody,
|
||||
response,
|
||||
provider,
|
||||
transformer,
|
||||
bypass,
|
||||
{
|
||||
req,
|
||||
}
|
||||
);
|
||||
|
||||
// 格式化并返回响应
|
||||
return formatResponse(finalResponse, reply, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理请求转换器链
|
||||
* 依次执行transformRequestOut、provider transformers、model-specific transformers
|
||||
* 返回处理后的请求体、配置和是否跳过转换器的标志
|
||||
*/
|
||||
async function processRequestTransformers(
|
||||
body: any,
|
||||
provider: any,
|
||||
transformer: any,
|
||||
headers: any,
|
||||
context: any
|
||||
) {
|
||||
let requestBody = body;
|
||||
let config: any = {};
|
||||
let bypass = false;
|
||||
|
||||
// 检查是否应该跳过转换器(透传参数)
|
||||
bypass = shouldBypassTransformers(provider, transformer, body);
|
||||
|
||||
if (bypass) {
|
||||
if (headers instanceof Headers) {
|
||||
headers.delete("content-length");
|
||||
} else {
|
||||
delete headers["content-length"];
|
||||
}
|
||||
config.headers = headers;
|
||||
}
|
||||
|
||||
// 执行transformer的transformRequestOut方法
|
||||
if (!bypass && typeof transformer.transformRequestOut === "function") {
|
||||
const transformOut = await transformer.transformRequestOut(requestBody);
|
||||
if (transformOut.body) {
|
||||
requestBody = transformOut.body;
|
||||
config = transformOut.config || {};
|
||||
} else {
|
||||
requestBody = transformOut;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行provider级别的转换器
|
||||
if (!bypass && provider.transformer?.use?.length) {
|
||||
for (const providerTransformer of provider.transformer.use) {
|
||||
if (
|
||||
!providerTransformer ||
|
||||
typeof providerTransformer.transformRequestIn !== "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const transformIn = await providerTransformer.transformRequestIn(
|
||||
requestBody,
|
||||
provider,
|
||||
context
|
||||
);
|
||||
if (transformIn.body) {
|
||||
requestBody = transformIn.body;
|
||||
config = { ...config, ...transformIn.config };
|
||||
} else {
|
||||
requestBody = transformIn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行模型特定的转换器
|
||||
if (!bypass && provider.transformer?.[body.model]?.use?.length) {
|
||||
for (const modelTransformer of provider.transformer[body.model].use) {
|
||||
if (
|
||||
!modelTransformer ||
|
||||
typeof modelTransformer.transformRequestIn !== "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
requestBody = await modelTransformer.transformRequestIn(
|
||||
requestBody,
|
||||
provider,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { requestBody, config, bypass };
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该跳过转换器(透传参数)
|
||||
* 当provider只使用一个transformer且该transformer与当前transformer相同时,跳过其他转换器
|
||||
*/
|
||||
function shouldBypassTransformers(
|
||||
provider: any,
|
||||
transformer: any,
|
||||
body: any
|
||||
): boolean {
|
||||
return (
|
||||
provider.transformer?.use?.length === 1 &&
|
||||
provider.transformer.use[0].name === transformer.name &&
|
||||
(!provider.transformer?.[body.model]?.use.length ||
|
||||
(provider.transformer?.[body.model]?.use.length === 1 &&
|
||||
provider.transformer?.[body.model]?.use[0].name === transformer.name))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求到LLM提供者
|
||||
* 处理认证、构建请求配置、发送请求并处理错误
|
||||
*/
|
||||
async function sendRequestToProvider(
|
||||
requestBody: any,
|
||||
config: any,
|
||||
provider: any,
|
||||
fastify: FastifyInstance,
|
||||
bypass: boolean,
|
||||
transformer: any,
|
||||
context: any
|
||||
) {
|
||||
const url = config.url || new URL(provider.baseUrl);
|
||||
|
||||
// 在透传参数下处理认证
|
||||
if (bypass && typeof transformer.auth === "function") {
|
||||
const auth = await transformer.auth(requestBody, provider);
|
||||
if (auth.body) {
|
||||
requestBody = auth.body;
|
||||
let headers = config.headers || {};
|
||||
if (auth.config?.headers) {
|
||||
headers = {
|
||||
...headers,
|
||||
...auth.config.headers,
|
||||
};
|
||||
delete headers.host;
|
||||
delete auth.config.headers;
|
||||
}
|
||||
config = {
|
||||
...config,
|
||||
...auth.config,
|
||||
headers,
|
||||
};
|
||||
} else {
|
||||
requestBody = auth;
|
||||
}
|
||||
}
|
||||
|
||||
// 发送HTTP请求
|
||||
// 准备headers
|
||||
const requestHeaders: Record<string, string> = {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
...(config?.headers || {}),
|
||||
};
|
||||
|
||||
for (const key in requestHeaders) {
|
||||
if (requestHeaders[key] === "undefined") {
|
||||
delete requestHeaders[key];
|
||||
} else if (
|
||||
["authorization", "Authorization"].includes(key) &&
|
||||
requestHeaders[key]?.includes("undefined")
|
||||
) {
|
||||
delete requestHeaders[key];
|
||||
}
|
||||
}
|
||||
|
||||
const response = await sendUnifiedRequest(
|
||||
url,
|
||||
requestBody,
|
||||
{
|
||||
httpsProxy: fastify._server!.configService.getHttpsProxy(),
|
||||
...config,
|
||||
headers: JSON.parse(JSON.stringify(requestHeaders)),
|
||||
},
|
||||
context,
|
||||
fastify.log
|
||||
);
|
||||
|
||||
// 处理请求错误
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
fastify.log.error(
|
||||
`[provider_response_error] Error from provider(${provider.name},${requestBody.model}: ${response.status}): ${errorText}`,
|
||||
);
|
||||
throw createApiError(
|
||||
`Error from provider(${provider.name},${requestBody.model}: ${response.status}): ${errorText}`,
|
||||
response.status,
|
||||
"provider_response_error"
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应转换器链
|
||||
* 依次执行provider transformers、model-specific transformers、transformer的transformResponseIn
|
||||
*/
|
||||
async function processResponseTransformers(
|
||||
requestBody: any,
|
||||
response: any,
|
||||
provider: any,
|
||||
transformer: any,
|
||||
bypass: boolean,
|
||||
context: any
|
||||
) {
|
||||
let finalResponse = response;
|
||||
|
||||
// 执行provider级别的响应转换器
|
||||
if (!bypass && provider.transformer?.use?.length) {
|
||||
for (const providerTransformer of Array.from(
|
||||
provider.transformer.use
|
||||
).reverse()) {
|
||||
if (
|
||||
!providerTransformer ||
|
||||
typeof providerTransformer.transformResponseOut !== "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
finalResponse = await providerTransformer.transformResponseOut(
|
||||
finalResponse,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行模型特定的响应转换器
|
||||
if (!bypass && provider.transformer?.[requestBody.model]?.use?.length) {
|
||||
for (const modelTransformer of Array.from(
|
||||
provider.transformer[requestBody.model].use
|
||||
).reverse()) {
|
||||
if (
|
||||
!modelTransformer ||
|
||||
typeof modelTransformer.transformResponseOut !== "function"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
finalResponse = await modelTransformer.transformResponseOut(
|
||||
finalResponse,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行transformer的transformResponseIn方法
|
||||
if (!bypass && transformer.transformResponseIn) {
|
||||
finalResponse = await transformer.transformResponseIn(
|
||||
finalResponse,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化并返回响应
|
||||
* 处理HTTP状态码、流式响应和普通响应的格式化
|
||||
*/
|
||||
function formatResponse(response: any, reply: FastifyReply, body: any) {
|
||||
// 设置HTTP状态码
|
||||
if (!response.ok) {
|
||||
reply.code(response.status);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
const isStream = body.stream === true;
|
||||
if (isStream) {
|
||||
reply.header("Content-Type", "text/event-stream");
|
||||
reply.header("Cache-Control", "no-cache");
|
||||
reply.header("Connection", "keep-alive");
|
||||
return reply.send(response.body);
|
||||
} else {
|
||||
// 处理普通JSON响应
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const registerApiRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance
|
||||
) => {
|
||||
// Health and info endpoints
|
||||
fastify.get("/", async () => {
|
||||
return { message: "LLMs API", version };
|
||||
});
|
||||
|
||||
fastify.get("/health", async () => {
|
||||
return { status: "ok", timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
const transformersWithEndpoint =
|
||||
fastify._server!.transformerService.getTransformersWithEndpoint();
|
||||
|
||||
for (const { transformer } of transformersWithEndpoint) {
|
||||
if (transformer.endPoint) {
|
||||
fastify.post(
|
||||
transformer.endPoint,
|
||||
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
return handleTransformerEndpoint(req, reply, fastify, transformer);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fastify.post(
|
||||
"/providers",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
type: { type: "string", enum: ["openai", "anthropic"] },
|
||||
baseUrl: { type: "string" },
|
||||
apiKey: { type: "string" },
|
||||
models: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["id", "name", "type", "baseUrl", "apiKey", "models"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{ Body: RegisterProviderRequest }>,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
// Validation
|
||||
const { name, baseUrl, apiKey, models } = request.body;
|
||||
|
||||
if (!name?.trim()) {
|
||||
throw createApiError(
|
||||
"Provider name is required",
|
||||
400,
|
||||
"invalid_request"
|
||||
);
|
||||
}
|
||||
|
||||
if (!baseUrl || !isValidUrl(baseUrl)) {
|
||||
throw createApiError(
|
||||
"Valid base URL is required",
|
||||
400,
|
||||
"invalid_request"
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey?.trim()) {
|
||||
throw createApiError("API key is required", 400, "invalid_request");
|
||||
}
|
||||
|
||||
if (!models || !Array.isArray(models) || models.length === 0) {
|
||||
throw createApiError(
|
||||
"At least one model is required",
|
||||
400,
|
||||
"invalid_request"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if provider already exists
|
||||
if (fastify._server!.providerService.getProvider(request.body.name)) {
|
||||
throw createApiError(
|
||||
`Provider with name '${request.body.name}' already exists`,
|
||||
400,
|
||||
"provider_exists"
|
||||
);
|
||||
}
|
||||
|
||||
return fastify._server!.providerService.registerProvider(request.body);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get("/providers", async () => {
|
||||
return fastify._server!.providerService.getProviders();
|
||||
});
|
||||
|
||||
fastify.get(
|
||||
"/providers/:id",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>) => {
|
||||
const provider = fastify._server!.providerService.getProvider(
|
||||
request.params.id
|
||||
);
|
||||
if (!provider) {
|
||||
throw createApiError("Provider not found", 404, "provider_not_found");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
"/providers/:id",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
type: { type: "string", enum: ["openai", "anthropic"] },
|
||||
baseUrl: { type: "string" },
|
||||
apiKey: { type: "string" },
|
||||
models: { type: "array", items: { type: "string" } },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: Partial<LLMProvider>;
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
const provider = fastify._server!.providerService.updateProvider(
|
||||
request.params.id,
|
||||
request.body
|
||||
);
|
||||
if (!provider) {
|
||||
throw createApiError("Provider not found", 404, "provider_not_found");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/providers/:id",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>) => {
|
||||
const success = fastify._server!.providerService.deleteProvider(
|
||||
request.params.id
|
||||
);
|
||||
if (!success) {
|
||||
throw createApiError("Provider not found", 404, "provider_not_found");
|
||||
}
|
||||
return { message: "Provider deleted successfully" };
|
||||
}
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/providers/:id/toggle",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
required: ["enabled"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: { enabled: boolean };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
const success = fastify._server!.providerService.toggleProvider(
|
||||
request.params.id,
|
||||
request.body.enabled
|
||||
);
|
||||
if (!success) {
|
||||
throw createApiError("Provider not found", 404, "provider_not_found");
|
||||
}
|
||||
return {
|
||||
message: `Provider ${
|
||||
request.body.enabled ? "enabled" : "disabled"
|
||||
} successfully`,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
207
packages/core/src/server.ts
Normal file
207
packages/core/src/server.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import Fastify, {
|
||||
FastifyInstance,
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
FastifyPluginAsync,
|
||||
FastifyPluginCallback,
|
||||
FastifyPluginOptions,
|
||||
FastifyRegisterOptions,
|
||||
preHandlerHookHandler,
|
||||
onRequestHookHandler,
|
||||
preParsingHookHandler,
|
||||
preValidationHookHandler,
|
||||
preSerializationHookHandler,
|
||||
onSendHookHandler,
|
||||
onResponseHookHandler,
|
||||
onTimeoutHookHandler,
|
||||
onErrorHookHandler,
|
||||
onRouteHookHandler,
|
||||
onRegisterHookHandler,
|
||||
onReadyHookHandler,
|
||||
onListenHookHandler,
|
||||
onCloseHookHandler,
|
||||
FastifyBaseLogger,
|
||||
FastifyLoggerOptions,
|
||||
FastifyServerOptions,
|
||||
} from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import { ConfigService, AppConfig } from "./services/config";
|
||||
import { errorHandler } from "./api/middleware";
|
||||
import { registerApiRoutes } from "./api/routes";
|
||||
import { ProviderService } from "./services/provider";
|
||||
import { TransformerService } from "./services/transformer";
|
||||
|
||||
// Extend FastifyRequest to include custom properties
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
provider?: string;
|
||||
}
|
||||
interface FastifyInstance {
|
||||
_server?: Server;
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerOptions extends FastifyServerOptions {
|
||||
initialConfig?: AppConfig;
|
||||
}
|
||||
|
||||
// Application factory
|
||||
function createApp(options: FastifyServerOptions = {}): FastifyInstance {
|
||||
const fastify = Fastify({
|
||||
bodyLimit: 50 * 1024 * 1024,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Register error handler
|
||||
fastify.setErrorHandler(errorHandler);
|
||||
|
||||
// Register CORS
|
||||
fastify.register(cors);
|
||||
return fastify;
|
||||
}
|
||||
|
||||
// Server class
|
||||
class Server {
|
||||
private app: FastifyInstance;
|
||||
configService: ConfigService;
|
||||
providerService!: ProviderService;
|
||||
transformerService: TransformerService;
|
||||
|
||||
constructor(options: ServerOptions = {}) {
|
||||
const { initialConfig, ...fastifyOptions } = options;
|
||||
this.app = createApp({
|
||||
...fastifyOptions,
|
||||
logger: fastifyOptions.logger ?? true,
|
||||
});
|
||||
this.configService = new ConfigService(options);
|
||||
this.transformerService = new TransformerService(
|
||||
this.configService,
|
||||
this.app.log
|
||||
);
|
||||
this.transformerService.initialize().finally(() => {
|
||||
this.providerService = new ProviderService(
|
||||
this.configService,
|
||||
this.transformerService,
|
||||
this.app.log
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async register<Options extends FastifyPluginOptions = FastifyPluginOptions>(
|
||||
plugin: FastifyPluginAsync<Options> | FastifyPluginCallback<Options>,
|
||||
options?: FastifyRegisterOptions<Options>
|
||||
): Promise<void> {
|
||||
await (this.app as any).register(plugin, options);
|
||||
}
|
||||
|
||||
addHook(hookName: "onRequest", hookFunction: onRequestHookHandler): void;
|
||||
addHook(hookName: "preParsing", hookFunction: preParsingHookHandler): void;
|
||||
addHook(
|
||||
hookName: "preValidation",
|
||||
hookFunction: preValidationHookHandler
|
||||
): void;
|
||||
addHook(hookName: "preHandler", hookFunction: preHandlerHookHandler): void;
|
||||
addHook(
|
||||
hookName: "preSerialization",
|
||||
hookFunction: preSerializationHookHandler
|
||||
): void;
|
||||
addHook(hookName: "onSend", hookFunction: onSendHookHandler): void;
|
||||
addHook(hookName: "onResponse", hookFunction: onResponseHookHandler): void;
|
||||
addHook(hookName: "onTimeout", hookFunction: onTimeoutHookHandler): void;
|
||||
addHook(hookName: "onError", hookFunction: onErrorHookHandler): void;
|
||||
addHook(hookName: "onRoute", hookFunction: onRouteHookHandler): void;
|
||||
addHook(hookName: "onRegister", hookFunction: onRegisterHookHandler): void;
|
||||
addHook(hookName: "onReady", hookFunction: onReadyHookHandler): void;
|
||||
addHook(hookName: "onListen", hookFunction: onListenHookHandler): void;
|
||||
addHook(hookName: "onClose", hookFunction: onCloseHookHandler): void;
|
||||
public addHook(hookName: string, hookFunction: any): void {
|
||||
this.app.addHook(hookName as any, hookFunction);
|
||||
}
|
||||
|
||||
public async registerNamespace(name: string, options: any) {
|
||||
if (!name) throw new Error("name is required");
|
||||
const configService = new ConfigService(options);
|
||||
const transformerService = new TransformerService(
|
||||
configService,
|
||||
this.app.log
|
||||
);
|
||||
await transformerService.initialize();
|
||||
const providerService = new ProviderService(
|
||||
configService,
|
||||
transformerService,
|
||||
this.app.log
|
||||
);
|
||||
this.app.register((fastify) => {
|
||||
fastify.decorate('configService', configService);
|
||||
fastify.decorate('transformerService', transformerService);
|
||||
fastify.decorate('providerService', providerService);
|
||||
}, { prefix: name });
|
||||
this.app.register(registerApiRoutes, { prefix: name });
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
this.app._server = this;
|
||||
|
||||
this.app.addHook("preHandler", (req, reply, done) => {
|
||||
const url = new URL(`http://127.0.0.1${req.url}`);
|
||||
if (url.pathname.endsWith("/v1/messages") && req.body) {
|
||||
const body = req.body as any;
|
||||
req.log.info({ data: body, type: "request body" });
|
||||
if (!body.stream) {
|
||||
body.stream = false;
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
this.app.addHook(
|
||||
"preHandler",
|
||||
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = new URL(`http://127.0.0.1${req.url}`);
|
||||
if (url.pathname.endsWith("/v1/messages") && req.body) {
|
||||
try {
|
||||
const body = req.body as any;
|
||||
if (!body || !body.model) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "Missing model in request body" });
|
||||
}
|
||||
const [provider, ...model] = body.model.split(",");
|
||||
body.model = model.join(",");
|
||||
req.provider = provider;
|
||||
return;
|
||||
} catch (err) {
|
||||
req.log.error({error: err}, "Error in modelProviderMiddleware:");
|
||||
return reply.code(500).send({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.app.register(registerApiRoutes);
|
||||
|
||||
const address = await this.app.listen({
|
||||
port: parseInt(this.configService.get("PORT") || "3000", 10),
|
||||
host: this.configService.get("HOST") || "127.0.0.1",
|
||||
});
|
||||
|
||||
this.app.log.info(`🚀 LLMs API server listening on ${address}`);
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
this.app.log.info(`Received ${signal}, shutting down gracefully...`);
|
||||
await this.app.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
} catch (error) {
|
||||
this.app.log.error(`Error starting server: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for external use
|
||||
export default Server;
|
||||
179
packages/core/src/services/config.ts
Normal file
179
packages/core/src/services/config.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { config } from "dotenv";
|
||||
import JSON5 from 'json5';
|
||||
|
||||
export interface ConfigOptions {
|
||||
envPath?: string;
|
||||
jsonPath?: string;
|
||||
useEnvFile?: boolean;
|
||||
useJsonFile?: boolean;
|
||||
useEnvironmentVariables?: boolean;
|
||||
initialConfig?: AppConfig;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private config: AppConfig = {};
|
||||
private options: ConfigOptions;
|
||||
|
||||
constructor(
|
||||
options: ConfigOptions = {
|
||||
jsonPath: "./config.json",
|
||||
}
|
||||
) {
|
||||
this.options = {
|
||||
envPath: options.envPath || ".env",
|
||||
jsonPath: options.jsonPath,
|
||||
useEnvFile: false,
|
||||
useJsonFile: options.useJsonFile !== false,
|
||||
useEnvironmentVariables: options.useEnvironmentVariables !== false,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
private loadConfig(): void {
|
||||
if (this.options.useJsonFile && this.options.jsonPath) {
|
||||
this.loadJsonConfig();
|
||||
}
|
||||
|
||||
if (this.options.initialConfig) {
|
||||
this.config = { ...this.config, ...this.options.initialConfig };
|
||||
}
|
||||
|
||||
if (this.options.useEnvFile) {
|
||||
this.loadEnvConfig();
|
||||
}
|
||||
|
||||
// if (this.options.useEnvironmentVariables) {
|
||||
// this.loadEnvironmentVariables();
|
||||
// }
|
||||
|
||||
if (this.config.LOG_FILE) {
|
||||
process.env.LOG_FILE = this.config.LOG_FILE;
|
||||
}
|
||||
if (this.config.LOG) {
|
||||
process.env.LOG = this.config.LOG;
|
||||
}
|
||||
}
|
||||
|
||||
private loadJsonConfig(): void {
|
||||
if (!this.options.jsonPath) return;
|
||||
|
||||
const jsonPath = this.isAbsolutePath(this.options.jsonPath)
|
||||
? this.options.jsonPath
|
||||
: join(process.cwd(), this.options.jsonPath);
|
||||
|
||||
if (existsSync(jsonPath)) {
|
||||
try {
|
||||
const jsonContent = readFileSync(jsonPath, "utf-8");
|
||||
const jsonConfig = JSON5.parse(jsonContent);
|
||||
this.config = { ...this.config, ...jsonConfig };
|
||||
console.log(`Loaded JSON config from: ${jsonPath}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load JSON config from ${jsonPath}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.warn(`JSON config file not found: ${jsonPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private loadEnvConfig(): void {
|
||||
const envPath = this.isAbsolutePath(this.options.envPath!)
|
||||
? this.options.envPath!
|
||||
: join(process.cwd(), this.options.envPath!);
|
||||
|
||||
if (existsSync(envPath)) {
|
||||
try {
|
||||
const result = config({ path: envPath });
|
||||
if (result.parsed) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...this.parseEnvConfig(result.parsed),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load .env config from ${envPath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadEnvironmentVariables(): void {
|
||||
const envConfig = this.parseEnvConfig(process.env);
|
||||
this.config = { ...this.config, ...envConfig };
|
||||
}
|
||||
|
||||
private parseEnvConfig(
|
||||
env: Record<string, string | undefined>
|
||||
): Partial<AppConfig> {
|
||||
const parsed: Partial<AppConfig> = {};
|
||||
|
||||
Object.assign(parsed, env);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private isAbsolutePath(path: string): boolean {
|
||||
return path.startsWith("/") || path.includes(":");
|
||||
}
|
||||
|
||||
public get<T = any>(key: keyof AppConfig): T | undefined;
|
||||
public get<T = any>(key: keyof AppConfig, defaultValue: T): T;
|
||||
public get<T = any>(key: keyof AppConfig, defaultValue?: T): T | undefined {
|
||||
const value = this.config[key];
|
||||
return value !== undefined ? (value as T) : defaultValue;
|
||||
}
|
||||
|
||||
public getAll(): AppConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
public getHttpsProxy(): string | undefined {
|
||||
return (
|
||||
this.get("HTTPS_PROXY") ||
|
||||
this.get("https_proxy") ||
|
||||
this.get("httpsProxy") ||
|
||||
this.get("PROXY_URL")
|
||||
);
|
||||
}
|
||||
|
||||
public has(key: keyof AppConfig): boolean {
|
||||
return this.config[key] !== undefined;
|
||||
}
|
||||
|
||||
public set(key: keyof AppConfig, value: any): void {
|
||||
this.config[key] = value;
|
||||
}
|
||||
|
||||
public reload(): void {
|
||||
this.config = {};
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
public getConfigSummary(): string {
|
||||
const summary: string[] = [];
|
||||
|
||||
if (this.options.initialConfig) {
|
||||
summary.push("Initial Config");
|
||||
}
|
||||
|
||||
if (this.options.useJsonFile && this.options.jsonPath) {
|
||||
summary.push(`JSON: ${this.options.jsonPath}`);
|
||||
}
|
||||
|
||||
if (this.options.useEnvFile) {
|
||||
summary.push(`ENV: ${this.options.envPath}`);
|
||||
}
|
||||
|
||||
if (this.options.useEnvironmentVariables) {
|
||||
summary.push("Environment Variables");
|
||||
}
|
||||
|
||||
return `Config sources: ${summary.join(", ")}`;
|
||||
}
|
||||
}
|
||||
287
packages/core/src/services/provider.ts
Normal file
287
packages/core/src/services/provider.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { TransformerConstructor } from "@/types/transformer";
|
||||
import {
|
||||
LLMProvider,
|
||||
RegisterProviderRequest,
|
||||
ModelRoute,
|
||||
RequestRouteInfo,
|
||||
ConfigProvider,
|
||||
} from "../types/llm";
|
||||
import { ConfigService } from "./config";
|
||||
import { TransformerService } from "./transformer";
|
||||
|
||||
export class ProviderService {
|
||||
private providers: Map<string, LLMProvider> = new Map();
|
||||
private modelRoutes: Map<string, ModelRoute> = new Map();
|
||||
|
||||
constructor(private readonly configService: ConfigService, private readonly transformerService: TransformerService, private readonly logger: any) {
|
||||
this.initializeCustomProviders();
|
||||
}
|
||||
|
||||
private initializeCustomProviders() {
|
||||
const providersConfig =
|
||||
this.configService.get<ConfigProvider[]>("providers");
|
||||
if (providersConfig && Array.isArray(providersConfig)) {
|
||||
this.initializeFromProvidersArray(providersConfig);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private initializeFromProvidersArray(providersConfig: ConfigProvider[]) {
|
||||
providersConfig.forEach((providerConfig: ConfigProvider) => {
|
||||
try {
|
||||
if (
|
||||
!providerConfig.name ||
|
||||
!providerConfig.api_base_url ||
|
||||
!providerConfig.api_key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transformer: LLMProvider["transformer"] = {}
|
||||
|
||||
if (providerConfig.transformer) {
|
||||
Object.keys(providerConfig.transformer).forEach(key => {
|
||||
if (key === 'use') {
|
||||
if (Array.isArray(providerConfig.transformer.use)) {
|
||||
transformer.use = providerConfig.transformer.use.map((transformer) => {
|
||||
if (Array.isArray(transformer) && typeof transformer[0] === 'string') {
|
||||
const Constructor = this.transformerService.getTransformer(transformer[0]);
|
||||
if (Constructor) {
|
||||
return new (Constructor as TransformerConstructor)(transformer[1]);
|
||||
}
|
||||
}
|
||||
if (typeof transformer === 'string') {
|
||||
const transformerInstance = this.transformerService.getTransformer(transformer);
|
||||
if (typeof transformerInstance === 'function') {
|
||||
return new transformerInstance();
|
||||
}
|
||||
return transformerInstance;
|
||||
}
|
||||
}).filter((transformer) => typeof transformer !== 'undefined');
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(providerConfig.transformer[key]?.use)) {
|
||||
transformer[key] = {
|
||||
use: providerConfig.transformer[key].use.map((transformer) => {
|
||||
if (Array.isArray(transformer) && typeof transformer[0] === 'string') {
|
||||
const Constructor = this.transformerService.getTransformer(transformer[0]);
|
||||
if (Constructor) {
|
||||
return new (Constructor as TransformerConstructor)(transformer[1]);
|
||||
}
|
||||
}
|
||||
if (typeof transformer === 'string') {
|
||||
const transformerInstance = this.transformerService.getTransformer(transformer);
|
||||
if (typeof transformerInstance === 'function') {
|
||||
return new transformerInstance();
|
||||
}
|
||||
return transformerInstance;
|
||||
}
|
||||
}).filter((transformer) => typeof transformer !== 'undefined')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.registerProvider({
|
||||
name: providerConfig.name,
|
||||
baseUrl: providerConfig.api_base_url,
|
||||
apiKey: providerConfig.api_key,
|
||||
models: providerConfig.models || [],
|
||||
transformer: providerConfig.transformer ? transformer : undefined,
|
||||
});
|
||||
|
||||
this.logger.info(`${providerConfig.name} provider registered`);
|
||||
} catch (error) {
|
||||
this.logger.error(`${providerConfig.name} provider registered error: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerProvider(request: RegisterProviderRequest): LLMProvider {
|
||||
const provider: LLMProvider = {
|
||||
...request,
|
||||
};
|
||||
|
||||
this.providers.set(provider.name, provider);
|
||||
|
||||
request.models.forEach((model) => {
|
||||
const fullModel = `${provider.name},${model}`;
|
||||
const route: ModelRoute = {
|
||||
provider: provider.name,
|
||||
model,
|
||||
fullModel,
|
||||
};
|
||||
this.modelRoutes.set(fullModel, route);
|
||||
if (!this.modelRoutes.has(model)) {
|
||||
this.modelRoutes.set(model, route);
|
||||
}
|
||||
});
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
getProviders(): LLMProvider[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
|
||||
getProvider(name: string): LLMProvider | undefined {
|
||||
return this.providers.get(name);
|
||||
}
|
||||
|
||||
updateProvider(
|
||||
id: string,
|
||||
updates: Partial<LLMProvider>
|
||||
): LLMProvider | null {
|
||||
const provider = this.providers.get(id);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedProvider = {
|
||||
...provider,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.providers.set(id, updatedProvider);
|
||||
|
||||
if (updates.models) {
|
||||
provider.models.forEach((model) => {
|
||||
const fullModel = `${provider.name},${model}`;
|
||||
this.modelRoutes.delete(fullModel);
|
||||
this.modelRoutes.delete(model);
|
||||
});
|
||||
|
||||
updates.models.forEach((model) => {
|
||||
const fullModel = `${provider.name},${model}`;
|
||||
const route: ModelRoute = {
|
||||
provider: provider.name,
|
||||
model,
|
||||
fullModel,
|
||||
};
|
||||
this.modelRoutes.set(fullModel, route);
|
||||
if (!this.modelRoutes.has(model)) {
|
||||
this.modelRoutes.set(model, route);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return updatedProvider;
|
||||
}
|
||||
|
||||
deleteProvider(id: string): boolean {
|
||||
const provider = this.providers.get(id);
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
provider.models.forEach((model) => {
|
||||
const fullModel = `${provider.name},${model}`;
|
||||
this.modelRoutes.delete(fullModel);
|
||||
this.modelRoutes.delete(model);
|
||||
});
|
||||
|
||||
this.providers.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
toggleProvider(name: string, enabled: boolean): boolean {
|
||||
const provider = this.providers.get(name);
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
resolveModelRoute(modelName: string): RequestRouteInfo | null {
|
||||
const route = this.modelRoutes.get(modelName);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = this.providers.get(route.provider);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
originalModel: modelName,
|
||||
targetModel: route.model,
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableModelNames(): string[] {
|
||||
const modelNames: string[] = [];
|
||||
this.providers.forEach((provider) => {
|
||||
provider.models.forEach((model) => {
|
||||
modelNames.push(model);
|
||||
modelNames.push(`${provider.name},${model}`);
|
||||
});
|
||||
});
|
||||
return modelNames;
|
||||
}
|
||||
|
||||
getModelRoutes(): ModelRoute[] {
|
||||
return Array.from(this.modelRoutes.values());
|
||||
}
|
||||
|
||||
private parseTransformerConfig(transformerConfig: any): any {
|
||||
if (!transformerConfig) return {};
|
||||
|
||||
if (Array.isArray(transformerConfig)) {
|
||||
return transformerConfig.reduce((acc, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const [name, config = {}] = item;
|
||||
acc[name] = config;
|
||||
} else {
|
||||
acc[item] = {};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return transformerConfig;
|
||||
}
|
||||
|
||||
async getAvailableModels(): Promise<{
|
||||
object: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
owned_by: string;
|
||||
provider: string;
|
||||
}>;
|
||||
}> {
|
||||
const models: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
owned_by: string;
|
||||
provider: string;
|
||||
}> = [];
|
||||
|
||||
this.providers.forEach((provider) => {
|
||||
provider.models.forEach((model) => {
|
||||
models.push({
|
||||
id: model,
|
||||
object: "model",
|
||||
owned_by: provider.name,
|
||||
provider: provider.name,
|
||||
});
|
||||
|
||||
models.push({
|
||||
id: `${provider.name},${model}`,
|
||||
object: "model",
|
||||
owned_by: provider.name,
|
||||
provider: provider.name,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
object: "list",
|
||||
data: models,
|
||||
};
|
||||
}
|
||||
}
|
||||
165
packages/core/src/services/transformer.ts
Normal file
165
packages/core/src/services/transformer.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Transformer, TransformerConstructor } from "@/types/transformer";
|
||||
import { ConfigService } from "./config";
|
||||
import Transformers from "@/transformer";
|
||||
import Module from "node:module";
|
||||
|
||||
interface TransformerConfig {
|
||||
transformers: Array<{
|
||||
name: string;
|
||||
type: "class" | "module";
|
||||
path?: string;
|
||||
options?: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class TransformerService {
|
||||
private transformers: Map<string, Transformer | TransformerConstructor> =
|
||||
new Map();
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly logger: any
|
||||
) {}
|
||||
|
||||
registerTransformer(name: string, transformer: Transformer): void {
|
||||
this.transformers.set(name, transformer);
|
||||
this.logger.info(
|
||||
`register transformer: ${name}${
|
||||
transformer.endPoint
|
||||
? ` (endpoint: ${transformer.endPoint})`
|
||||
: " (no endpoint)"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
getTransformer(
|
||||
name: string
|
||||
): Transformer | TransformerConstructor | undefined {
|
||||
return this.transformers.get(name);
|
||||
}
|
||||
|
||||
getAllTransformers(): Map<string, Transformer | TransformerConstructor> {
|
||||
return new Map(this.transformers);
|
||||
}
|
||||
|
||||
getTransformersWithEndpoint(): { name: string; transformer: Transformer }[] {
|
||||
const result: { name: string; transformer: Transformer }[] = [];
|
||||
|
||||
this.transformers.forEach((transformer, name) => {
|
||||
// Check if it's an instance with endPoint
|
||||
if (typeof transformer === 'object' && transformer.endPoint) {
|
||||
result.push({ name, transformer });
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getTransformersWithoutEndpoint(): {
|
||||
name: string;
|
||||
transformer: Transformer;
|
||||
}[] {
|
||||
const result: { name: string; transformer: Transformer }[] = [];
|
||||
|
||||
this.transformers.forEach((transformer, name) => {
|
||||
// Check if it's an instance without endPoint
|
||||
if (typeof transformer === 'object' && !transformer.endPoint) {
|
||||
result.push({ name, transformer });
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
removeTransformer(name: string): boolean {
|
||||
return this.transformers.delete(name);
|
||||
}
|
||||
|
||||
hasTransformer(name: string): boolean {
|
||||
return this.transformers.has(name);
|
||||
}
|
||||
|
||||
async registerTransformerFromConfig(config: {
|
||||
path?: string;
|
||||
options?: any;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
if (config.path) {
|
||||
const module = require(require.resolve(config.path));
|
||||
if (module) {
|
||||
const instance = new module(config.options);
|
||||
// Set logger for transformer instance
|
||||
if (instance && typeof instance === "object") {
|
||||
(instance as any).logger = this.logger;
|
||||
}
|
||||
if (!instance.name) {
|
||||
throw new Error(
|
||||
`Transformer instance from ${config.path} does not have a name property.`
|
||||
);
|
||||
}
|
||||
this.registerTransformer(instance.name, instance);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`load transformer (${config.path}) \nerror: ${error.message}\nstack: ${error.stack}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.registerDefaultTransformersInternal();
|
||||
await this.loadFromConfig();
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`TransformerService init error: ${error.message}\nStack: ${error.stack}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async registerDefaultTransformersInternal(): Promise<void> {
|
||||
try {
|
||||
Object.values(Transformers).forEach(
|
||||
(TransformerStatic: any) => {
|
||||
if (
|
||||
"TransformerName" in TransformerStatic &&
|
||||
typeof TransformerStatic.TransformerName === "string"
|
||||
) {
|
||||
this.registerTransformer(
|
||||
TransformerStatic.TransformerName,
|
||||
TransformerStatic
|
||||
);
|
||||
} else {
|
||||
const transformerInstance = new TransformerStatic();
|
||||
// Set logger for transformer instance
|
||||
if (
|
||||
transformerInstance &&
|
||||
typeof transformerInstance === "object"
|
||||
) {
|
||||
(transformerInstance as any).logger = this.logger;
|
||||
}
|
||||
this.registerTransformer(
|
||||
transformerInstance.name!,
|
||||
transformerInstance
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, "transformer regist error:");
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFromConfig(): Promise<void> {
|
||||
const transformers = this.configService.get<
|
||||
TransformerConfig["transformers"]
|
||||
>("transformers", []);
|
||||
for (const transformer of transformers) {
|
||||
await this.registerTransformerFromConfig(transformer);
|
||||
}
|
||||
}
|
||||
}
|
||||
1069
packages/core/src/transformer/anthropic.transformer.ts
Normal file
1069
packages/core/src/transformer/anthropic.transformer.ts
Normal file
File diff suppressed because it is too large
Load Diff
45
packages/core/src/transformer/cerebras.transformer.ts
Normal file
45
packages/core/src/transformer/cerebras.transformer.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { LLMProvider, UnifiedChatRequest, UnifiedMessage } from "@/types/llm";
|
||||
import { Transformer } from "@/types/transformer";
|
||||
|
||||
|
||||
/**
|
||||
* Transformer class for Cerebras
|
||||
*/
|
||||
export class CerebrasTransformer implements Transformer {
|
||||
name = "cerebras";
|
||||
|
||||
/**
|
||||
* Transform the request from Claude Code format to Cerebras format
|
||||
* @param request - The incoming request
|
||||
* @param provider - The LLM provider information
|
||||
* @returns The transformed request
|
||||
*/
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest,
|
||||
provider: LLMProvider
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Deep clone the request to avoid modifying the original
|
||||
const transformedRequest = JSON.parse(JSON.stringify(request));
|
||||
|
||||
if (transformedRequest.reasoning) {
|
||||
delete transformedRequest.reasoning;
|
||||
} else {
|
||||
transformedRequest.disable_reasoning = false
|
||||
}
|
||||
|
||||
return {
|
||||
body: transformedRequest,
|
||||
config: {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${provider.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
23
packages/core/src/transformer/cleancache.transformer.ts
Normal file
23
packages/core/src/transformer/cleancache.transformer.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { MessageContent, TextContent, UnifiedChatRequest } from "@/types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
|
||||
export class CleancacheTransformer implements Transformer {
|
||||
name = "cleancache";
|
||||
|
||||
async transformRequestIn(request: UnifiedChatRequest): Promise<UnifiedChatRequest> {
|
||||
if (Array.isArray(request.messages)) {
|
||||
request.messages.forEach((msg) => {
|
||||
if (Array.isArray(msg.content)) {
|
||||
(msg.content as MessageContent[]).forEach((item) => {
|
||||
if ((item as TextContent).cache_control) {
|
||||
delete (item as TextContent).cache_control;
|
||||
}
|
||||
});
|
||||
} else if (msg.cache_control) {
|
||||
delete msg.cache_control;
|
||||
}
|
||||
});
|
||||
}
|
||||
return request;
|
||||
}
|
||||
}
|
||||
108
packages/core/src/transformer/customparams.transformer.ts
Normal file
108
packages/core/src/transformer/customparams.transformer.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer, TransformerOptions } from "../types/transformer";
|
||||
|
||||
export interface CustomParamsOptions extends TransformerOptions {
|
||||
/**
|
||||
* Custom parameters to inject into the request body
|
||||
* Any key-value pairs will be added to the request
|
||||
* Supports: string, number, boolean, object, array
|
||||
*/
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformer for injecting dynamic custom parameters into LLM requests
|
||||
* Allows runtime configuration of arbitrary parameters that get merged
|
||||
* into the request body using deep merge strategy
|
||||
*/
|
||||
export class CustomParamsTransformer implements Transformer {
|
||||
static TransformerName = "customparams";
|
||||
|
||||
private options: CustomParamsOptions;
|
||||
|
||||
constructor(options: CustomParamsOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
// Create a copy of the request to avoid mutating the original
|
||||
const modifiedRequest = { ...request } as any;
|
||||
|
||||
// Inject custom parameters with deep merge
|
||||
const parametersToInject = Object.entries(this.options);
|
||||
|
||||
for (const [key, value] of parametersToInject) {
|
||||
if (key in modifiedRequest) {
|
||||
// Deep merge with existing parameter
|
||||
if (typeof modifiedRequest[key] === 'object' &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(modifiedRequest[key]) &&
|
||||
!Array.isArray(value) &&
|
||||
modifiedRequest[key] !== null &&
|
||||
value !== null) {
|
||||
// Deep merge objects
|
||||
modifiedRequest[key] = this.deepMergeObjects(modifiedRequest[key], value);
|
||||
} else {
|
||||
// For non-objects, keep existing value (preserve original)
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Add new parameter
|
||||
modifiedRequest[key] = this.cloneValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedRequest;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
// Pass through response unchanged
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Deep merge two objects recursively
|
||||
*/
|
||||
private deepMergeObjects(target: any, source: any): any {
|
||||
const result = { ...target };
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key in result &&
|
||||
typeof result[key] === 'object' &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(result[key]) &&
|
||||
!Array.isArray(value) &&
|
||||
result[key] !== null &&
|
||||
value !== null) {
|
||||
result[key] = this.deepMergeObjects(result[key], value);
|
||||
} else {
|
||||
result[key] = this.cloneValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a value to prevent reference issues
|
||||
*/
|
||||
private cloneValue(value: any): any {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => this.cloneValue(item));
|
||||
}
|
||||
|
||||
const cloned: any = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
cloned[key] = this.cloneValue(val);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
221
packages/core/src/transformer/deepseek.transformer.ts
Normal file
221
packages/core/src/transformer/deepseek.transformer.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
|
||||
export class DeepseekTransformer implements Transformer {
|
||||
name = "deepseek";
|
||||
|
||||
async transformRequestIn(request: UnifiedChatRequest): Promise<UnifiedChatRequest> {
|
||||
if (request.max_tokens && request.max_tokens > 8192) {
|
||||
request.max_tokens = 8192; // DeepSeek has a max token limit of 8192
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
// Handle non-streaming response if needed
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let reasoningContent = "";
|
||||
let isReasoningComplete = false;
|
||||
let buffer = ""; // 用于缓冲不完整的数据
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
const processBuffer = (
|
||||
buffer: string,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) => {
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processLine = (
|
||||
line: string,
|
||||
context: {
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
reasoningContent: () => string;
|
||||
appendReasoningContent: (content: string) => void;
|
||||
isReasoningComplete: () => boolean;
|
||||
setReasoningComplete: (val: boolean) => void;
|
||||
}
|
||||
) => {
|
||||
const { controller, encoder } = context;
|
||||
|
||||
if (
|
||||
line.startsWith("data: ") &&
|
||||
line.trim() !== "data: [DONE]"
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
// Extract reasoning_content from delta
|
||||
if (data.choices?.[0]?.delta?.reasoning_content) {
|
||||
context.appendReasoningContent(
|
||||
data.choices[0].delta.reasoning_content
|
||||
);
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
thinking: {
|
||||
content: data.choices[0].delta.reasoning_content,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
delete thinkingChunk.choices[0].delta.reasoning_content;
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if reasoning is complete (when delta has content but no reasoning_content)
|
||||
if (
|
||||
data.choices?.[0]?.delta?.content &&
|
||||
context.reasoningContent() &&
|
||||
!context.isReasoningComplete()
|
||||
) {
|
||||
context.setReasoningComplete(true);
|
||||
const signature = Date.now().toString();
|
||||
|
||||
// Create a new chunk with thinking block
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
content: null,
|
||||
thinking: {
|
||||
content: context.reasoningContent(),
|
||||
signature: signature,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
delete thinkingChunk.choices[0].delta.reasoning_content;
|
||||
// Send the thinking chunk
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
}
|
||||
|
||||
if (data.choices[0]?.delta?.reasoning_content) {
|
||||
delete data.choices[0].delta.reasoning_content;
|
||||
}
|
||||
|
||||
// Send the modified chunk
|
||||
if (
|
||||
data.choices?.[0]?.delta &&
|
||||
Object.keys(data.choices[0].delta).length > 0
|
||||
) {
|
||||
if (context.isReasoningComplete()) {
|
||||
data.choices[0].index++;
|
||||
}
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
}
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, pass through the original line
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// Pass through non-data lines (like [DONE])
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
processBuffer(buffer, controller, encoder);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// 处理缓冲区中完整的数据行
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // 最后一行可能不完整,保留在缓冲区
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) => (isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("Content-Type") || "text/plain",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
334
packages/core/src/transformer/enhancetool.transformer.ts
Normal file
334
packages/core/src/transformer/enhancetool.transformer.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Transformer } from "@/types/transformer";
|
||||
import { parseToolArguments } from "@/utils/toolArgumentsParser";
|
||||
|
||||
export class EnhanceToolTransformer implements Transformer {
|
||||
name = "enhancetool";
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
if (jsonResponse?.choices?.[0]?.message?.tool_calls?.length) {
|
||||
// 处理非流式的工具调用参数解析
|
||||
for (const toolCall of jsonResponse.choices[0].message.tool_calls) {
|
||||
if (toolCall.function?.arguments) {
|
||||
toolCall.function.arguments = parseToolArguments(
|
||||
toolCall.function.arguments,
|
||||
this.logger
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Define interface for tool call tracking
|
||||
interface ToolCall {
|
||||
index?: number;
|
||||
name?: string;
|
||||
id?: string;
|
||||
arguments?: string;
|
||||
}
|
||||
|
||||
let currentToolCall: ToolCall = {};
|
||||
|
||||
let hasTextContent = false;
|
||||
let reasoningContent = "";
|
||||
let isReasoningComplete = false;
|
||||
let hasToolCall = false;
|
||||
let buffer = ""; // 用于缓冲不完整的数据
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
const processBuffer = (
|
||||
buffer: string,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) => {
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to process completed tool calls
|
||||
const processCompletedToolCall = (
|
||||
data: any,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) => {
|
||||
let finalArgs = "";
|
||||
try {
|
||||
finalArgs = parseToolArguments(currentToolCall.arguments || "", this.logger);
|
||||
} catch (e: any) {
|
||||
console.error(
|
||||
`${e.message} ${
|
||||
e.stack
|
||||
} 工具调用参数解析失败: ${JSON.stringify(
|
||||
currentToolCall
|
||||
)}`
|
||||
);
|
||||
// Use original arguments if parsing fails
|
||||
finalArgs = currentToolCall.arguments || "";
|
||||
}
|
||||
|
||||
const delta = {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: currentToolCall.name,
|
||||
arguments: finalArgs,
|
||||
},
|
||||
id: currentToolCall.id,
|
||||
index: currentToolCall.index,
|
||||
type: "function",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Remove content field entirely to prevent extra null values
|
||||
const modifiedData = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices[0],
|
||||
delta,
|
||||
},
|
||||
],
|
||||
};
|
||||
// Remove content field if it exists
|
||||
if (modifiedData.choices[0].delta.content !== undefined) {
|
||||
delete modifiedData.choices[0].delta.content;
|
||||
}
|
||||
|
||||
const modifiedLine = `data: ${JSON.stringify(modifiedData)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
};
|
||||
|
||||
const processLine = (
|
||||
line: string,
|
||||
context: {
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
hasTextContent: () => boolean;
|
||||
setHasTextContent: (val: boolean) => void;
|
||||
reasoningContent: () => string;
|
||||
appendReasoningContent: (content: string) => void;
|
||||
isReasoningComplete: () => boolean;
|
||||
setReasoningComplete: (val: boolean) => void;
|
||||
}
|
||||
) => {
|
||||
const { controller, encoder } = context;
|
||||
|
||||
if (line.startsWith("data: ") && line.trim() !== "data: [DONE]") {
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
// Handle tool calls in streaming mode
|
||||
if (data.choices?.[0]?.delta?.tool_calls?.length) {
|
||||
const toolCallDelta = data.choices[0].delta.tool_calls[0];
|
||||
|
||||
// Initialize currentToolCall if this is the first chunk for this tool call
|
||||
if (typeof currentToolCall.index === "undefined") {
|
||||
currentToolCall = {
|
||||
index: toolCallDelta.index,
|
||||
name: toolCallDelta.function?.name || "",
|
||||
id: toolCallDelta.id || "",
|
||||
arguments: toolCallDelta.function?.arguments || ""
|
||||
};
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
toolCallDelta.function.arguments = ''
|
||||
}
|
||||
// Send the first chunk as-is
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
return;
|
||||
}
|
||||
// Accumulate arguments if this is a continuation of the current tool call
|
||||
else if (currentToolCall.index === toolCallDelta.index) {
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
currentToolCall.arguments += toolCallDelta.function.arguments;
|
||||
}
|
||||
// Don't send intermediate chunks that only contain arguments
|
||||
return;
|
||||
}
|
||||
// If we have a different tool call index, process the previous one and start a new one
|
||||
else {
|
||||
// Process the completed tool call using helper function
|
||||
processCompletedToolCall(data, controller, encoder);
|
||||
|
||||
// Start tracking the new tool call
|
||||
currentToolCall = {
|
||||
index: toolCallDelta.index,
|
||||
name: toolCallDelta.function?.name || "",
|
||||
id: toolCallDelta.id || "",
|
||||
arguments: toolCallDelta.function?.arguments || ""
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish_reason for tool_calls
|
||||
if (data.choices?.[0]?.finish_reason === "tool_calls" && currentToolCall.index !== undefined) {
|
||||
// Process the final tool call using helper function
|
||||
processCompletedToolCall(data, controller, encoder);
|
||||
currentToolCall = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text content alongside tool calls
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
context.hasTextContent()
|
||||
) {
|
||||
if (typeof data.choices[0].index === "number") {
|
||||
data.choices[0].index += 1;
|
||||
} else {
|
||||
data.choices[0].index = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
} catch (e) {
|
||||
// 如果JSON解析失败,可能是数据不完整,将原始行传递下去
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// Pass through non-data lines (like [DONE])
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
processBuffer(buffer, controller, encoder);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查value是否有效
|
||||
if (!value || value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk;
|
||||
try {
|
||||
chunk = decoder.decode(value, { stream: true });
|
||||
} catch (decodeError) {
|
||||
console.warn("Failed to decode chunk", decodeError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += chunk;
|
||||
|
||||
// 如果缓冲区过大,进行处理避免内存泄漏
|
||||
if (buffer.length > 1000000) {
|
||||
// 1MB 限制
|
||||
console.warn(
|
||||
"Buffer size exceeds limit, processing partial data"
|
||||
);
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => (hasTextContent = val),
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) =>
|
||||
(isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理缓冲区中完整的数据行
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // 最后一行可能不完整,保留在缓冲区
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => (hasTextContent = val),
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) => (isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
342
packages/core/src/transformer/forcereasoning.transformer.ts
Normal file
342
packages/core/src/transformer/forcereasoning.transformer.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
|
||||
const PROMPT = `Always think before answering. Even if the problem seems simple, always write down your reasoning process explicitly.
|
||||
|
||||
Output format:
|
||||
<reasoning_content>
|
||||
Your detailed thinking process goes here
|
||||
</reasoning_content>
|
||||
Your final answer must follow after the closing tag above.`;
|
||||
|
||||
const MAX_INTERLEAVED_TIMES = 10;
|
||||
|
||||
export class ForceReasoningTransformer implements Transformer {
|
||||
name = "forcereasoning";
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
let times = 0
|
||||
request.messages
|
||||
.filter((msg) => msg.role === "assistant")
|
||||
.reverse()
|
||||
.forEach((message) => {
|
||||
if (message.thinking) {
|
||||
if (message.thinking.content) {
|
||||
if (!message.content || times < MAX_INTERLEAVED_TIMES) {
|
||||
times++;
|
||||
message.content = `<reasoning_content>${message.thinking.content}</reasoning_content>\n${message.content}`;
|
||||
}
|
||||
}
|
||||
delete message.thinking;
|
||||
}
|
||||
});
|
||||
const lastMessage = request.messages[request.messages.length - 1];
|
||||
if (lastMessage.role === "user") {
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
lastMessage.content.push({
|
||||
type: "text",
|
||||
text: PROMPT,
|
||||
});
|
||||
} else {
|
||||
lastMessage.content = [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: lastMessage.content || '',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
if (lastMessage.role === "tool") {
|
||||
request.messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: PROMPT,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
const reasonStartTag = "<reasoning_content>";
|
||||
const reasonStopTag = "</reasoning_content>";
|
||||
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse: any = await response.json();
|
||||
if (jsonResponse.choices[0]?.message.content) {
|
||||
const regex = /<reasoning_content>(.*?)<\/reasoning_content>/s;
|
||||
const match = jsonResponse.choices[0]?.message.content.match(regex);
|
||||
if (match && match[1]) {
|
||||
jsonResponse.thinking = {
|
||||
content: match[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
let contentIndex = 0;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
let lineBuffer = "";
|
||||
|
||||
let fsmState: "SEARCHING" | "REASONING" | "FINAL" = "SEARCHING";
|
||||
let tagBuffer = "";
|
||||
let finalBuffer = "";
|
||||
|
||||
const processAndEnqueue = (
|
||||
originalData: any,
|
||||
content: string | null | undefined
|
||||
) => {
|
||||
if (typeof content !== "string") {
|
||||
if (
|
||||
originalData.choices?.[0]?.delta &&
|
||||
Object.keys(originalData.choices[0].delta).length > 0 &&
|
||||
!originalData.choices[0].delta.content
|
||||
) {
|
||||
originalData.choices[0].index = contentIndex
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(originalData)}\n\n`)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let currentContent = tagBuffer + content;
|
||||
tagBuffer = "";
|
||||
|
||||
while (currentContent.length > 0) {
|
||||
if (fsmState === "SEARCHING") {
|
||||
const startTagIndex = currentContent.indexOf(reasonStartTag);
|
||||
if (startTagIndex !== -1) {
|
||||
currentContent = currentContent.substring(
|
||||
startTagIndex + reasonStartTag.length
|
||||
);
|
||||
fsmState = "REASONING";
|
||||
} else {
|
||||
for (let i = reasonStartTag.length - 1; i > 0; i--) {
|
||||
if (
|
||||
currentContent.endsWith(reasonStartTag.substring(0, i))
|
||||
) {
|
||||
tagBuffer = currentContent.substring(
|
||||
currentContent.length - i
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentContent = "";
|
||||
}
|
||||
} else if (fsmState === "REASONING") {
|
||||
const endTagIndex = currentContent.indexOf(reasonStopTag);
|
||||
if (endTagIndex !== -1) {
|
||||
const reasoningPart = currentContent.substring(
|
||||
0,
|
||||
endTagIndex
|
||||
);
|
||||
if (reasoningPart.length > 0) {
|
||||
const newDelta = {
|
||||
...originalData.choices[0].delta,
|
||||
thinking: {
|
||||
content: reasoningPart,
|
||||
},
|
||||
};
|
||||
delete newDelta.content;
|
||||
const thinkingChunk = {
|
||||
...originalData,
|
||||
choices: [
|
||||
{ ...originalData.choices[0], delta: newDelta, index: contentIndex },
|
||||
],
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(thinkingChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Send signature message
|
||||
const signatureDelta = {
|
||||
...originalData.choices[0].delta,
|
||||
thinking: { signature: new Date().getTime().toString() },
|
||||
};
|
||||
delete signatureDelta.content;
|
||||
const signatureChunk = {
|
||||
...originalData,
|
||||
choices: [
|
||||
{ ...originalData.choices[0], delta: signatureDelta, index: contentIndex },
|
||||
],
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(signatureChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
contentIndex++;
|
||||
|
||||
currentContent = currentContent.substring(
|
||||
endTagIndex + reasonStopTag.length
|
||||
);
|
||||
fsmState = "FINAL";
|
||||
} else {
|
||||
let reasoningPart = currentContent;
|
||||
for (let i = reasonStopTag.length - 1; i > 0; i--) {
|
||||
if (
|
||||
currentContent.endsWith(reasonStopTag.substring(0, i))
|
||||
) {
|
||||
tagBuffer = currentContent.substring(
|
||||
currentContent.length - i
|
||||
);
|
||||
reasoningPart = currentContent.substring(
|
||||
0,
|
||||
currentContent.length - i
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (reasoningPart.length > 0) {
|
||||
const newDelta = {
|
||||
...originalData.choices[0].delta,
|
||||
thinking: { content: reasoningPart },
|
||||
};
|
||||
delete newDelta.content;
|
||||
const thinkingChunk = {
|
||||
...originalData,
|
||||
choices: [
|
||||
{ ...originalData.choices[0], delta: newDelta, index: contentIndex },
|
||||
],
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(thinkingChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
currentContent = "";
|
||||
}
|
||||
} else if (fsmState === "FINAL") {
|
||||
if (currentContent.length > 0) {
|
||||
// 检查内容是否只包含换行符
|
||||
const isOnlyNewlines = /^\s*$/.test(currentContent);
|
||||
|
||||
if (isOnlyNewlines) {
|
||||
// 如果只有换行符,添加到缓冲区但不发送
|
||||
finalBuffer += currentContent;
|
||||
} else {
|
||||
// 如果有非换行符内容,将缓冲区和新内容一起发送
|
||||
const finalPart = finalBuffer + currentContent;
|
||||
const newDelta = {
|
||||
...originalData.choices[0].delta,
|
||||
content: finalPart,
|
||||
};
|
||||
if (newDelta.thinking) delete newDelta.thinking;
|
||||
const finalChunk = {
|
||||
...originalData,
|
||||
choices: [
|
||||
{ ...originalData.choices[0], delta: newDelta },
|
||||
],
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`)
|
||||
);
|
||||
// 发送后清空缓冲区
|
||||
finalBuffer = "";
|
||||
}
|
||||
}
|
||||
contentIndex++
|
||||
currentContent = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
lineBuffer += chunk;
|
||||
const lines = lineBuffer.split("\n");
|
||||
lineBuffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
if (line.trim() === "data: [DONE]") {
|
||||
controller.enqueue(encoder.encode(line + "\n\n"));
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.startsWith("data:")) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(5));
|
||||
processAndEnqueue(data, data.choices?.[0]?.delta?.content);
|
||||
} catch (e) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
|
||||
if (fsmState === "REASONING") {
|
||||
const signatureDelta = {
|
||||
thinking: { signature: new Date().getTime().toString() },
|
||||
};
|
||||
const signatureChunk = {
|
||||
choices: [{ delta: signatureDelta }],
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(signatureChunk)}\n\n`)
|
||||
);
|
||||
}
|
||||
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("Content-Type") || "text/plain",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
40
packages/core/src/transformer/gemini.transformer.ts
Normal file
40
packages/core/src/transformer/gemini.transformer.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { LLMProvider, UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
import {
|
||||
buildRequestBody,
|
||||
transformRequestOut,
|
||||
transformResponseOut,
|
||||
} from "../utils/gemini.util";
|
||||
|
||||
export class GeminiTransformer implements Transformer {
|
||||
name = "gemini";
|
||||
|
||||
endPoint = "/v1beta/models/:modelAndAction";
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest,
|
||||
provider: LLMProvider
|
||||
): Promise<Record<string, any>> {
|
||||
return {
|
||||
body: buildRequestBody(request),
|
||||
config: {
|
||||
url: new URL(
|
||||
`./${request.model}:${
|
||||
request.stream ? "streamGenerateContent?alt=sse" : "generateContent"
|
||||
}`,
|
||||
provider.baseUrl
|
||||
),
|
||||
headers: {
|
||||
"x-goog-api-key": provider.apiKey,
|
||||
Authorization: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
transformRequestOut = transformRequestOut;
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
return transformResponseOut(response, this.name, this.logger);
|
||||
}
|
||||
}
|
||||
228
packages/core/src/transformer/groq.transformer.ts
Normal file
228
packages/core/src/transformer/groq.transformer.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { MessageContent, TextContent, UnifiedChatRequest } from "@/types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
|
||||
export class GroqTransformer implements Transformer {
|
||||
name = "groq";
|
||||
|
||||
async transformRequestIn(request: UnifiedChatRequest): Promise<UnifiedChatRequest> {
|
||||
request.messages.forEach(msg => {
|
||||
if (Array.isArray(msg.content)) {
|
||||
(msg.content as MessageContent[]).forEach((item) => {
|
||||
if ((item as TextContent).cache_control) {
|
||||
delete (item as TextContent).cache_control;
|
||||
}
|
||||
});
|
||||
} else if (msg.cache_control) {
|
||||
delete msg.cache_control;
|
||||
}
|
||||
})
|
||||
if (Array.isArray(request.tools)) {
|
||||
request.tools.forEach(tool => {
|
||||
delete tool.function.parameters.$schema;
|
||||
})
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let hasTextContent = false;
|
||||
let reasoningContent = "";
|
||||
let isReasoningComplete = false;
|
||||
let buffer = ""; // 用于缓冲不完整的数据
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
const processBuffer = (buffer: string, controller: ReadableStreamDefaultController, encoder: InstanceType<typeof TextEncoder>) => {
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processLine = (line: string, context: {
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: typeof TextEncoder;
|
||||
hasTextContent: () => boolean;
|
||||
setHasTextContent: (val: boolean) => void;
|
||||
reasoningContent: () => string;
|
||||
appendReasoningContent: (content: string) => void;
|
||||
isReasoningComplete: () => boolean;
|
||||
setReasoningComplete: (val: boolean) => void;
|
||||
}) => {
|
||||
const { controller, encoder } = context;
|
||||
|
||||
if (line.startsWith("data: ") && line.trim() !== "data: [DONE]") {
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.error) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
|
||||
if (data.choices?.[0]?.delta?.content && !context.hasTextContent()) {
|
||||
context.setHasTextContent(true);
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length
|
||||
) {
|
||||
data.choices?.[0]?.delta?.tool_calls.forEach((tool: any) => {
|
||||
tool.id = `call_${uuidv4()}`;
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
context.hasTextContent()
|
||||
) {
|
||||
if (typeof data.choices[0].index === 'number') {
|
||||
data.choices[0].index += 1;
|
||||
} else {
|
||||
data.choices[0].index = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
} catch (e) {
|
||||
// 如果JSON解析失败,可能是数据不完整,将原始行传递下去
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// Pass through non-data lines (like [DONE])
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
processBuffer(buffer, controller, encoder);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查value是否有效
|
||||
if (!value || value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk;
|
||||
try {
|
||||
chunk = decoder.decode(value, { stream: true });
|
||||
} catch (decodeError) {
|
||||
console.warn("Failed to decode chunk", decodeError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += chunk;
|
||||
|
||||
// 如果缓冲区过大,进行处理避免内存泄漏
|
||||
if (buffer.length > 1000000) { // 1MB 限制
|
||||
console.warn("Buffer size exceeds limit, processing partial data");
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => hasTextContent = val,
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) => reasoningContent += content,
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) => isReasoningComplete = val
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理缓冲区中完整的数据行
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // 最后一行可能不完整,保留在缓冲区
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => hasTextContent = val,
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) => reasoningContent += content,
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) => isReasoningComplete = val
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
45
packages/core/src/transformer/index.ts
Normal file
45
packages/core/src/transformer/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AnthropicTransformer } from "./anthropic.transformer";
|
||||
import { GeminiTransformer } from "./gemini.transformer";
|
||||
import { VertexGeminiTransformer } from "./vertex-gemini.transformer";
|
||||
import { DeepseekTransformer } from "./deepseek.transformer";
|
||||
import { TooluseTransformer } from "./tooluse.transformer";
|
||||
import { OpenrouterTransformer } from "./openrouter.transformer";
|
||||
import { MaxTokenTransformer } from "./maxtoken.transformer";
|
||||
import { GroqTransformer } from "./groq.transformer";
|
||||
import { CleancacheTransformer } from "./cleancache.transformer";
|
||||
import { EnhanceToolTransformer } from "./enhancetool.transformer";
|
||||
import { ReasoningTransformer } from "./reasoning.transformer";
|
||||
import { SamplingTransformer } from "./sampling.transformer";
|
||||
import { MaxCompletionTokens } from "./maxcompletiontokens.transformer";
|
||||
import { VertexClaudeTransformer } from "./vertex-claude.transformer";
|
||||
import { CerebrasTransformer } from "./cerebras.transformer";
|
||||
import { StreamOptionsTransformer } from "./streamoptions.transformer";
|
||||
import { OpenAITransformer } from "./openai.transformer";
|
||||
import { CustomParamsTransformer } from "./customparams.transformer";
|
||||
import { VercelTransformer } from "./vercel.transformer";
|
||||
import { OpenAIResponsesTransformer } from "./openai.responses.transformer";
|
||||
import { ForceReasoningTransformer } from "./forcereasoning.transformer"
|
||||
|
||||
export default {
|
||||
AnthropicTransformer,
|
||||
GeminiTransformer,
|
||||
VertexGeminiTransformer,
|
||||
VertexClaudeTransformer,
|
||||
DeepseekTransformer,
|
||||
TooluseTransformer,
|
||||
OpenrouterTransformer,
|
||||
OpenAITransformer,
|
||||
MaxTokenTransformer,
|
||||
GroqTransformer,
|
||||
CleancacheTransformer,
|
||||
EnhanceToolTransformer,
|
||||
ReasoningTransformer,
|
||||
SamplingTransformer,
|
||||
MaxCompletionTokens,
|
||||
CerebrasTransformer,
|
||||
StreamOptionsTransformer,
|
||||
CustomParamsTransformer,
|
||||
VercelTransformer,
|
||||
OpenAIResponsesTransformer,
|
||||
ForceReasoningTransformer
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
|
||||
export class MaxCompletionTokens implements Transformer {
|
||||
static TransformerName = "maxcompletiontokens";
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
if (request.max_tokens) {
|
||||
request.max_completion_tokens = request.max_tokens;
|
||||
delete request.max_tokens;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
}
|
||||
18
packages/core/src/transformer/maxtoken.transformer.ts
Normal file
18
packages/core/src/transformer/maxtoken.transformer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer, TransformerOptions } from "../types/transformer";
|
||||
|
||||
export class MaxTokenTransformer implements Transformer {
|
||||
static TransformerName = "maxtoken";
|
||||
max_tokens: number;
|
||||
|
||||
constructor(private readonly options?: TransformerOptions) {
|
||||
this.max_tokens = this.options?.max_tokens;
|
||||
}
|
||||
|
||||
async transformRequestIn(request: UnifiedChatRequest): Promise<UnifiedChatRequest> {
|
||||
if (request.max_tokens && request.max_tokens > this.max_tokens) {
|
||||
request.max_tokens = this.max_tokens;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
}
|
||||
792
packages/core/src/transformer/openai.responses.transformer.ts
Normal file
792
packages/core/src/transformer/openai.responses.transformer.ts
Normal file
@@ -0,0 +1,792 @@
|
||||
import { UnifiedChatRequest, MessageContent } from "@/types/llm";
|
||||
import { Transformer } from "@/types/transformer";
|
||||
|
||||
interface ResponsesAPIOutputItem {
|
||||
type: string;
|
||||
id?: string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: string;
|
||||
mime_type?: string;
|
||||
image_base64?: string;
|
||||
}>;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
interface ResponsesAPIPayload {
|
||||
id: string;
|
||||
object: string;
|
||||
model: string;
|
||||
created_at: number;
|
||||
output: ResponsesAPIOutputItem[];
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponsesStreamEvent {
|
||||
type: string;
|
||||
item_id?: string;
|
||||
output_index?: number;
|
||||
delta?:
|
||||
| string
|
||||
| {
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
mime_type?: string;
|
||||
};
|
||||
item?: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: string;
|
||||
mime_type?: string;
|
||||
}>;
|
||||
reasoning?: string; // 添加 reasoning 字段支持
|
||||
};
|
||||
response?: {
|
||||
id?: string;
|
||||
model?: string;
|
||||
output?: Array<{
|
||||
type: string;
|
||||
}>;
|
||||
};
|
||||
reasoning_summary?: string; // 添加推理摘要支持
|
||||
}
|
||||
|
||||
export class OpenAIResponsesTransformer implements Transformer {
|
||||
name = "openai-responses";
|
||||
endPoint = "/v1/responses";
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
delete request.temperature;
|
||||
delete request.max_tokens;
|
||||
|
||||
// 处理 reasoning 参数
|
||||
if (request.reasoning) {
|
||||
(request as any).reasoning = {
|
||||
effort: request.reasoning.effort,
|
||||
summary: "detailed",
|
||||
};
|
||||
}
|
||||
|
||||
const input: any[] = [];
|
||||
|
||||
const systemMessages = request.messages.filter(
|
||||
(msg) => msg.role === "system"
|
||||
);
|
||||
if (systemMessages.length > 0) {
|
||||
const firstSystem = systemMessages[0];
|
||||
if (Array.isArray(firstSystem.content)) {
|
||||
firstSystem.content.forEach((item) => {
|
||||
let text = "";
|
||||
if (typeof item === "string") {
|
||||
text = item;
|
||||
} else if (item && typeof item === "object" && "text" in item) {
|
||||
text = (item as { text: string }).text;
|
||||
}
|
||||
input.push({
|
||||
role: "system",
|
||||
content: text,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
(request as any).instructions = firstSystem.content;
|
||||
}
|
||||
}
|
||||
|
||||
request.messages.forEach((message) => {
|
||||
if (message.role === "system") return;
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
const convertedContent = message.content
|
||||
.map((content) => this.normalizeRequestContent(content, message.role))
|
||||
.filter(
|
||||
(content): content is Record<string, unknown> => content !== null
|
||||
);
|
||||
|
||||
if (convertedContent.length > 0) {
|
||||
(message as any).content = convertedContent;
|
||||
} else {
|
||||
delete (message as any).content;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
const toolMessage: any = { ...message };
|
||||
toolMessage.type = "function_call_output";
|
||||
toolMessage.call_id = message.tool_call_id;
|
||||
toolMessage.output = message.content;
|
||||
delete toolMessage.cache_control;
|
||||
delete toolMessage.role;
|
||||
delete toolMessage.tool_call_id;
|
||||
delete toolMessage.content;
|
||||
input.push(toolMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.tool_calls)) {
|
||||
message.tool_calls.forEach((tool) => {
|
||||
input.push({
|
||||
type: "function_call",
|
||||
arguments: tool.function.arguments,
|
||||
name: tool.function.name,
|
||||
call_id: tool.id,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
input.push(message);
|
||||
});
|
||||
|
||||
(request as any).input = input;
|
||||
delete (request as any).messages;
|
||||
|
||||
if (Array.isArray(request.tools)) {
|
||||
const webSearch = request.tools.find(
|
||||
(tool) => tool.function.name === "web_search"
|
||||
);
|
||||
|
||||
(request as any).tools = request.tools
|
||||
.filter((tool) => tool.function.name !== "web_search")
|
||||
.map((tool) => {
|
||||
if (tool.function.name === "WebSearch") {
|
||||
delete tool.function.parameters.properties.allowed_domains;
|
||||
}
|
||||
if (tool.function.name === "Edit") {
|
||||
return {
|
||||
type: tool.type,
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: {
|
||||
...tool.function.parameters,
|
||||
required: [
|
||||
"file_path",
|
||||
"old_string",
|
||||
"new_string",
|
||||
"replace_all",
|
||||
],
|
||||
},
|
||||
strict: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: tool.type,
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
};
|
||||
});
|
||||
|
||||
if (webSearch) {
|
||||
(request as any).tools.push({
|
||||
type: "web_search",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
request.parallel_tool_calls = false;
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const jsonResponse: any = await response.json();
|
||||
|
||||
// 检查是否为responses API格式的JSON响应
|
||||
if (jsonResponse.object === "response" && jsonResponse.output) {
|
||||
// 将responses格式转换为chat格式
|
||||
const chatResponse = this.convertResponseToChat(jsonResponse);
|
||||
return new Response(JSON.stringify(chatResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
// 不是responses API格式,保持原样
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (contentType.includes("text/event-stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = ""; // 用于缓冲不完整的数据
|
||||
let isStreamEnded = false;
|
||||
|
||||
const transformer = this;
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
|
||||
// 索引跟踪变量,只有在事件类型切换时才增加索引
|
||||
let currentIndex = -1;
|
||||
let lastEventType = "";
|
||||
|
||||
// 获取当前应该使用的索引的函数
|
||||
const getCurrentIndex = (eventType: string) => {
|
||||
if (eventType !== lastEventType) {
|
||||
currentIndex++;
|
||||
lastEventType = eventType;
|
||||
}
|
||||
return currentIndex;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (!isStreamEnded) {
|
||||
// 发送结束标记
|
||||
const doneChunk = `data: [DONE]\n\n`;
|
||||
controller.enqueue(encoder.encode(doneChunk));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// 处理缓冲区中完整的数据行
|
||||
let lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() || ""; // 最后一行可能不完整,保留在缓冲区
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
if (line.startsWith("event: ")) {
|
||||
// 处理事件行,暂存以便与下一行数据配对
|
||||
continue;
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const dataStr = line.slice(5).trim(); // 移除 "data: " 前缀
|
||||
if (dataStr === "[DONE]") {
|
||||
isStreamEnded = true;
|
||||
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const data: ResponsesStreamEvent = JSON.parse(dataStr);
|
||||
|
||||
// 根据不同的事件类型转换为chat格式
|
||||
if (data.type === "response.output_text.delta") {
|
||||
// 将output_text.delta转换为chat格式
|
||||
const chatChunk = {
|
||||
id: data.item_id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model,
|
||||
choices: [
|
||||
{
|
||||
index: getCurrentIndex(data.type),
|
||||
delta: {
|
||||
content: data.delta || "",
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(chatChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
data.type === "response.output_item.added" &&
|
||||
data.item?.type === "function_call"
|
||||
) {
|
||||
// 处理function call开始 - 创建初始的tool call chunk
|
||||
const functionCallChunk = {
|
||||
id:
|
||||
data.item.call_id ||
|
||||
data.item.id ||
|
||||
"chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model || "gpt-5-codex-",
|
||||
choices: [
|
||||
{
|
||||
index: getCurrentIndex(data.type),
|
||||
delta: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: data.item.call_id || data.item.id,
|
||||
function: {
|
||||
name: data.item.name || "",
|
||||
arguments: "",
|
||||
},
|
||||
type: "function",
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(functionCallChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
data.type === "response.output_item.added" &&
|
||||
data.item?.type === "message"
|
||||
) {
|
||||
// 处理message item added事件
|
||||
const contentItems: MessageContent[] = [];
|
||||
(data.item.content || []).forEach((item: any) => {
|
||||
if (item.type === "output_text") {
|
||||
contentItems.push({
|
||||
type: "text",
|
||||
text: item.text || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const delta: any = { role: "assistant" };
|
||||
if (
|
||||
contentItems.length === 1 &&
|
||||
contentItems[0].type === "text"
|
||||
) {
|
||||
delta.content = contentItems[0].text;
|
||||
} else if (contentItems.length > 0) {
|
||||
delta.content = contentItems;
|
||||
}
|
||||
if (delta.content) {
|
||||
const messageChunk = {
|
||||
id: data.item.id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model,
|
||||
choices: [
|
||||
{
|
||||
index: getCurrentIndex(data.type),
|
||||
delta,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(messageChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
data.type === "response.output_text.annotation.added"
|
||||
) {
|
||||
const annotationChunk = {
|
||||
id: data.item_id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model || "gpt-5-codex",
|
||||
choices: [
|
||||
{
|
||||
index: getCurrentIndex(data.type),
|
||||
delta: {
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url_citation: {
|
||||
url: data.annotation?.url || "",
|
||||
title: data.annotation?.title || "",
|
||||
content: "",
|
||||
start_index:
|
||||
data.annotation?.start_index || 0,
|
||||
end_index:
|
||||
data.annotation?.end_index || 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(annotationChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
data.type === "response.function_call_arguments.delta"
|
||||
) {
|
||||
// 处理function call参数增量
|
||||
const functionCallChunk = {
|
||||
id: data.item_id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model || "gpt-5-codex-",
|
||||
choices: [
|
||||
{
|
||||
index: getCurrentIndex(data.type),
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: data.delta || "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(functionCallChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
} else if (data.type === "response.completed") {
|
||||
// 发送结束标记 - 检查是否是tool_calls完成
|
||||
const finishReason = data.response?.output?.some(
|
||||
(item: any) => item.type === "function_call"
|
||||
)
|
||||
? "tool_calls"
|
||||
: "stop";
|
||||
|
||||
const endChunk = {
|
||||
id: data.response?.id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model || "gpt-5-codex-",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: finishReason,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(endChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
isStreamEnded = true;
|
||||
} else if (
|
||||
data.type === "response.reasoning_summary_text.delta"
|
||||
) {
|
||||
// 处理推理文本,将其转换为 thinking delta 格式
|
||||
const thinkingChunk = {
|
||||
id: data.item_id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model,
|
||||
choices: [
|
||||
{
|
||||
index: getCurrentIndex(data.type),
|
||||
delta: {
|
||||
thinking: {
|
||||
content: data.delta || "",
|
||||
},
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(thinkingChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
data.type === "response.reasoning_summary_part.done" &&
|
||||
data.part
|
||||
) {
|
||||
const thinkingChunk = {
|
||||
id: data.item_id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: data.response?.model,
|
||||
choices: [
|
||||
{
|
||||
index: currentIndex,
|
||||
delta: {
|
||||
thinking: {
|
||||
signature: data.item_id,
|
||||
},
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify(thinkingChunk)}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果JSON解析失败,传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// 传递其他行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
controller.enqueue(encoder.encode(buffer + "\n"));
|
||||
}
|
||||
|
||||
// 确保流结束时发送结束标记
|
||||
if (!isStreamEnded) {
|
||||
const doneChunk = `data: [DONE]\n\n`;
|
||||
controller.enqueue(encoder.encode(doneChunk));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private normalizeRequestContent(content: any, role: string | undefined) {
|
||||
// 克隆内容对象并删除缓存控制字段
|
||||
const clone = { ...content };
|
||||
delete clone.cache_control;
|
||||
|
||||
if (content.type === "text") {
|
||||
return {
|
||||
type: role === "assistant" ? "output_text" : "input_text",
|
||||
text: content.text,
|
||||
};
|
||||
}
|
||||
|
||||
if (content.type === "image_url") {
|
||||
console.log(content);
|
||||
const imagePayload: Record<string, unknown> = {
|
||||
type: role === "assistant" ? "output_image" : "input_image",
|
||||
};
|
||||
|
||||
if (typeof content.image_url?.url === "string") {
|
||||
imagePayload.image_url = content.image_url.url;
|
||||
}
|
||||
|
||||
return imagePayload;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertResponseToChat(responseData: ResponsesAPIPayload): any {
|
||||
// 从output数组中提取不同类型的输出
|
||||
const messageOutput = responseData.output?.find(
|
||||
(item) => item.type === "message"
|
||||
);
|
||||
const functionCallOutput = responseData.output?.find(
|
||||
(item) => item.type === "function_call"
|
||||
);
|
||||
let annotations;
|
||||
if (
|
||||
messageOutput?.content?.length &&
|
||||
messageOutput?.content[0].annotations
|
||||
) {
|
||||
annotations = messageOutput.content[0].annotations.map((item) => {
|
||||
return {
|
||||
type: "url_citation",
|
||||
url_citation: {
|
||||
url: item.url || "",
|
||||
title: item.title || "",
|
||||
content: "",
|
||||
start_index: item.start_index || 0,
|
||||
end_index: item.end_index || 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
data: annotations,
|
||||
type: "url_citation",
|
||||
});
|
||||
|
||||
let messageContent: string | MessageContent[] | null = null;
|
||||
let toolCalls = null;
|
||||
let thinking = null;
|
||||
|
||||
// 处理推理内容
|
||||
if (messageOutput && messageOutput.reasoning) {
|
||||
thinking = {
|
||||
content: messageOutput.reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
if (messageOutput && messageOutput.content) {
|
||||
// 分离文本和图片内容
|
||||
const textParts: string[] = [];
|
||||
const imageParts: MessageContent[] = [];
|
||||
|
||||
messageOutput.content.forEach((item: any) => {
|
||||
if (item.type === "output_text") {
|
||||
textParts.push(item.text || "");
|
||||
} else if (item.type === "output_image") {
|
||||
const imageContent = this.buildImageContent({
|
||||
url: item.image_url,
|
||||
mime_type: item.mime_type,
|
||||
});
|
||||
if (imageContent) {
|
||||
imageParts.push(imageContent);
|
||||
}
|
||||
} else if (item.type === "output_image_base64") {
|
||||
const imageContent = this.buildImageContent({
|
||||
b64_json: item.image_base64,
|
||||
mime_type: item.mime_type,
|
||||
});
|
||||
if (imageContent) {
|
||||
imageParts.push(imageContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 构建最终内容
|
||||
if (imageParts.length > 0) {
|
||||
// 如果有图片,将所有内容组合成数组
|
||||
const contentArray: MessageContent[] = [];
|
||||
if (textParts.length > 0) {
|
||||
contentArray.push({
|
||||
type: "text",
|
||||
text: textParts.join(""),
|
||||
});
|
||||
}
|
||||
contentArray.push(...imageParts);
|
||||
messageContent = contentArray;
|
||||
} else {
|
||||
// 如果只有文本,返回字符串
|
||||
messageContent = textParts.join("");
|
||||
}
|
||||
}
|
||||
|
||||
if (functionCallOutput) {
|
||||
// 处理function_call类型的输出
|
||||
toolCalls = [
|
||||
{
|
||||
id: functionCallOutput.call_id || functionCallOutput.id,
|
||||
function: {
|
||||
name: functionCallOutput.name,
|
||||
arguments: functionCallOutput.arguments,
|
||||
},
|
||||
type: "function",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 构建chat格式的响应
|
||||
const chatResponse = {
|
||||
id: responseData.id || "chatcmpl-" + Date.now(),
|
||||
object: "chat.completion",
|
||||
created: responseData.created_at,
|
||||
model: responseData.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: messageContent || null,
|
||||
tool_calls: toolCalls,
|
||||
thinking: thinking,
|
||||
annotations: annotations,
|
||||
},
|
||||
logprobs: null,
|
||||
finish_reason: toolCalls ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: responseData.usage
|
||||
? {
|
||||
prompt_tokens: responseData.usage.input_tokens || 0,
|
||||
completion_tokens: responseData.usage.output_tokens || 0,
|
||||
total_tokens: responseData.usage.total_tokens || 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return chatResponse;
|
||||
}
|
||||
|
||||
private buildImageContent(source: {
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
mime_type?: string;
|
||||
}): MessageContent | null {
|
||||
if (!source) return null;
|
||||
|
||||
if (source.url || source.b64_json) {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: source.url || "",
|
||||
b64_json: source.b64_json,
|
||||
},
|
||||
media_type: source.mime_type,
|
||||
} as MessageContent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
6
packages/core/src/transformer/openai.transformer.ts
Normal file
6
packages/core/src/transformer/openai.transformer.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Transformer } from "@/types/transformer";
|
||||
|
||||
export class OpenAITransformer implements Transformer {
|
||||
name = "OpenAI";
|
||||
endPoint = "/v1/chat/completions";
|
||||
}
|
||||
357
packages/core/src/transformer/openrouter.transformer.ts
Normal file
357
packages/core/src/transformer/openrouter.transformer.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { UnifiedChatRequest } from "@/types/llm";
|
||||
import { Transformer, TransformerOptions } from "../types/transformer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export class OpenrouterTransformer implements Transformer {
|
||||
static TransformerName = "openrouter";
|
||||
|
||||
constructor(private readonly options?: TransformerOptions) {}
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
if (!request.model.includes("claude")) {
|
||||
request.messages.forEach((msg) => {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content.forEach((item: any) => {
|
||||
if (item.cache_control) {
|
||||
delete item.cache_control;
|
||||
}
|
||||
if (item.type === "image_url") {
|
||||
if (!item.image_url.url.startsWith("http")) {
|
||||
item.image_url.url = `${item.image_url.url}`;
|
||||
}
|
||||
delete item.media_type;
|
||||
}
|
||||
});
|
||||
} else if (msg.cache_control) {
|
||||
delete msg.cache_control;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
request.messages.forEach((msg) => {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content.forEach((item: any) => {
|
||||
if (item.type === "image_url") {
|
||||
if (!item.image_url.url.startsWith("http")) {
|
||||
item.image_url.url = `data:${item.media_type};base64,${item.image_url.url}`;
|
||||
}
|
||||
delete item.media_type;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Object.assign(request, this.options || {});
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let hasTextContent = false;
|
||||
let reasoningContent = "";
|
||||
let isReasoningComplete = false;
|
||||
let hasToolCall = false;
|
||||
let buffer = ""; // 用于缓冲不完整的数据
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
const processBuffer = (
|
||||
buffer: string,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) => {
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processLine = (
|
||||
line: string,
|
||||
context: {
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
hasTextContent: () => boolean;
|
||||
setHasTextContent: (val: boolean) => void;
|
||||
reasoningContent: () => string;
|
||||
appendReasoningContent: (content: string) => void;
|
||||
isReasoningComplete: () => boolean;
|
||||
setReasoningComplete: (val: boolean) => void;
|
||||
}
|
||||
) => {
|
||||
const { controller, encoder } = context;
|
||||
|
||||
if (line.startsWith("data: ") && line.trim() !== "data: [DONE]") {
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.usage) {
|
||||
this.logger?.debug(
|
||||
{ usage: data.usage, hasToolCall },
|
||||
"usage"
|
||||
);
|
||||
data.choices[0].finish_reason = hasToolCall
|
||||
? "tool_calls"
|
||||
: "stop";
|
||||
}
|
||||
|
||||
if (data.choices?.[0]?.finish_reason === "error") {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
error: data.choices?.[0].error,
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.content &&
|
||||
!context.hasTextContent()
|
||||
) {
|
||||
context.setHasTextContent(true);
|
||||
}
|
||||
|
||||
// Extract reasoning_content from delta
|
||||
if (data.choices?.[0]?.delta?.reasoning) {
|
||||
context.appendReasoningContent(
|
||||
data.choices[0].delta.reasoning
|
||||
);
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices?.[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
thinking: {
|
||||
content: data.choices[0].delta.reasoning,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
if (thinkingChunk.choices?.[0]?.delta) {
|
||||
delete thinkingChunk.choices[0].delta.reasoning;
|
||||
}
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if reasoning is complete
|
||||
if (
|
||||
data.choices?.[0]?.delta?.content &&
|
||||
context.reasoningContent() &&
|
||||
!context.isReasoningComplete()
|
||||
) {
|
||||
context.setReasoningComplete(true);
|
||||
const signature = Date.now().toString();
|
||||
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices?.[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
content: null,
|
||||
thinking: {
|
||||
content: context.reasoningContent(),
|
||||
signature: signature,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
if (thinkingChunk.choices?.[0]?.delta) {
|
||||
delete thinkingChunk.choices[0].delta.reasoning;
|
||||
}
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
}
|
||||
|
||||
if (data.choices?.[0]?.delta?.reasoning) {
|
||||
delete data.choices[0].delta.reasoning;
|
||||
}
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
!Number.isNaN(
|
||||
parseInt(data.choices?.[0]?.delta?.tool_calls[0].id, 10)
|
||||
)
|
||||
) {
|
||||
data.choices?.[0]?.delta?.tool_calls.forEach((tool: any) => {
|
||||
tool.id = `call_${uuidv4()}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
!hasToolCall
|
||||
) {
|
||||
hasToolCall = true;
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
context.hasTextContent()
|
||||
) {
|
||||
if (typeof data.choices[0].index === "number") {
|
||||
data.choices[0].index += 1;
|
||||
} else {
|
||||
data.choices[0].index = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
} catch (e) {
|
||||
// 如果JSON解析失败,可能是数据不完整,将原始行传递下去
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// Pass through non-data lines (like [DONE])
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
processBuffer(buffer, controller, encoder);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查value是否有效
|
||||
if (!value || value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk;
|
||||
try {
|
||||
chunk = decoder.decode(value, { stream: true });
|
||||
} catch (decodeError) {
|
||||
console.warn("Failed to decode chunk", decodeError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += chunk;
|
||||
|
||||
// 如果缓冲区过大,进行处理避免内存泄漏
|
||||
if (buffer.length > 1000000) {
|
||||
// 1MB 限制
|
||||
console.warn(
|
||||
"Buffer size exceeds limit, processing partial data"
|
||||
);
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => (hasTextContent = val),
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) =>
|
||||
(isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理缓冲区中完整的数据行
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // 最后一行可能不完整,保留在缓冲区
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => (hasTextContent = val),
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) => (isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
250
packages/core/src/transformer/reasoning.transformer.ts
Normal file
250
packages/core/src/transformer/reasoning.transformer.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { UnifiedChatRequest } from "@/types/llm";
|
||||
import { Transformer, TransformerOptions } from "../types/transformer";
|
||||
|
||||
export class ReasoningTransformer implements Transformer {
|
||||
static TransformerName = "reasoning";
|
||||
enable: any;
|
||||
|
||||
constructor(private readonly options?: TransformerOptions) {
|
||||
this.enable = this.options?.enable ?? true;
|
||||
}
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
if (!this.enable) {
|
||||
request.thinking = {
|
||||
type: "disabled",
|
||||
budget_tokens: -1,
|
||||
};
|
||||
request.enable_thinking = false;
|
||||
return request;
|
||||
}
|
||||
if (request.reasoning) {
|
||||
request.thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: request.reasoning.max_tokens,
|
||||
};
|
||||
request.enable_thinking = true;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (!this.enable) return response;
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
if (jsonResponse.choices[0]?.message.reasoning_content) {
|
||||
jsonResponse.thinking = {
|
||||
content: jsonResponse.choices[0]?.message.reasoning_content
|
||||
}
|
||||
}
|
||||
// Handle non-streaming response if needed
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let reasoningContent = "";
|
||||
let isReasoningComplete = false;
|
||||
let buffer = ""; // Buffer for incomplete data
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
|
||||
// Process buffer function
|
||||
const processBuffer = (
|
||||
buffer: string,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) => {
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Process line function
|
||||
const processLine = (
|
||||
line: string,
|
||||
context: {
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: typeof TextEncoder;
|
||||
reasoningContent: () => string;
|
||||
appendReasoningContent: (content: string) => void;
|
||||
isReasoningComplete: () => boolean;
|
||||
setReasoningComplete: (val: boolean) => void;
|
||||
}
|
||||
) => {
|
||||
const { controller, encoder } = context;
|
||||
|
||||
this.logger?.debug({ line }, `Processing reason line`);
|
||||
|
||||
if (line.startsWith("data: ") && line.trim() !== "data: [DONE]") {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
console.log(JSON.stringify(data))
|
||||
|
||||
// Extract reasoning_content from delta
|
||||
if (data.choices?.[0]?.delta?.reasoning_content) {
|
||||
context.appendReasoningContent(
|
||||
data.choices[0].delta.reasoning_content
|
||||
);
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
thinking: {
|
||||
content: data.choices[0].delta.reasoning_content,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
delete thinkingChunk.choices[0].delta.reasoning_content;
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if reasoning is complete (when delta has content but no reasoning_content)
|
||||
if (
|
||||
(data.choices?.[0]?.delta?.content ||
|
||||
data.choices?.[0]?.delta?.tool_calls) &&
|
||||
context.reasoningContent() &&
|
||||
!context.isReasoningComplete()
|
||||
) {
|
||||
context.setReasoningComplete(true);
|
||||
const signature = Date.now().toString();
|
||||
|
||||
// Create a new chunk with thinking block
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
content: null,
|
||||
thinking: {
|
||||
content: context.reasoningContent(),
|
||||
signature: signature,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
delete thinkingChunk.choices[0].delta.reasoning_content;
|
||||
// Send the thinking chunk
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
}
|
||||
|
||||
if (data.choices?.[0]?.delta?.reasoning_content) {
|
||||
delete data.choices[0].delta.reasoning_content;
|
||||
}
|
||||
|
||||
// Send the modified chunk
|
||||
if (
|
||||
data.choices?.[0]?.delta &&
|
||||
Object.keys(data.choices[0].delta).length > 0
|
||||
) {
|
||||
if (context.isReasoningComplete()) {
|
||||
data.choices[0].index++;
|
||||
}
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
}
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, pass through the original line
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// Pass through non-data lines (like [DONE])
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// Process remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
processBuffer(buffer, controller, encoder);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// Process complete lines from buffer
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder: encoder,
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) => (isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// Pass through original line if parsing fails
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
41
packages/core/src/transformer/sampling.transformer.ts
Normal file
41
packages/core/src/transformer/sampling.transformer.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer, TransformerOptions } from "../types/transformer";
|
||||
|
||||
export class SamplingTransformer implements Transformer {
|
||||
static TransformerName = "sampling";
|
||||
|
||||
max_tokens: number;
|
||||
temperature: number;
|
||||
top_p: number;
|
||||
top_k: number;
|
||||
repetition_penalty: number;
|
||||
|
||||
constructor(private readonly options?: TransformerOptions) {
|
||||
this.max_tokens = this.options?.max_tokens;
|
||||
this.temperature = this.options?.temperature;
|
||||
this.top_p = this.options?.top_p;
|
||||
this.top_k = this.options?.top_k;
|
||||
this.repetition_penalty = this.options?.repetition_penalty;
|
||||
}
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
if (request.max_tokens && request.max_tokens > this.max_tokens) {
|
||||
request.max_tokens = this.max_tokens;
|
||||
}
|
||||
if (typeof this.temperature !== "undefined") {
|
||||
request.temperature = this.temperature;
|
||||
}
|
||||
if (typeof this.top_p !== "undefined") {
|
||||
request.top_p = this.top_p;
|
||||
}
|
||||
if (typeof this.top_k !== "undefined") {
|
||||
request.top_k = this.top_k;
|
||||
}
|
||||
if (typeof this.repetition_penalty !== "undefined") {
|
||||
request.repetition_penalty = this.repetition_penalty;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
}
|
||||
16
packages/core/src/transformer/streamoptions.transformer.ts
Normal file
16
packages/core/src/transformer/streamoptions.transformer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer, TransformerOptions } from "../types/transformer";
|
||||
|
||||
export class StreamOptionsTransformer implements Transformer {
|
||||
name = "streamoptions";
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
if (!request.stream) return request;
|
||||
request.stream_options = {
|
||||
include_usage: true,
|
||||
};
|
||||
return request;
|
||||
}
|
||||
}
|
||||
223
packages/core/src/transformer/tooluse.transformer.ts
Normal file
223
packages/core/src/transformer/tooluse.transformer.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
|
||||
export class TooluseTransformer implements Transformer {
|
||||
name = "tooluse";
|
||||
|
||||
transformRequestIn(request: UnifiedChatRequest): UnifiedChatRequest {
|
||||
request.messages.push({
|
||||
role: "system",
|
||||
content: `<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task.
|
||||
Before invoking a tool, you must carefully evaluate whether it matches the current task. If no available tool is appropriate for the task, you MUST call the \`ExitTool\` to exit tool mode — this is the only valid way to terminate tool mode.
|
||||
Always prioritize completing the user's task effectively and efficiently by using tools whenever appropriate.</system-reminder>`,
|
||||
});
|
||||
if (request.tools?.length) {
|
||||
request.tool_choice = "required";
|
||||
request.tools.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ExitTool",
|
||||
description: `Use this tool when you are in tool mode and have completed the task. This is the only valid way to exit tool mode.
|
||||
IMPORTANT: Before using this tool, ensure that none of the available tools are applicable to the current task. You must evaluate all available options — only if no suitable tool can help you complete the task should you use ExitTool to terminate tool mode.
|
||||
Examples:
|
||||
1. Task: "Use a tool to summarize this document" — Do not use ExitTool if a summarization tool is available.
|
||||
2. Task: "What’s the weather today?" — If no tool is available to answer, use ExitTool after reasoning that none can fulfill the task.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
response: {
|
||||
type: "string",
|
||||
description:
|
||||
"Your response will be forwarded to the user exactly as returned — the tool will not modify or post-process it in any way.",
|
||||
},
|
||||
},
|
||||
required: ["response"],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
if (
|
||||
jsonResponse?.choices?.[0]?.message.tool_calls?.length &&
|
||||
jsonResponse?.choices?.[0]?.message.tool_calls[0]?.function?.name ===
|
||||
"ExitTool"
|
||||
) {
|
||||
const toolCall = jsonResponse?.choices[0]?.message.tool_calls[0];
|
||||
const toolArguments = JSON.parse(toolCall.function.arguments || "{}");
|
||||
jsonResponse.choices[0].message.content = toolArguments.response || "";
|
||||
delete jsonResponse.choices[0].message.tool_calls;
|
||||
}
|
||||
|
||||
// Handle non-streaming response if needed
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let exitToolIndex = -1;
|
||||
let exitToolResponse = "";
|
||||
let buffer = ""; // 用于缓冲不完整的数据
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
|
||||
const processBuffer = (
|
||||
buffer: string,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) => {
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processLine = (
|
||||
line: string,
|
||||
context: {
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
exitToolIndex: () => number;
|
||||
setExitToolIndex: (val: number) => void;
|
||||
exitToolResponse: () => string;
|
||||
appendExitToolResponse: (content: string) => void;
|
||||
}
|
||||
) => {
|
||||
const {
|
||||
controller,
|
||||
encoder,
|
||||
exitToolIndex,
|
||||
setExitToolIndex,
|
||||
appendExitToolResponse,
|
||||
} = context;
|
||||
|
||||
if (
|
||||
line.startsWith("data: ") &&
|
||||
line.trim() !== "data: [DONE]"
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.choices[0]?.delta?.tool_calls?.length) {
|
||||
const toolCall = data.choices[0].delta.tool_calls[0];
|
||||
|
||||
if (toolCall.function?.name === "ExitTool") {
|
||||
setExitToolIndex(toolCall.index);
|
||||
return;
|
||||
} else if (
|
||||
exitToolIndex() > -1 &&
|
||||
toolCall.index === exitToolIndex() &&
|
||||
toolCall.function.arguments
|
||||
) {
|
||||
appendExitToolResponse(toolCall.function.arguments);
|
||||
try {
|
||||
const response = JSON.parse(context.exitToolResponse());
|
||||
data.choices = [
|
||||
{
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: response.response || "",
|
||||
},
|
||||
},
|
||||
];
|
||||
const modifiedLine = `data: ${JSON.stringify(
|
||||
data
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta &&
|
||||
Object.keys(data.choices[0].delta).length > 0
|
||||
) {
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
}
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, pass through the original line
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// Pass through non-data lines (like [DONE])
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (buffer.trim()) {
|
||||
processBuffer(buffer, controller, encoder);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
exitToolIndex: () => exitToolIndex,
|
||||
setExitToolIndex: (val) => (exitToolIndex = val),
|
||||
exitToolResponse: () => exitToolResponse,
|
||||
appendExitToolResponse: (content) =>
|
||||
(exitToolResponse += content),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// 如果解析失败,直接传递原始行
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
358
packages/core/src/transformer/vercel.transformer.ts
Normal file
358
packages/core/src/transformer/vercel.transformer.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { UnifiedChatRequest } from "@/types/llm";
|
||||
import { Transformer, TransformerOptions } from "../types/transformer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export class VercelTransformer implements Transformer {
|
||||
static TransformerName = "vercel";
|
||||
endPoint = "/v1/chat/completions";
|
||||
|
||||
constructor(private readonly options?: TransformerOptions) {}
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest
|
||||
): Promise<UnifiedChatRequest> {
|
||||
if (!request.model.includes("claude")) {
|
||||
request.messages.forEach((msg) => {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content.forEach((item: any) => {
|
||||
if (item.cache_control) {
|
||||
delete item.cache_control;
|
||||
}
|
||||
if (item.type === "image_url") {
|
||||
if (!item.image_url.url.startsWith("http")) {
|
||||
item.image_url.url = `data:${item.media_type};base64,${item.image_url.url}`;
|
||||
}
|
||||
delete item.media_type;
|
||||
}
|
||||
});
|
||||
} else if (msg.cache_control) {
|
||||
delete msg.cache_control;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
request.messages.forEach((msg) => {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content.forEach((item: any) => {
|
||||
if (item.type === "image_url") {
|
||||
if (!item.image_url.url.startsWith("http")) {
|
||||
item.image_url.url = `data:${item.media_type};base64,${item.image_url.url}`;
|
||||
}
|
||||
delete item.media_type;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Object.assign(request, this.options || {});
|
||||
return request;
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = await response.json();
|
||||
return new Response(JSON.stringify(jsonResponse), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let hasTextContent = false;
|
||||
let reasoningContent = "";
|
||||
let isReasoningComplete = false;
|
||||
let hasToolCall = false;
|
||||
let buffer = ""; // Buffer for incomplete data
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
const processBuffer = (
|
||||
buffer: string,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder
|
||||
) => {
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processLine = (
|
||||
line: string,
|
||||
context: {
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
hasTextContent: () => boolean;
|
||||
setHasTextContent: (val: boolean) => void;
|
||||
reasoningContent: () => string;
|
||||
appendReasoningContent: (content: string) => void;
|
||||
isReasoningComplete: () => boolean;
|
||||
setReasoningComplete: (val: boolean) => void;
|
||||
}
|
||||
) => {
|
||||
const { controller, encoder } = context;
|
||||
|
||||
if (line.startsWith("data: ") && line.trim() !== "data: [DONE]") {
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.usage) {
|
||||
this.logger?.debug(
|
||||
{ usage: data.usage, hasToolCall },
|
||||
"usage"
|
||||
);
|
||||
data.choices[0].finish_reason = hasToolCall
|
||||
? "tool_calls"
|
||||
: "stop";
|
||||
}
|
||||
|
||||
if (data.choices?.[0]?.finish_reason === "error") {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
error: data.choices?.[0].error,
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.content &&
|
||||
!context.hasTextContent()
|
||||
) {
|
||||
context.setHasTextContent(true);
|
||||
}
|
||||
|
||||
// Extract reasoning_content from delta
|
||||
if (data.choices?.[0]?.delta?.reasoning) {
|
||||
context.appendReasoningContent(
|
||||
data.choices[0].delta.reasoning
|
||||
);
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices?.[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
thinking: {
|
||||
content: data.choices[0].delta.reasoning,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
if (thinkingChunk.choices?.[0]?.delta) {
|
||||
delete thinkingChunk.choices[0].delta.reasoning;
|
||||
}
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if reasoning is complete
|
||||
if (
|
||||
data.choices?.[0]?.delta?.content &&
|
||||
context.reasoningContent() &&
|
||||
!context.isReasoningComplete()
|
||||
) {
|
||||
context.setReasoningComplete(true);
|
||||
const signature = Date.now().toString();
|
||||
|
||||
const thinkingChunk = {
|
||||
...data,
|
||||
choices: [
|
||||
{
|
||||
...data.choices?.[0],
|
||||
delta: {
|
||||
...data.choices[0].delta,
|
||||
content: null,
|
||||
thinking: {
|
||||
content: context.reasoningContent(),
|
||||
signature: signature,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
if (thinkingChunk.choices?.[0]?.delta) {
|
||||
delete thinkingChunk.choices[0].delta.reasoning;
|
||||
}
|
||||
const thinkingLine = `data: ${JSON.stringify(
|
||||
thinkingChunk
|
||||
)}\n\n`;
|
||||
controller.enqueue(encoder.encode(thinkingLine));
|
||||
}
|
||||
|
||||
if (data.choices?.[0]?.delta?.reasoning) {
|
||||
delete data.choices[0].delta.reasoning;
|
||||
}
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
!Number.isNaN(
|
||||
parseInt(data.choices?.[0]?.delta?.tool_calls[0].id, 10)
|
||||
)
|
||||
) {
|
||||
data.choices?.[0]?.delta?.tool_calls.forEach((tool: any) => {
|
||||
tool.id = `call_${uuidv4()}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
!hasToolCall
|
||||
) {
|
||||
hasToolCall = true;
|
||||
}
|
||||
|
||||
if (
|
||||
data.choices?.[0]?.delta?.tool_calls?.length &&
|
||||
context.hasTextContent()
|
||||
) {
|
||||
if (typeof data.choices[0].index === "number") {
|
||||
data.choices[0].index += 1;
|
||||
} else {
|
||||
data.choices[0].index = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedLine = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(modifiedLine));
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, data might be incomplete, pass through the original line
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
} else {
|
||||
// Pass through non-data lines (like [DONE])
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// Process remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
processBuffer(buffer, controller, encoder);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if value is valid
|
||||
if (!value || value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk;
|
||||
try {
|
||||
chunk = decoder.decode(value, { stream: true });
|
||||
} catch (decodeError) {
|
||||
console.warn("Failed to decode chunk", decodeError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += chunk;
|
||||
|
||||
// Process buffer if it gets too large to avoid memory leaks
|
||||
if (buffer.length > 1000000) {
|
||||
// 1MB limit
|
||||
console.warn(
|
||||
"Buffer size exceeds limit, processing partial data"
|
||||
);
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => (hasTextContent = val),
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) =>
|
||||
(isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// If parsing fails, pass through the original line
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process complete lines in buffer
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Last line might be incomplete, keep in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
processLine(line, {
|
||||
controller,
|
||||
encoder,
|
||||
hasTextContent: () => hasTextContent,
|
||||
setHasTextContent: (val) => (hasTextContent = val),
|
||||
reasoningContent: () => reasoningContent,
|
||||
appendReasoningContent: (content) =>
|
||||
(reasoningContent += content),
|
||||
isReasoningComplete: () => isReasoningComplete,
|
||||
setReasoningComplete: (val) => (isReasoningComplete = val),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing line:", line, error);
|
||||
// If parsing fails, pass through the original line
|
||||
controller.enqueue(encoder.encode(line + "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream error:", error);
|
||||
controller.error(error);
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
console.error("Error releasing reader lock:", e);
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
81
packages/core/src/transformer/vertex-claude.transformer.ts
Normal file
81
packages/core/src/transformer/vertex-claude.transformer.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { LLMProvider, UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
import {
|
||||
buildRequestBody,
|
||||
transformRequestOut,
|
||||
transformResponseOut,
|
||||
} from "../utils/vertex-claude.util";
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
try {
|
||||
const { GoogleAuth } = await import('google-auth-library');
|
||||
|
||||
const auth = new GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform']
|
||||
});
|
||||
|
||||
const client = await auth.getClient();
|
||||
const accessToken = await client.getAccessToken();
|
||||
return accessToken.token || '';
|
||||
} catch (error) {
|
||||
console.error('Error getting access token:', error);
|
||||
throw new Error('Failed to get access token for Vertex AI. Please ensure you have set up authentication using one of these methods:\n' +
|
||||
'1. Set GOOGLE_APPLICATION_CREDENTIALS to point to service account key file\n' +
|
||||
'2. Run "gcloud auth application-default login"\n' +
|
||||
'3. Use Google Cloud environment with default service account');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class VertexClaudeTransformer implements Transformer {
|
||||
name = "vertex-claude";
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest,
|
||||
provider: LLMProvider
|
||||
): Promise<Record<string, any>> {
|
||||
let projectId = process.env.GOOGLE_CLOUD_PROJECT;
|
||||
const location = process.env.GOOGLE_CLOUD_LOCATION || 'us-east5';
|
||||
|
||||
if (!projectId && process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
const keyContent = fs.readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8');
|
||||
const credentials = JSON.parse(keyContent);
|
||||
if (credentials && credentials.project_id) {
|
||||
projectId = credentials.project_id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting project_id from GOOGLE_APPLICATION_CREDENTIALS:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error('Project ID is required for Vertex AI. Set GOOGLE_CLOUD_PROJECT environment variable or ensure project_id is in GOOGLE_APPLICATION_CREDENTIALS file.');
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
return {
|
||||
body: buildRequestBody(request),
|
||||
config: {
|
||||
url: new URL(
|
||||
`/v1/projects/${projectId}/locations/${location}/publishers/anthropic/models/${request.model}:${request.stream ? "streamRawPredict" : "rawPredict"}`,
|
||||
`https://${location}-aiplatform.googleapis.com`
|
||||
).toString(),
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async transformRequestOut(request: Record<string, any>): Promise<UnifiedChatRequest> {
|
||||
return transformRequestOut(request);
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
return transformResponseOut(response, this.name, this.logger);
|
||||
}
|
||||
}
|
||||
79
packages/core/src/transformer/vertex-gemini.transformer.ts
Normal file
79
packages/core/src/transformer/vertex-gemini.transformer.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { LLMProvider, UnifiedChatRequest } from "../types/llm";
|
||||
import { Transformer } from "../types/transformer";
|
||||
import {
|
||||
buildRequestBody,
|
||||
transformRequestOut,
|
||||
transformResponseOut,
|
||||
} from "../utils/gemini.util";
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
try {
|
||||
const { GoogleAuth } = await import('google-auth-library');
|
||||
|
||||
const auth = new GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform']
|
||||
});
|
||||
|
||||
const client = await auth.getClient();
|
||||
const accessToken = await client.getAccessToken();
|
||||
return accessToken.token || '';
|
||||
} catch (error) {
|
||||
console.error('Error getting access token:', error);
|
||||
throw new Error('Failed to get access token for Vertex AI. Please ensure you have set up authentication using one of these methods:\n' +
|
||||
'1. Set GOOGLE_APPLICATION_CREDENTIALS to point to service account key file\n' +
|
||||
'2. Run "gcloud auth application-default login"\n' +
|
||||
'3. Use Google Cloud environment with default service account');
|
||||
}
|
||||
}
|
||||
|
||||
export class VertexGeminiTransformer implements Transformer {
|
||||
name = "vertex-gemini";
|
||||
|
||||
async transformRequestIn(
|
||||
request: UnifiedChatRequest,
|
||||
provider: LLMProvider
|
||||
): Promise<Record<string, any>> {
|
||||
let projectId = process.env.GOOGLE_CLOUD_PROJECT;
|
||||
const location = process.env.GOOGLE_CLOUD_LOCATION || 'us-central1';
|
||||
|
||||
if (!projectId && process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
const keyContent = fs.readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8');
|
||||
const credentials = JSON.parse(keyContent);
|
||||
if (credentials && credentials.project_id) {
|
||||
projectId = credentials.project_id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting project_id from GOOGLE_APPLICATION_CREDENTIALS:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error('Project ID is required for Vertex AI. Set GOOGLE_CLOUD_PROJECT environment variable or ensure project_id is in GOOGLE_APPLICATION_CREDENTIALS file.');
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
return {
|
||||
body: buildRequestBody(request),
|
||||
config: {
|
||||
url: new URL(
|
||||
`./v1beta1/projects/${projectId}/locations/${location}/publishers/google/models/${request.model}:${request.stream ? "streamGenerateContent" : "generateContent"}`,
|
||||
provider.baseUrl.endsWith('/') ? provider.baseUrl : provider.baseUrl + '/' || `https://${location}-aiplatform.googleapis.com`
|
||||
),
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"x-goog-api-key": undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async transformRequestOut(request: Record<string, any>): Promise<UnifiedChatRequest> {
|
||||
return transformRequestOut(request);
|
||||
}
|
||||
|
||||
async transformResponseOut(response: Response): Promise<Response> {
|
||||
return transformResponseOut(response, this.name);
|
||||
}
|
||||
}
|
||||
239
packages/core/src/types/llm.ts
Normal file
239
packages/core/src/types/llm.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { ChatCompletionMessageParam as OpenAIMessage } from "openai/resources/chat/completions";
|
||||
import type { MessageParam as AnthropicMessage } from "@anthropic-ai/sdk/resources/messages";
|
||||
import type {
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
} from "openai/resources/chat/completions";
|
||||
import type {
|
||||
Message,
|
||||
MessageStreamEvent,
|
||||
} from "@anthropic-ai/sdk/resources/messages";
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions";
|
||||
import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages";
|
||||
import { Transformer } from "./transformer";
|
||||
|
||||
export interface UrlCitation {
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
}
|
||||
export interface Annotation {
|
||||
type: "url_citation";
|
||||
url_citation?: UrlCitation;
|
||||
}
|
||||
|
||||
// 内容类型定义
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
cache_control?: {
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image_url";
|
||||
image_url: {
|
||||
url: string;
|
||||
};
|
||||
media_type: string;
|
||||
}
|
||||
|
||||
export type MessageContent = TextContent | ImageContent;
|
||||
|
||||
// 统一的消息接口
|
||||
export interface UnifiedMessage {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string | null | MessageContent[];
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
tool_call_id?: string;
|
||||
cache_control?: {
|
||||
type?: string;
|
||||
};
|
||||
thinking?: {
|
||||
content: string;
|
||||
signature?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 统一的工具定义接口
|
||||
export interface UnifiedTool {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
$schema?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type ThinkLevel = "none" | "low" | "medium" | "high";
|
||||
|
||||
// 统一的请求接口
|
||||
export interface UnifiedChatRequest {
|
||||
messages: UnifiedMessage[];
|
||||
model: string;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
stream?: boolean;
|
||||
tools?: UnifiedTool[];
|
||||
tool_choice?:
|
||||
| "auto"
|
||||
| "none"
|
||||
| "required"
|
||||
| string
|
||||
| { type: "function"; function: { name: string } };
|
||||
reasoning?: {
|
||||
// OpenAI-style
|
||||
effort?: ThinkLevel;
|
||||
|
||||
// Anthropic-style
|
||||
max_tokens?: number;
|
||||
|
||||
enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 统一的响应接口
|
||||
export interface UnifiedChatResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
content: string | null;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
// 流式响应相关类型
|
||||
export interface StreamChunk {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices?: Array<{
|
||||
index: number;
|
||||
delta: {
|
||||
role?: string;
|
||||
content?: string;
|
||||
thinking?: {
|
||||
content?: string;
|
||||
signature?: string;
|
||||
};
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
type?: "function";
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
}>;
|
||||
annotations?: Annotation[];
|
||||
};
|
||||
finish_reason?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Anthropic 流式事件类型
|
||||
export type AnthropicStreamEvent = MessageStreamEvent;
|
||||
|
||||
// OpenAI 流式块类型
|
||||
export type OpenAIStreamChunk = ChatCompletionChunk;
|
||||
|
||||
// OpenAI 特定类型
|
||||
export interface OpenAIChatRequest {
|
||||
messages: OpenAIMessage[];
|
||||
model: string;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
stream?: boolean;
|
||||
tools?: ChatCompletionTool[];
|
||||
tool_choice?:
|
||||
| "auto"
|
||||
| "none"
|
||||
| { type: "function"; function: { name: string } };
|
||||
}
|
||||
|
||||
// Anthropic 特定类型
|
||||
export interface AnthropicChatRequest {
|
||||
messages: AnthropicMessage[];
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
temperature?: number;
|
||||
stream?: boolean;
|
||||
system?: string;
|
||||
tools?: AnthropicTool[];
|
||||
tool_choice?: { type: "auto" } | { type: "tool"; name: string };
|
||||
}
|
||||
|
||||
// 转换选项
|
||||
export interface ConversionOptions {
|
||||
targetProvider: "openai" | "anthropic";
|
||||
sourceProvider: "openai" | "anthropic";
|
||||
}
|
||||
|
||||
export interface LLMProvider {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: string[];
|
||||
transformer?: {
|
||||
[key: string]: {
|
||||
use?: Transformer[];
|
||||
};
|
||||
} & {
|
||||
use?: Transformer[];
|
||||
};
|
||||
}
|
||||
|
||||
export type RegisterProviderRequest = LLMProvider;
|
||||
|
||||
export interface ModelRoute {
|
||||
provider: string;
|
||||
model: string;
|
||||
fullModel: string;
|
||||
}
|
||||
|
||||
export interface RequestRouteInfo {
|
||||
provider: LLMProvider;
|
||||
originalModel: string;
|
||||
targetModel: string;
|
||||
}
|
||||
|
||||
export interface ConfigProvider {
|
||||
name: string;
|
||||
api_base_url: string;
|
||||
api_key: string;
|
||||
models: string[];
|
||||
transformer: {
|
||||
use?: string[] | Array<any>[];
|
||||
} & {
|
||||
[key: string]: {
|
||||
use?: string[] | Array<any>[];
|
||||
};
|
||||
};
|
||||
}
|
||||
43
packages/core/src/types/transformer.ts
Normal file
43
packages/core/src/types/transformer.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { LLMProvider, UnifiedChatRequest } from "./llm";
|
||||
|
||||
export interface TransformerOptions {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface TransformerWithStaticName {
|
||||
new (options?: TransformerOptions): Transformer;
|
||||
TransformerName?: string;
|
||||
}
|
||||
|
||||
|
||||
interface TransformerWithInstanceName {
|
||||
new (): Transformer;
|
||||
name?: never;
|
||||
}
|
||||
|
||||
export type TransformerConstructor = TransformerWithStaticName;
|
||||
|
||||
export interface TransformerContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type Transformer = {
|
||||
transformRequestIn?: (
|
||||
request: UnifiedChatRequest,
|
||||
provider: LLMProvider,
|
||||
context: TransformerContext,
|
||||
) => Promise<Record<string, any>>;
|
||||
transformResponseIn?: (response: Response, context?: TransformerContext) => Promise<Response>;
|
||||
|
||||
// 将请求格式转换为通用的格式
|
||||
transformRequestOut?: (request: any, context: TransformerContext) => Promise<UnifiedChatRequest>;
|
||||
// 将相应格式转换为通用的格式
|
||||
transformResponseOut?: (response: Response, context: TransformerContext) => Promise<Response>;
|
||||
|
||||
endPoint?: string;
|
||||
name?: string;
|
||||
auth?: (request: any, provider: LLMProvider, context: TransformerContext) => Promise<any>;
|
||||
|
||||
// Logger for transformer
|
||||
logger?: any;
|
||||
};
|
||||
478
packages/core/src/utils/converter.ts
Normal file
478
packages/core/src/utils/converter.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import type { ChatCompletionMessageParam as OpenAIMessage } from "openai/resources/chat/completions";
|
||||
import type { MessageParam as AnthropicMessage } from "@anthropic-ai/sdk/resources/messages";
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions";
|
||||
import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages";
|
||||
import {
|
||||
UnifiedMessage,
|
||||
UnifiedChatRequest,
|
||||
UnifiedTool,
|
||||
OpenAIChatRequest,
|
||||
AnthropicChatRequest,
|
||||
ConversionOptions,
|
||||
} from "../types/llm";
|
||||
|
||||
// Simple logger function
|
||||
function log(...args: any[]) {
|
||||
// Can be extended to use a proper logger
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
export function convertToolsToOpenAI(
|
||||
tools: UnifiedTool[]
|
||||
): ChatCompletionTool[] {
|
||||
return tools.map((tool) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function convertToolsToAnthropic(tools: UnifiedTool[]): AnthropicTool[] {
|
||||
return tools.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
export function convertToolsFromOpenAI(
|
||||
tools: ChatCompletionTool[]
|
||||
): UnifiedTool[] {
|
||||
return tools.map((tool) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description || "",
|
||||
parameters: tool.function.parameters as any,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function convertToolsFromAnthropic(
|
||||
tools: AnthropicTool[]
|
||||
): UnifiedTool[] {
|
||||
return tools.map((tool) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: tool.input_schema as any,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function convertToOpenAI(
|
||||
request: UnifiedChatRequest
|
||||
): OpenAIChatRequest {
|
||||
const messages: OpenAIMessage[] = [];
|
||||
const toolResponsesQueue: Map<string, any> = new Map(); // 用于存储工具响应
|
||||
|
||||
request.messages.forEach((msg) => {
|
||||
if (msg.role === "tool" && msg.tool_call_id) {
|
||||
if (!toolResponsesQueue.has(msg.tool_call_id)) {
|
||||
toolResponsesQueue.set(msg.tool_call_id, []);
|
||||
}
|
||||
toolResponsesQueue.get(msg.tool_call_id).push({
|
||||
role: "tool",
|
||||
content: msg.content,
|
||||
tool_call_id: msg.tool_call_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < request.messages.length; i++) {
|
||||
const msg = request.messages[i];
|
||||
|
||||
if (msg.role === "tool") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const message: any = {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
};
|
||||
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
message.tool_calls = msg.tool_calls;
|
||||
if (message.content === null) {
|
||||
message.content = null;
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(message);
|
||||
|
||||
if (
|
||||
msg.role === "assistant" &&
|
||||
msg.tool_calls &&
|
||||
msg.tool_calls.length > 0
|
||||
) {
|
||||
for (const toolCall of msg.tool_calls) {
|
||||
if (toolResponsesQueue.has(toolCall.id)) {
|
||||
const responses = toolResponsesQueue.get(toolCall.id);
|
||||
|
||||
responses.forEach((response) => {
|
||||
messages.push(response);
|
||||
});
|
||||
|
||||
toolResponsesQueue.delete(toolCall.id);
|
||||
} else {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
success: true,
|
||||
message: "Tool call executed successfully",
|
||||
tool_call_id: toolCall.id,
|
||||
}),
|
||||
tool_call_id: toolCall.id,
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolResponsesQueue.size > 0) {
|
||||
for (const [id, responses] of toolResponsesQueue.entries()) {
|
||||
responses.forEach((response) => {
|
||||
messages.push(response);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
messages,
|
||||
model: request.model,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
stream: request.stream,
|
||||
};
|
||||
|
||||
if (request.tools && request.tools.length > 0) {
|
||||
result.tools = convertToolsToOpenAI(request.tools);
|
||||
if (request.tool_choice) {
|
||||
if (request.tool_choice === "auto" || request.tool_choice === "none") {
|
||||
result.tool_choice = request.tool_choice;
|
||||
} else {
|
||||
result.tool_choice = {
|
||||
type: "function",
|
||||
function: { name: request.tool_choice },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function isToolCallContent(content: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.some((item) => item.type === "tool_use" && item.id && item.name)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertFromOpenAI(
|
||||
request: OpenAIChatRequest
|
||||
): UnifiedChatRequest {
|
||||
const messages: UnifiedMessage[] = request.messages.map((msg) => {
|
||||
if (
|
||||
msg.role === "assistant" &&
|
||||
typeof msg.content === "string" &&
|
||||
isToolCallContent(msg.content)
|
||||
) {
|
||||
try {
|
||||
const toolCalls = JSON.parse(msg.content);
|
||||
const convertedToolCalls = toolCalls.map((call: any) => ({
|
||||
id: call.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.input || {}),
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
role: msg.role as "user" | "assistant" | "system",
|
||||
content: null,
|
||||
tool_calls: convertedToolCalls,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
role: msg.role as "user" | "assistant" | "system",
|
||||
content: msg.content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
return {
|
||||
role: msg.role as "tool",
|
||||
content:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content),
|
||||
tool_call_id: (msg as any).tool_call_id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: msg.role as "user" | "assistant" | "system",
|
||||
content:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content),
|
||||
...((msg as any).tool_calls && { tool_calls: (msg as any).tool_calls }),
|
||||
};
|
||||
});
|
||||
|
||||
const result: UnifiedChatRequest = {
|
||||
messages,
|
||||
model: request.model,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
stream: request.stream,
|
||||
};
|
||||
|
||||
if (request.tools && request.tools.length > 0) {
|
||||
result.tools = convertToolsFromOpenAI(request.tools);
|
||||
|
||||
if (request.tool_choice) {
|
||||
if (typeof request.tool_choice === "string") {
|
||||
result.tool_choice = request.tool_choice;
|
||||
} else if (request.tool_choice.type === "function") {
|
||||
result.tool_choice = request.tool_choice.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertFromAnthropic(
|
||||
request: AnthropicChatRequest
|
||||
): UnifiedChatRequest {
|
||||
const messages: UnifiedMessage[] = [];
|
||||
|
||||
if (request.system) {
|
||||
messages.push({
|
||||
role: "system",
|
||||
content: request.system,
|
||||
});
|
||||
}
|
||||
const pendingToolCalls: any[] = [];
|
||||
const pendingTextContent: string[] = [];
|
||||
let lastRole: string | null = null;
|
||||
|
||||
for (let i = 0; i < request.messages.length; i++) {
|
||||
const msg = request.messages[i];
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
if (
|
||||
lastRole === "assistant" &&
|
||||
pendingToolCalls.length > 0 &&
|
||||
msg.role !== "assistant"
|
||||
) {
|
||||
const assistantMessage: UnifiedMessage = {
|
||||
role: "assistant",
|
||||
content: pendingTextContent.join("") || null,
|
||||
tool_calls:
|
||||
pendingToolCalls.length > 0 ? pendingToolCalls : undefined,
|
||||
};
|
||||
if (assistantMessage.tool_calls && pendingTextContent.length === 0) {
|
||||
assistantMessage.content = null;
|
||||
}
|
||||
messages.push(assistantMessage);
|
||||
pendingToolCalls.length = 0;
|
||||
pendingTextContent.length = 0;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
});
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
const textBlocks: string[] = [];
|
||||
const toolCalls: any[] = [];
|
||||
const toolResults: any[] = [];
|
||||
|
||||
msg.content.forEach((block) => {
|
||||
if (block.type === "text") {
|
||||
textBlocks.push(block.text);
|
||||
} else if (block.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input || {}),
|
||||
},
|
||||
});
|
||||
} else if (block.type === "tool_result") {
|
||||
toolResults.push(block);
|
||||
}
|
||||
});
|
||||
|
||||
if (toolResults.length > 0) {
|
||||
if (lastRole === "assistant" && pendingToolCalls.length > 0) {
|
||||
const assistantMessage: UnifiedMessage = {
|
||||
role: "assistant",
|
||||
content: pendingTextContent.join("") || null,
|
||||
tool_calls: pendingToolCalls,
|
||||
};
|
||||
if (pendingTextContent.length === 0) {
|
||||
assistantMessage.content = null;
|
||||
}
|
||||
messages.push(assistantMessage);
|
||||
pendingToolCalls.length = 0;
|
||||
pendingTextContent.length = 0;
|
||||
}
|
||||
|
||||
toolResults.forEach((toolResult) => {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content:
|
||||
typeof toolResult.content === "string"
|
||||
? toolResult.content
|
||||
: JSON.stringify(toolResult.content),
|
||||
tool_call_id: toolResult.tool_use_id,
|
||||
});
|
||||
});
|
||||
} else if (msg.role === "assistant") {
|
||||
if (lastRole === "assistant") {
|
||||
pendingToolCalls.push(...toolCalls);
|
||||
pendingTextContent.push(...textBlocks);
|
||||
} else {
|
||||
if (pendingToolCalls.length > 0) {
|
||||
const prevAssistantMessage: UnifiedMessage = {
|
||||
role: "assistant",
|
||||
content: pendingTextContent.join("") || null,
|
||||
tool_calls: pendingToolCalls,
|
||||
};
|
||||
if (pendingTextContent.length === 0) {
|
||||
prevAssistantMessage.content = null;
|
||||
}
|
||||
messages.push(prevAssistantMessage);
|
||||
}
|
||||
|
||||
pendingToolCalls.length = 0;
|
||||
pendingTextContent.length = 0;
|
||||
pendingToolCalls.push(...toolCalls);
|
||||
pendingTextContent.push(...textBlocks);
|
||||
}
|
||||
} else {
|
||||
if (lastRole === "assistant" && pendingToolCalls.length > 0) {
|
||||
const assistantMessage: UnifiedMessage = {
|
||||
role: "assistant",
|
||||
content: pendingTextContent.join("") || null,
|
||||
tool_calls: pendingToolCalls,
|
||||
};
|
||||
if (pendingTextContent.length === 0) {
|
||||
assistantMessage.content = null;
|
||||
}
|
||||
messages.push(assistantMessage);
|
||||
pendingToolCalls.length = 0;
|
||||
pendingTextContent.length = 0;
|
||||
}
|
||||
|
||||
const message: UnifiedMessage = {
|
||||
role: msg.role,
|
||||
content: textBlocks.join("") || null,
|
||||
};
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
if (textBlocks.length === 0) {
|
||||
message.content = null;
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(message);
|
||||
}
|
||||
} else {
|
||||
if (lastRole === "assistant" && pendingToolCalls.length > 0) {
|
||||
const assistantMessage: UnifiedMessage = {
|
||||
role: "assistant",
|
||||
content: pendingTextContent.join("") || null,
|
||||
tool_calls: pendingToolCalls,
|
||||
};
|
||||
if (pendingTextContent.length === 0) {
|
||||
assistantMessage.content = null;
|
||||
}
|
||||
messages.push(assistantMessage);
|
||||
pendingToolCalls.length = 0;
|
||||
pendingTextContent.length = 0;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: msg.role,
|
||||
content: JSON.stringify(msg.content),
|
||||
});
|
||||
}
|
||||
|
||||
lastRole = msg.role;
|
||||
}
|
||||
|
||||
if (lastRole === "assistant" && pendingToolCalls.length > 0) {
|
||||
const assistantMessage: UnifiedMessage = {
|
||||
role: "assistant",
|
||||
content: pendingTextContent.join("") || null,
|
||||
tool_calls: pendingToolCalls,
|
||||
};
|
||||
if (pendingTextContent.length === 0) {
|
||||
assistantMessage.content = null;
|
||||
}
|
||||
messages.push(assistantMessage);
|
||||
}
|
||||
|
||||
const result: UnifiedChatRequest = {
|
||||
messages,
|
||||
model: request.model,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
stream: request.stream,
|
||||
};
|
||||
|
||||
if (request.tools && request.tools.length > 0) {
|
||||
result.tools = convertToolsFromAnthropic(request.tools);
|
||||
|
||||
if (request.tool_choice) {
|
||||
if (request.tool_choice.type === "auto") {
|
||||
result.tool_choice = "auto";
|
||||
} else if (request.tool_choice.type === "tool") {
|
||||
result.tool_choice = request.tool_choice.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertRequest(
|
||||
request: OpenAIChatRequest | AnthropicChatRequest | UnifiedChatRequest,
|
||||
options: ConversionOptions
|
||||
): OpenAIChatRequest | AnthropicChatRequest {
|
||||
let unifiedRequest: UnifiedChatRequest;
|
||||
if (options.sourceProvider === "openai") {
|
||||
unifiedRequest = convertFromOpenAI(request as OpenAIChatRequest);
|
||||
} else if (options.sourceProvider === "anthropic") {
|
||||
unifiedRequest = convertFromAnthropic(request as AnthropicChatRequest);
|
||||
} else {
|
||||
unifiedRequest = request as UnifiedChatRequest;
|
||||
}
|
||||
|
||||
if (options.targetProvider === "openai") {
|
||||
return convertToOpenAI(unifiedRequest);
|
||||
} else {
|
||||
// For now, return unified request since Anthropic format is similar
|
||||
return unifiedRequest as any;
|
||||
}
|
||||
}
|
||||
1044
packages/core/src/utils/gemini.util.ts
Normal file
1044
packages/core/src/utils/gemini.util.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/core/src/utils/image.ts
Normal file
9
packages/core/src/utils/image.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const formatBase64 = (data: string, media_type: string) => {
|
||||
if (data.includes("base64")) {
|
||||
data = data.split("base64").pop() as string;
|
||||
if (data.startsWith(",")) {
|
||||
data = data.slice(1);
|
||||
}
|
||||
}
|
||||
return `data:${media_type};base64,${data}`;
|
||||
};
|
||||
57
packages/core/src/utils/request.ts
Normal file
57
packages/core/src/utils/request.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ProxyAgent } from "undici";
|
||||
import { UnifiedChatRequest } from "../types/llm";
|
||||
|
||||
export function sendUnifiedRequest(
|
||||
url: URL | string,
|
||||
request: UnifiedChatRequest,
|
||||
config: any,
|
||||
context: any,
|
||||
logger?: any
|
||||
): Promise<Response> {
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
if (config.headers) {
|
||||
Object.entries(config.headers).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
headers.set(key, value as string);
|
||||
}
|
||||
});
|
||||
}
|
||||
let combinedSignal: AbortSignal;
|
||||
const timeoutSignal = AbortSignal.timeout(config.TIMEOUT ?? 60 * 1000 * 60);
|
||||
|
||||
if (config.signal) {
|
||||
const controller = new AbortController();
|
||||
const abortHandler = () => controller.abort();
|
||||
config.signal.addEventListener("abort", abortHandler);
|
||||
timeoutSignal.addEventListener("abort", abortHandler);
|
||||
combinedSignal = controller.signal;
|
||||
} else {
|
||||
combinedSignal = timeoutSignal;
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(request),
|
||||
signal: combinedSignal,
|
||||
};
|
||||
|
||||
if (config.httpsProxy) {
|
||||
(fetchOptions as any).dispatcher = new ProxyAgent(
|
||||
new URL(config.httpsProxy).toString()
|
||||
);
|
||||
}
|
||||
logger?.debug(
|
||||
{
|
||||
reqId: context.req.id,
|
||||
request: fetchOptions,
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
requestUrl: typeof url === "string" ? url : url.toString(),
|
||||
useProxy: config.httpsProxy,
|
||||
},
|
||||
"final request"
|
||||
);
|
||||
return fetch(typeof url === "string" ? url : url.toString(), fetchOptions);
|
||||
}
|
||||
8
packages/core/src/utils/thinking.ts
Normal file
8
packages/core/src/utils/thinking.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ThinkLevel } from "@/types/llm";
|
||||
|
||||
export const getThinkLevel = (thinking_budget: number): ThinkLevel => {
|
||||
if (thinking_budget <= 0) return "none";
|
||||
if (thinking_budget <= 1024) return "low";
|
||||
if (thinking_budget <= 8192) return "medium";
|
||||
return "high";
|
||||
};
|
||||
51
packages/core/src/utils/toolArgumentsParser.ts
Normal file
51
packages/core/src/utils/toolArgumentsParser.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import JSON5 from "json5";
|
||||
import { jsonrepair } from "jsonrepair";
|
||||
|
||||
/**
|
||||
* 解析工具调用参数的函数
|
||||
* Parse tool call arguments function
|
||||
* 先尝试标准JSON解析,然后JSON5解析,最后使用jsonrepair进行安全修复
|
||||
* First try standard JSON parsing, then JSON5 parsing, finally use jsonrepair for safe repair
|
||||
*
|
||||
* @param argsString - 需要解析的参数字符串 / Parameter string to parse
|
||||
* @returns 解析后的参数对象或安全的空对象 / Parsed parameter object or safe empty object
|
||||
*/
|
||||
export function parseToolArguments(argsString: string, logger?: any): string {
|
||||
// Handle empty or null input
|
||||
if (!argsString || argsString.trim() === "" || argsString === "{}") {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
try {
|
||||
// First attempt: Standard JSON parsing
|
||||
JSON.parse(argsString);
|
||||
logger?.debug(`工具调用参数标准JSON解析成功 / Tool arguments standard JSON parsing successful`);
|
||||
return argsString;
|
||||
} catch (jsonError: any) {
|
||||
try {
|
||||
// Second attempt: JSON5 parsing for relaxed syntax
|
||||
const args = JSON5.parse(argsString);
|
||||
logger?.debug(`工具调用参数JSON5解析成功 / Tool arguments JSON5 parsing successful`);
|
||||
return JSON.stringify(args);
|
||||
} catch (json5Error: any) {
|
||||
try {
|
||||
// Third attempt: Safe JSON repair without code execution
|
||||
const repairedJson = jsonrepair(argsString);
|
||||
logger?.debug(`工具调用参数安全修复成功 / Tool arguments safely repaired`);
|
||||
return repairedJson;
|
||||
} catch (repairError: any) {
|
||||
// All parsing attempts failed - log errors and return safe fallback
|
||||
logger?.error(
|
||||
`JSON解析失败 / JSON parsing failed: ${jsonError.message}. ` +
|
||||
`JSON5解析失败 / JSON5 parsing failed: ${json5Error.message}. ` +
|
||||
`JSON修复失败 / JSON repair failed: ${repairError.message}. ` +
|
||||
`输入数据 / Input data: ${JSON.stringify(argsString)}`
|
||||
);
|
||||
|
||||
// Return safe empty object as fallback instead of potentially malformed input
|
||||
logger?.debug(`返回安全的空对象作为后备方案 / Returning safe empty object as fallback`);
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
542
packages/core/src/utils/vertex-claude.util.ts
Normal file
542
packages/core/src/utils/vertex-claude.util.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import { UnifiedChatRequest, UnifiedMessage, UnifiedTool } from "../types/llm";
|
||||
|
||||
// Vertex Claude消息接口
|
||||
interface ClaudeMessage {
|
||||
role: "user" | "assistant";
|
||||
content: Array<{
|
||||
type: "text" | "image";
|
||||
text?: string;
|
||||
source?: {
|
||||
type: "base64";
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
// Vertex Claude工具接口
|
||||
interface ClaudeTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: {
|
||||
type: string;
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
$schema?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Vertex Claude请求接口
|
||||
interface VertexClaudeRequest {
|
||||
anthropic_version: "vertex-2023-10-16";
|
||||
messages: ClaudeMessage[];
|
||||
max_tokens: number;
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
tools?: ClaudeTool[];
|
||||
tool_choice?: "auto" | "none" | { type: "tool"; name: string };
|
||||
}
|
||||
|
||||
// Vertex Claude响应接口
|
||||
interface VertexClaudeResponse {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
id: string;
|
||||
model: string;
|
||||
role: "assistant";
|
||||
stop_reason: string;
|
||||
stop_sequence: null;
|
||||
type: "message";
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
tool_use?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, any>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function buildRequestBody(
|
||||
request: UnifiedChatRequest
|
||||
): VertexClaudeRequest {
|
||||
const messages: ClaudeMessage[] = [];
|
||||
|
||||
for (let i = 0; i < request.messages.length; i++) {
|
||||
const message = request.messages[i];
|
||||
const isLastMessage = i === request.messages.length - 1;
|
||||
const isAssistantMessage = message.role === "assistant";
|
||||
|
||||
const content: ClaudeMessage["content"] = [];
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// 保留所有字符串内容,即使是空字符串,因为可能包含重要信息
|
||||
content.push({
|
||||
type: "text",
|
||||
text: message.content,
|
||||
});
|
||||
} else if (Array.isArray(message.content)) {
|
||||
message.content.forEach((item) => {
|
||||
if (item.type === "text") {
|
||||
// 保留所有文本内容,即使是空字符串
|
||||
content.push({
|
||||
type: "text",
|
||||
text: item.text || "",
|
||||
});
|
||||
} else if (item.type === "image_url") {
|
||||
// 处理图片内容
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: item.media_type || "image/jpeg",
|
||||
data: item.image_url.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 只跳过完全空的非最后一条消息(没有内容和工具调用)
|
||||
if (
|
||||
!isLastMessage &&
|
||||
content.length === 0 &&
|
||||
!message.tool_calls &&
|
||||
!message.content
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对于最后一条 assistant 消息,如果没有内容但有工具调用,则添加空内容
|
||||
if (
|
||||
isLastMessage &&
|
||||
isAssistantMessage &&
|
||||
content.length === 0 &&
|
||||
message.tool_calls
|
||||
) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: message.role === "assistant" ? "assistant" : "user",
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
const requestBody: VertexClaudeRequest = {
|
||||
anthropic_version: "vertex-2023-10-16",
|
||||
messages,
|
||||
max_tokens: request.max_tokens || 1000,
|
||||
stream: request.stream || false,
|
||||
...(request.temperature && { temperature: request.temperature }),
|
||||
};
|
||||
|
||||
// 处理工具定义
|
||||
if (request.tools && request.tools.length > 0) {
|
||||
requestBody.tools = request.tools.map((tool: UnifiedTool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
// 处理工具选择
|
||||
if (request.tool_choice) {
|
||||
if (request.tool_choice === "auto" || request.tool_choice === "none") {
|
||||
requestBody.tool_choice = request.tool_choice;
|
||||
} else if (typeof request.tool_choice === "string") {
|
||||
// 如果 tool_choice 是字符串,假设是工具名称
|
||||
requestBody.tool_choice = {
|
||||
type: "tool",
|
||||
name: request.tool_choice,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
export function transformRequestOut(
|
||||
request: Record<string, any>
|
||||
): UnifiedChatRequest {
|
||||
const vertexRequest = request as VertexClaudeRequest;
|
||||
|
||||
const messages: UnifiedMessage[] = vertexRequest.messages.map((msg) => {
|
||||
const content = msg.content.map((item) => {
|
||||
if (item.type === "text") {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: item.text || "",
|
||||
};
|
||||
} else if (item.type === "image" && item.source) {
|
||||
return {
|
||||
type: "image_url" as const,
|
||||
image_url: {
|
||||
url: item.source.data,
|
||||
},
|
||||
media_type: item.source.media_type,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: "",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
role: msg.role,
|
||||
content,
|
||||
};
|
||||
});
|
||||
|
||||
const result: UnifiedChatRequest = {
|
||||
messages,
|
||||
model: request.model || "claude-sonnet-4@20250514",
|
||||
max_tokens: vertexRequest.max_tokens,
|
||||
temperature: vertexRequest.temperature,
|
||||
stream: vertexRequest.stream,
|
||||
};
|
||||
|
||||
// 处理工具定义
|
||||
if (vertexRequest.tools && vertexRequest.tools.length > 0) {
|
||||
result.tools = vertexRequest.tools.map((tool) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: tool.input_schema.properties,
|
||||
required: tool.input_schema.required,
|
||||
additionalProperties: tool.input_schema.additionalProperties,
|
||||
$schema: tool.input_schema.$schema,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// 处理工具选择
|
||||
if (vertexRequest.tool_choice) {
|
||||
if (typeof vertexRequest.tool_choice === "string") {
|
||||
result.tool_choice = vertexRequest.tool_choice;
|
||||
} else if (vertexRequest.tool_choice.type === "tool") {
|
||||
result.tool_choice = vertexRequest.tool_choice.name;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function transformResponseOut(
|
||||
response: Response,
|
||||
providerName: string,
|
||||
logger?: any
|
||||
): Promise<Response> {
|
||||
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const jsonResponse = (await response.json()) as VertexClaudeResponse;
|
||||
|
||||
// 处理工具调用
|
||||
let tool_calls = undefined;
|
||||
if (jsonResponse.tool_use && jsonResponse.tool_use.length > 0) {
|
||||
tool_calls = jsonResponse.tool_use.map((tool) => ({
|
||||
id: tool.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tool.name,
|
||||
arguments: JSON.stringify(tool.input),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// 转换为OpenAI格式的响应
|
||||
const res = {
|
||||
id: jsonResponse.id,
|
||||
choices: [
|
||||
{
|
||||
finish_reason: jsonResponse.stop_reason || null,
|
||||
index: 0,
|
||||
message: {
|
||||
content: jsonResponse.content[0]?.text || "",
|
||||
role: "assistant",
|
||||
...(tool_calls && { tool_calls }),
|
||||
},
|
||||
},
|
||||
],
|
||||
created: parseInt(new Date().getTime() / 1000 + "", 10),
|
||||
model: jsonResponse.model,
|
||||
object: "chat.completion",
|
||||
usage: {
|
||||
completion_tokens: jsonResponse.usage.output_tokens,
|
||||
prompt_tokens: jsonResponse.usage.input_tokens,
|
||||
total_tokens:
|
||||
jsonResponse.usage.input_tokens + jsonResponse.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(res), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} else if (response.headers.get("Content-Type")?.includes("stream")) {
|
||||
// 处理流式响应
|
||||
if (!response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const processLine = (
|
||||
line: string,
|
||||
controller: ReadableStreamDefaultController
|
||||
) => {
|
||||
if (line.startsWith("data: ")) {
|
||||
const chunkStr = line.slice(6).trim();
|
||||
if (chunkStr) {
|
||||
logger?.debug({ chunkStr }, `${providerName} chunk:`);
|
||||
try {
|
||||
const chunk = JSON.parse(chunkStr);
|
||||
|
||||
// 处理 Anthropic 原生格式的流式响应
|
||||
if (
|
||||
chunk.type === "content_block_delta" &&
|
||||
chunk.delta?.type === "text_delta"
|
||||
) {
|
||||
// 这是 Anthropic 原生格式,需要转换为 OpenAI 格式
|
||||
const res = {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: chunk.delta.text || "",
|
||||
},
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
created: parseInt(new Date().getTime() / 1000 + "", 10),
|
||||
id: chunk.id || "",
|
||||
model: chunk.model || "",
|
||||
object: "chat.completion.chunk",
|
||||
system_fingerprint: "fp_a49d71b8a1",
|
||||
usage: {
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
total_tokens:
|
||||
(chunk.usage?.input_tokens || 0) +
|
||||
(chunk.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(res)}\n\n`)
|
||||
);
|
||||
} else if (
|
||||
chunk.type === "content_block_delta" &&
|
||||
chunk.delta?.type === "input_json_delta"
|
||||
) {
|
||||
// 处理工具调用的参数增量
|
||||
const res = {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: chunk.index || 0,
|
||||
function: {
|
||||
arguments: chunk.delta.partial_json || "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
created: parseInt(new Date().getTime() / 1000 + "", 10),
|
||||
id: chunk.id || "",
|
||||
model: chunk.model || "",
|
||||
object: "chat.completion.chunk",
|
||||
system_fingerprint: "fp_a49d71b8a1",
|
||||
usage: {
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
total_tokens:
|
||||
(chunk.usage?.input_tokens || 0) +
|
||||
(chunk.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(res)}\n\n`)
|
||||
);
|
||||
} else if (
|
||||
chunk.type === "content_block_start" &&
|
||||
chunk.content_block?.type === "tool_use"
|
||||
) {
|
||||
// 处理工具调用开始
|
||||
const res = {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: chunk.index || 0,
|
||||
id: chunk.content_block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: chunk.content_block.name,
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
created: parseInt(new Date().getTime() / 1000 + "", 10),
|
||||
id: chunk.id || "",
|
||||
model: chunk.model || "",
|
||||
object: "chat.completion.chunk",
|
||||
system_fingerprint: "fp_a49d71b8a1",
|
||||
usage: {
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
total_tokens:
|
||||
(chunk.usage?.input_tokens || 0) +
|
||||
(chunk.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(res)}\n\n`)
|
||||
);
|
||||
} else if (chunk.type === "message_delta") {
|
||||
// 处理消息结束
|
||||
const res = {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
finish_reason:
|
||||
chunk.delta?.stop_reason === "tool_use"
|
||||
? "tool_calls"
|
||||
: chunk.delta?.stop_reason === "max_tokens"
|
||||
? "length"
|
||||
: chunk.delta?.stop_reason === "stop_sequence"
|
||||
? "content_filter"
|
||||
: "stop",
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
created: parseInt(new Date().getTime() / 1000 + "", 10),
|
||||
id: chunk.id || "",
|
||||
model: chunk.model || "",
|
||||
object: "chat.completion.chunk",
|
||||
system_fingerprint: "fp_a49d71b8a1",
|
||||
usage: {
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
total_tokens:
|
||||
(chunk.usage?.input_tokens || 0) +
|
||||
(chunk.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(res)}\n\n`)
|
||||
);
|
||||
} else if (chunk.type === "message_stop") {
|
||||
// 发送结束标记
|
||||
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
|
||||
} else {
|
||||
// 处理其他格式的响应(保持原有逻辑作为后备)
|
||||
const res = {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: chunk.content?.[0]?.text || "",
|
||||
},
|
||||
finish_reason: chunk.stop_reason?.toLowerCase() || null,
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
created: parseInt(new Date().getTime() / 1000 + "", 10),
|
||||
id: chunk.id || "",
|
||||
model: chunk.model || "",
|
||||
object: "chat.completion.chunk",
|
||||
system_fingerprint: "fp_a49d71b8a1",
|
||||
usage: {
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
total_tokens:
|
||||
(chunk.usage?.input_tokens || 0) +
|
||||
(chunk.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(res)}\n\n`)
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger?.error(
|
||||
`Error parsing ${providerName} stream chunk`,
|
||||
chunkStr,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = response.body!.getReader();
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (buffer) {
|
||||
processLine(buffer, controller);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
processLine(line, controller);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
28
packages/core/tsconfig.json
Normal file
28
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
30
packages/ui/src/components/ui/checkbox.tsx
Normal file
30
packages/ui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitives from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitives.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitives.Indicator>
|
||||
</CheckboxPrimitives.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitives.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
160
packages/ui/src/components/ui/select.tsx
Normal file
160
packages/ui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
24
packages/ui/src/components/ui/textarea.tsx
Normal file
24
packages/ui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
23
scripts/build-core.js
Normal file
23
scripts/build-core.js
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
console.log('Building Core package (@musistudio/llms)...');
|
||||
|
||||
try {
|
||||
const coreDir = path.join(__dirname, '../packages/core');
|
||||
|
||||
// Build using the core package's build script
|
||||
console.log('Building core package (CJS and ESM)...');
|
||||
execSync('pnpm build', {
|
||||
stdio: 'inherit',
|
||||
cwd: coreDir
|
||||
});
|
||||
|
||||
console.log('Core package build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Core package build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user