init
This commit is contained in:
commit
90525cdfd8
22 files changed
+9590
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { Context } from "hono"
|
||||
import { contentJson, OpenAPIRoute } from 'chanfana';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
import { ChannelConfigRow } from "../db/db_model";
|
||||
|
||||
// 获取所有 Channel 配置
|
||||
export class ChannelGetEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Get all channel configurations',
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.array(z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}))),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT * FROM channel_config"
|
||||
).all<ChannelConfigRow>();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.results
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建或更新 Channel 配置
|
||||
export class ChannelUpsertEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Create or update channel configuration',
|
||||
request: {
|
||||
params: z.object({
|
||||
key: z.string().describe('Channel key'),
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
name: z.string().describe('Channel name'),
|
||||
type: z.string().describe('Channel type'),
|
||||
endpoint: z.string().describe('API endpoint'),
|
||||
api_key: z.string().describe('API key'),
|
||||
api_version: z.string().describe('API version'),
|
||||
deployment_mapper: z.record(z.string()).describe('Model deployment mapping'),
|
||||
model_pricing: z.record(z.object({
|
||||
input: z.number().describe('Input token price per 1000 tokens'),
|
||||
output: z.number().describe('Output token price per 1000 tokens'),
|
||||
})).optional().describe('Custom model pricing for this channel'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.boolean()),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const { key } = c.req.param();
|
||||
const config = await c.req.json<ChannelConfig>();
|
||||
|
||||
// Upsert channel config directly using SQL
|
||||
// excluded.value 指的是 INSERT 语句中要插入的新值
|
||||
// 当发生冲突时,用新值更新现有记录
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO channel_config (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = datetime('now')`
|
||||
).bind(key, JSON.stringify(config)).run();
|
||||
|
||||
if (!result.success) {
|
||||
return c.text('Failed to upsert channel config', 500);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: true
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 Channel 配置
|
||||
export class ChannelDeleteEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Delete channel configuration',
|
||||
request: {
|
||||
params: z.object({
|
||||
key: z.string().describe('Channel key'),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.boolean()),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const { key } = c.req.param();
|
||||
|
||||
// Delete channel config directly using SQL
|
||||
const result = await c.env.DB.prepare(
|
||||
`DELETE FROM channel_config WHERE key = ?`
|
||||
).bind(key).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.success
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Context } from "hono"
|
||||
import { OpenAPIRoute } from 'chanfana';
|
||||
import { z } from 'zod';
|
||||
|
||||
import db from "../db"
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
|
||||
export class DBInitializeEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.string()),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
await db.initialize(c);
|
||||
await db.migrate(c);
|
||||
return {
|
||||
success: true,
|
||||
data: await db.getVersion(c)
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Context, Hono, Next } from "hono"
|
||||
import { fromHono } from 'chanfana';
|
||||
import { DBInitializeEndpoint } from "./db_api"
|
||||
import {
|
||||
ChannelGetEndpoint, ChannelUpsertEndpoint, ChannelDeleteEndpoint
|
||||
} from "./channel_api"
|
||||
import {
|
||||
TokenListEndpoint, TokenUpsertEndpoint, TokenDeleteEndpoint
|
||||
} from "./token_api"
|
||||
import {
|
||||
PricingGetEndpoint, PricingUpdateEndpoint
|
||||
} from "./pricing_api"
|
||||
|
||||
const app = new Hono<HonoCustomType>()
|
||||
export const api = fromHono(app)
|
||||
|
||||
// Authentication Middleware - using environment variable
|
||||
app.use('/api/admin/*', async (c, next) => {
|
||||
const token = c.req.header('x-admin-token');
|
||||
const adminToken = c.env.ADMIN_TOKEN;
|
||||
|
||||
if (!token || !adminToken || token !== adminToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
api.post("/api/admin/db_initialize", DBInitializeEndpoint)
|
||||
|
||||
api.get("/api/admin/channel", ChannelGetEndpoint)
|
||||
api.post("/api/admin/channel/:key", ChannelUpsertEndpoint)
|
||||
api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint)
|
||||
|
||||
// Token management routes
|
||||
api.get("/api/admin/token", TokenListEndpoint)
|
||||
api.post("/api/admin/token/:key", TokenUpsertEndpoint)
|
||||
api.delete("/api/admin/token/:key", TokenDeleteEndpoint)
|
||||
|
||||
// Pricing management routes
|
||||
api.get("/api/admin/pricing", PricingGetEndpoint)
|
||||
api.post("/api/admin/pricing", PricingUpdateEndpoint)
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Context } from "hono";
|
||||
import { OpenAPIRoute } from 'chanfana';
|
||||
import { z } from 'zod';
|
||||
import { getJsonSetting, saveSetting } from "../utils";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
|
||||
// Pricing 获取配置 API
|
||||
export class PricingGetEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Get global pricing configuration',
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.record(z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
}))),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const pricingConfig = await getJsonSetting<Record<string, ModelPricing>>(
|
||||
c,
|
||||
CONSTANTS.MODEL_PRICING_KEY
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: pricingConfig || {}
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing 更新配置 API
|
||||
export class PricingUpdateEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Update global pricing configuration',
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.record(z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const body = await c.req.json<Record<string, ModelPricing>>();
|
||||
|
||||
await saveSetting(
|
||||
c,
|
||||
CONSTANTS.MODEL_PRICING_KEY,
|
||||
JSON.stringify(body)
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Pricing config updated successfully"
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Context } from "hono"
|
||||
import { OpenAPIRoute } from 'chanfana';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
|
||||
// Token 列表 API
|
||||
export class TokenListEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Get all tokens',
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.array(z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
usage: z.number(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}))),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const result = await c.env.DB.prepare(
|
||||
`SELECT * FROM api_token ORDER BY created_at DESC`
|
||||
).all<ApiTokenRow>();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.results
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Token 创建/更新 API (Upsert)
|
||||
export class TokenUpsertEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Create or update a token',
|
||||
request: {
|
||||
params: z.object({
|
||||
key: z.string().describe('Token key'),
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
name: z.string().describe('Token name'),
|
||||
channel_keys: z.array(z.string()).describe('Channel keys to bind (empty array means access to all channels)'),
|
||||
total_quota: z.number().describe('Total quota amount'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.boolean()),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const body = await c.req.json<ApiTokenData>();
|
||||
const { key } = c.req.param();
|
||||
|
||||
// Validate channels exist using batch query (if channel_keys is not empty)
|
||||
if (body.channel_keys && body.channel_keys.length > 0) {
|
||||
const channelQuery = body.channel_keys.map(() => '?').join(',');
|
||||
const existingChannels = await c.env.DB.prepare(
|
||||
`SELECT key FROM channel_config WHERE key IN (${channelQuery})`
|
||||
).bind(...body.channel_keys).all();
|
||||
|
||||
if (!existingChannels.results || existingChannels.results.length !== body.channel_keys.length) {
|
||||
const existingKeys = existingChannels.results?.map((row: any) => row.key) || [];
|
||||
const missingKeys = body.channel_keys.filter(key => !existingKeys.includes(key));
|
||||
return c.text(`Channels not found: ${missingKeys.join(', ')}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert token directly using SQL with ON CONFLICT
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO api_token (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = datetime('now')`
|
||||
).bind(key, JSON.stringify(body)).run();
|
||||
|
||||
if (!result.success) {
|
||||
return c.text('Failed to upsert token', 500);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: true
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Token 删除 API
|
||||
export class TokenDeleteEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['Admin API'],
|
||||
summary: 'Delete token',
|
||||
request: {
|
||||
params: z.object({
|
||||
key: z.string().describe('Token key'),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.boolean()),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const { key } = c.req.param();
|
||||
|
||||
// Delete token directly using SQL
|
||||
const result = await c.env.DB.prepare(
|
||||
`DELETE FROM api_token WHERE key = ?`
|
||||
).bind(key).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.success
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
// Token 工具类
|
||||
export class TokenUtils {
|
||||
static async updateUsage(c: Context<HonoCustomType>, key: string, usageAmount: number): Promise<boolean> {
|
||||
try {
|
||||
const result = await c.env.DB.prepare(
|
||||
`UPDATE api_token SET usage = usage + ?, updated_at = datetime('now') WHERE key = ?`
|
||||
).bind(usageAmount, key).run();
|
||||
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Error updating token usage:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: "v0.0.1",
|
||||
DB_VERSION_KEY: "db_version",
|
||||
DB_VERSION: "v0.0.1",
|
||||
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Context } from "hono";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import utils from "../utils";
|
||||
|
||||
const DB_INIT_QUERIES = `
|
||||
CREATE TABLE IF NOT EXISTS channel_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS api_token (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
usage REAL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
const dbOperations = {
|
||||
initialize: async (c: Context<HonoCustomType>) => {
|
||||
// remove all \r and \n characters from the query string
|
||||
// split by ; and join with a ;\n
|
||||
const query = DB_INIT_QUERIES.replace(/[\r\n]/g, "")
|
||||
.split(";")
|
||||
.map((query) => query.trim())
|
||||
.join(";\n");
|
||||
await c.env.DB.exec(query);
|
||||
|
||||
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
|
||||
if (version) {
|
||||
return c.json({ message: "Database already initialized" });
|
||||
}
|
||||
await utils.saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
|
||||
},
|
||||
migrate: async (c: Context<HonoCustomType>) => {
|
||||
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
|
||||
if (version != CONSTANTS.DB_VERSION) {
|
||||
// TODO: Perform migration logic here
|
||||
|
||||
// Update the version in the settings table
|
||||
await utils.saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
|
||||
}
|
||||
},
|
||||
getVersion: async (c: Context<HonoCustomType>): Promise<string | null> => {
|
||||
return await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export default dbOperations;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Context, Hono, Next } from 'hono'
|
||||
import { cors } from 'hono/cors';
|
||||
import { api as providerApi } from './providers'
|
||||
import { api as adminApi } from './admin'
|
||||
import { fromHono } from 'chanfana';
|
||||
|
||||
const API_PATHS = [
|
||||
"/api",
|
||||
"/v1",
|
||||
];
|
||||
|
||||
const app = new Hono<HonoCustomType>()
|
||||
const openapi = fromHono(app, {
|
||||
schema: {
|
||||
info: {
|
||||
title: 'Awsl One API',
|
||||
version: '1.0.0',
|
||||
}
|
||||
},
|
||||
docs_url: '/api/docs',
|
||||
redoc_url: '/api/redocs',
|
||||
openapi_url: '/api/openapi.json'
|
||||
});
|
||||
|
||||
// cors
|
||||
openapi.use('/*', cors());
|
||||
// global middlewares
|
||||
app.use('/*', async (c, next) => {
|
||||
// check if the request is for static files
|
||||
if (c.env.ASSETS && !API_PATHS.some(path => c.req.path.startsWith(path))) {
|
||||
const url = new URL(c.req.raw.url);
|
||||
if (!url.pathname.includes('.')) {
|
||||
url.pathname = ""
|
||||
}
|
||||
return c.env.ASSETS.fetch(url);
|
||||
}
|
||||
await next()
|
||||
});
|
||||
// global error handler
|
||||
openapi.onError((err, c) => {
|
||||
console.error(err)
|
||||
return c.text(`${err.name} ${err.message}`, 500)
|
||||
})
|
||||
|
||||
openapi.route('/', providerApi)
|
||||
openapi.route('/', adminApi)
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z, ZodType } from 'zod';
|
||||
|
||||
export const CommonSuccessfulResponse = (
|
||||
dataType: ZodType | null = null
|
||||
) => {
|
||||
return {
|
||||
200: {
|
||||
description: 'Successful Request',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
success: z.boolean(),
|
||||
message: z.nullable(z.string()),
|
||||
data: z.nullable(dataType || z.any()),
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CommonErrorResponse = {
|
||||
400: {
|
||||
description: 'Bad Request',
|
||||
content: {
|
||||
'text/plain': {
|
||||
schema: z.string(),
|
||||
}
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized',
|
||||
content: {
|
||||
'text/plain': {
|
||||
schema: z.string(),
|
||||
}
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: 'Internal Server Error',
|
||||
content: {
|
||||
'text/plain': {
|
||||
schema: z.string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Context } from "hono"
|
||||
import { streamText } from 'hono/streaming'
|
||||
|
||||
// 辅助函数
|
||||
const buildProxyRequest = (
|
||||
request: Request,
|
||||
reqJson: any,
|
||||
config: ChannelConfig,
|
||||
deploymentName: string
|
||||
): Request => {
|
||||
const url = new URL(request.url)
|
||||
const targetUrl = new URL(config.endpoint)
|
||||
targetUrl.pathname = `/openai/deployments/${deploymentName}/${url.pathname.replace('/v1/', '')}`
|
||||
targetUrl.searchParams.set('api-version', config.api_version)
|
||||
|
||||
const targetHeaders = new Headers(request.headers)
|
||||
targetHeaders.delete("Authorization")
|
||||
targetHeaders.set("api-key", config.api_key)
|
||||
|
||||
return new Request(targetUrl, {
|
||||
method: request.method,
|
||||
headers: targetHeaders,
|
||||
body: JSON.stringify(reqJson),
|
||||
})
|
||||
}
|
||||
|
||||
const checkoutUsageData = async (
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
response: Response | OpenAIResponse
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const res = response instanceof Response
|
||||
? await response.clone().json<OpenAIResponse>()
|
||||
: response
|
||||
if (!res.usage) return;
|
||||
await saveUsage(res.usage)
|
||||
} catch (error) {
|
||||
console.error("Error logging usage data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const processStreamData = (
|
||||
lines: string[],
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): void => {
|
||||
lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.startsWith('data:'))
|
||||
.map(line => line.replace('data: ', '').trim())
|
||||
.filter(line => line !== '[DONE]')
|
||||
.forEach(jsonContent => {
|
||||
try {
|
||||
const res = JSON.parse(jsonContent) as OpenAIResponse
|
||||
checkoutUsageData(saveUsage, res)
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream data:", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleStreamResponse = async (
|
||||
c: Context<HonoCustomType>,
|
||||
response: Response,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<Response> => {
|
||||
return streamText(c, async (stream) => {
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No reader found in response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
await stream.write(chunk)
|
||||
buffer += chunk
|
||||
|
||||
if (!chunk.includes('\n')) continue
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
processStreamData(lines, saveUsage)
|
||||
}
|
||||
|
||||
// 处理最后剩余的数据
|
||||
if (buffer.trim()) {
|
||||
processStreamData([buffer], saveUsage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
c: Context<HonoCustomType>,
|
||||
config: ChannelConfig,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
// 准备请求数据
|
||||
const reqJson = await c.req.json()
|
||||
// 强制包含使用数据
|
||||
const { model: modelName, stream } = reqJson;
|
||||
if (stream) {
|
||||
reqJson.stream_options = { "include_usage": true }
|
||||
}
|
||||
|
||||
// 获取部署名称
|
||||
const deploymentName = config.deployment_mapper[modelName]
|
||||
if (!deploymentName) {
|
||||
throw new Error(`Model ${modelName} not supported`)
|
||||
}
|
||||
|
||||
// 构建目标请求
|
||||
const proxyRequest = buildProxyRequest(c.req.raw, reqJson, config, deploymentName)
|
||||
|
||||
// 发送请求并获取响应
|
||||
const response = await fetch(proxyRequest)
|
||||
|
||||
// 处理流式响应
|
||||
if (stream) {
|
||||
return handleStreamResponse(c, response, saveUsage)
|
||||
}
|
||||
|
||||
// 处理常规响应
|
||||
if (response.ok) {
|
||||
await checkoutUsageData(saveUsage, response)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Context, Hono } from "hono"
|
||||
import { contentJson, fromHono, OpenAPIRoute } from 'chanfana';
|
||||
|
||||
import azureOpenaiProxy from "./azure-openai-proxy"
|
||||
import utils from "../utils"
|
||||
import { TokenUtils } from "../admin/token_utils"
|
||||
import { ChannelConfigRow } from "../db/db_model"
|
||||
import { CONSTANTS } from "../constants"
|
||||
import { z } from "zod";
|
||||
|
||||
export const api = fromHono(new Hono<HonoCustomType>())
|
||||
|
||||
const providerMap: Record<
|
||||
string,
|
||||
(
|
||||
c: Context<HonoCustomType>,
|
||||
config: ChannelConfig,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
) => Promise<Response>
|
||||
> = {
|
||||
"azure-openai": azureOpenaiProxy.fetch,
|
||||
};
|
||||
|
||||
class ProxyEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['OpenAI Proxy'],
|
||||
request: {
|
||||
headers: z.object({
|
||||
'Authorization': z.string().describe("Token for authentication"),
|
||||
}),
|
||||
body: contentJson({
|
||||
schema: z.any(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Successful response',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const authHeader = c.req.raw.headers.get('Authorization');
|
||||
const apiKey = authHeader?.replace("Bearer ", "").trim();
|
||||
if (!apiKey) {
|
||||
return c.text("Authorization header not found", 401);
|
||||
}
|
||||
|
||||
// Get token from database using direct SQL query
|
||||
const tokenResult = await c.env.DB.prepare(
|
||||
`SELECT * FROM api_token WHERE key = ?`
|
||||
).bind(apiKey).first();
|
||||
|
||||
if (!tokenResult || !tokenResult.value) {
|
||||
return c.text("Invalid API key", 401);
|
||||
}
|
||||
|
||||
const tokenData = JSON.parse(tokenResult.value as string) as ApiTokenData;
|
||||
const usage = tokenResult.usage as number || 0;
|
||||
|
||||
// Check if token has sufficient quota
|
||||
if (usage >= tokenData.total_quota) {
|
||||
return c.text("Quota exceeded", 402);
|
||||
} // Get available channel configs based on token permissions
|
||||
const channelKeys = tokenData.channel_keys;
|
||||
|
||||
let channelsResult;
|
||||
if (!channelKeys || channelKeys.length === 0) {
|
||||
// If channel_keys is empty, get all channels
|
||||
channelsResult = await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config`
|
||||
).all<ChannelConfigRow>();
|
||||
} else {
|
||||
// Query specified channels
|
||||
const channelQuery = channelKeys.map(() => '?').join(',');
|
||||
channelsResult = await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config WHERE key IN (${channelQuery})`
|
||||
).bind(...channelKeys).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
if (!channelsResult.results || channelsResult.results.length === 0) {
|
||||
return c.text("No available channels for this token", 401);
|
||||
}
|
||||
|
||||
// Parse channel configs and get the model from request
|
||||
const requestBody = await c.req.json();
|
||||
const model = requestBody.model || 'gpt-3.5-turbo';
|
||||
|
||||
// Filter channels that support the requested model
|
||||
const availableChannels: Array<{ key: string, config: ChannelConfig }> = [];
|
||||
|
||||
for (const row of channelsResult.results) {
|
||||
const config = JSON.parse(row.value) as ChannelConfig;
|
||||
|
||||
// Check if channel supports the model (has deployment mapping for it)
|
||||
if (config.deployment_mapper && config.deployment_mapper[model]) {
|
||||
availableChannels.push({
|
||||
key: row.key,
|
||||
config: config
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableChannels.length === 0) {
|
||||
return c.text(`No channels available for model: ${model}`, 400);
|
||||
}
|
||||
|
||||
// Randomly select one available channel
|
||||
const randomIndex = Math.floor(Math.random() * availableChannels.length);
|
||||
const selectedChannel = availableChannels[randomIndex];
|
||||
const targetChannelKey = selectedChannel.key;
|
||||
const targetChannelConfig = selectedChannel.config;
|
||||
|
||||
if (!targetChannelConfig.type) {
|
||||
return c.text("Channel type invalid", 400);
|
||||
}
|
||||
|
||||
const proxyFetch = providerMap[targetChannelConfig.type];
|
||||
if (!proxyFetch) {
|
||||
return c.text("Channel type not supported", 400);
|
||||
}
|
||||
|
||||
return proxyFetch(c, targetChannelConfig,
|
||||
async (usage: Usage) => {
|
||||
console.log("Usage data:", usage);
|
||||
|
||||
// Calculate cost based on usage and model pricing
|
||||
// Model was already extracted above
|
||||
|
||||
// Get global pricing as base configuration
|
||||
let pricing = null;
|
||||
const globalPricing = await utils.getSetting(c, CONSTANTS.MODEL_PRICING_KEY);
|
||||
if (globalPricing) {
|
||||
const globalPricingMap = JSON.parse(globalPricing);
|
||||
pricing = globalPricingMap[model];
|
||||
}
|
||||
|
||||
// Override with channel-specific pricing if available
|
||||
if (targetChannelConfig.model_pricing?.[model]) {
|
||||
pricing = targetChannelConfig.model_pricing[model];
|
||||
}
|
||||
|
||||
if (pricing && usage.prompt_tokens && usage.completion_tokens) {
|
||||
const inputCost = usage.prompt_tokens * pricing.input;
|
||||
const outputCost = usage.completion_tokens * pricing.output;
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
// Update token usage using TokenUtils
|
||||
const updated = await TokenUtils.updateUsage(c, apiKey, totalCost);
|
||||
if (!updated) {
|
||||
console.error("Failed to update token usage");
|
||||
}
|
||||
|
||||
console.log(`Model: ${model}, Channel: ${targetChannelKey}, Channel pricing: ${!!targetChannelConfig.model_pricing?.[model]}, Cost: ${totalCost}, Updated: ${updated}`);
|
||||
}
|
||||
else {
|
||||
console.warn(`No pricing found for model: ${model} in channel: ${targetChannelKey}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
api.post("/v1/chat/completions", ProxyEndpoint)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
const getJsonObjectValue = <T = any>(
|
||||
value: string | any
|
||||
): T | null => {
|
||||
if (value == undefined || value == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return value as T;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (e) {
|
||||
console.error(`GetJsonValue: Failed to parse ${value}`, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export const getJsonSetting = async <T = any>(
|
||||
c: Context<HonoCustomType>, key: string
|
||||
): Promise<T | null> => {
|
||||
const value = await getSetting(c, key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (e) {
|
||||
console.error(`GetJsonSetting: Failed to parse ${key}`, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getSetting = async (
|
||||
c: Context<HonoCustomType>, key: string
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const value = await c.env.DB.prepare(
|
||||
`SELECT value FROM settings where key = ?`
|
||||
).bind(key).first<string>("value");
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`GetSetting: Failed to get ${key}`, error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const saveSetting = async (
|
||||
c: Context<HonoCustomType>,
|
||||
key: string, value: string
|
||||
) => {
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)`
|
||||
+ ` ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')`
|
||||
).bind(key, value, value).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export default {
|
||||
getJsonObjectValue,
|
||||
getJsonSetting,
|
||||
getSetting,
|
||||
saveSetting,
|
||||
}
|
||||
Reference in new issue
Block a user