refactor: 统一 providers 层,提取共享模块消除重复代码
- 提取 shared/auth.ts、channel-resolver.ts、route-policy.ts、provider-registry.ts - 提取 shared/openai-stream-utils.ts 和 responses-stream-utils.ts - 统一 index.ts 为 UnifiedProxyEndpoint(303→55行) - 各 provider 文件精简约55%,只保留 buildProxyRequest - 修复 claude-to-openai-proxy.ts 类型错误 - 添加 E2E mock 测试基础设施(mock-upstream + setup + run-e2e) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d9dd2d81be
commit
6f99358fb5
18 files changed
+840
-710
No files matched your search
@@ -11,6 +11,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"wrangler": "^4.61.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
|
||||
Generated
+10
@@ -18,6 +18,9 @@ importers:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
wrangler:
|
||||
specifier: ^4.61.1
|
||||
version: 4.61.1
|
||||
@@ -471,6 +474,11 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici@7.18.2:
|
||||
resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
@@ -877,6 +885,8 @@ snapshots:
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici@7.18.2: {}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Context } from "hono"
|
||||
import { streamText } from 'hono/streaming'
|
||||
import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils"
|
||||
|
||||
// 辅助函数
|
||||
const buildProxyRequest = (
|
||||
request: Request,
|
||||
reqJson: any,
|
||||
@@ -11,7 +10,6 @@ const buildProxyRequest = (
|
||||
const url = new URL(request.url)
|
||||
const targetUrl = new URL(config.endpoint)
|
||||
|
||||
// 如果 endpoint 末尾不带 #,则设置标准的 Azure OpenAI pathname(保留原有 path 前缀)
|
||||
if (!config.endpoint.endsWith('#')) {
|
||||
const basePath = targetUrl.pathname.endsWith('/')
|
||||
? targetUrl.pathname.slice(0, -1)
|
||||
@@ -25,6 +23,8 @@ const buildProxyRequest = (
|
||||
|
||||
const targetHeaders = new Headers(request.headers)
|
||||
targetHeaders.delete("Authorization")
|
||||
targetHeaders.delete("Host")
|
||||
targetHeaders.delete("Cookie")
|
||||
targetHeaders.set("api-key", config.api_key)
|
||||
|
||||
return new Request(targetUrl, {
|
||||
@@ -34,80 +34,6 @@ const buildProxyRequest = (
|
||||
})
|
||||
}
|
||||
|
||||
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 = async (
|
||||
lines: string[],
|
||||
usageSaved: { value: boolean },
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
if (usageSaved.value) return
|
||||
const processedLines = lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.startsWith('data:'))
|
||||
.map(line => line.replace('data: ', '').trim())
|
||||
.filter(line => line !== '[DONE]')
|
||||
|
||||
for (const jsonContent of processedLines) {
|
||||
try {
|
||||
const res = JSON.parse(jsonContent) as OpenAIResponse
|
||||
if (!res.usage) continue
|
||||
await saveUsage(res.usage)
|
||||
usageSaved.value = true
|
||||
break
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream data:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamResponse = async (
|
||||
c: Context<HonoCustomType>,
|
||||
streamForServer: ReadableStream<any> | undefined,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
const reader = streamForServer?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No reader found in response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ""
|
||||
const usageSaved = { value: false }
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
if (!chunk.includes('\n')) continue
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
await processStreamData(lines, usageSaved, saveUsage)
|
||||
}
|
||||
|
||||
// 处理最后剩余的数据
|
||||
if (buffer.trim()) {
|
||||
await processStreamData([buffer], usageSaved, saveUsage)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
c: Context<HonoCustomType>,
|
||||
@@ -115,10 +41,8 @@ export default {
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
// model 已在上层完成映射,直接作为 deploymentName 使用
|
||||
const { model: deploymentName, stream } = requestBody;
|
||||
|
||||
// 强制包含使用数据
|
||||
if (stream) {
|
||||
requestBody.stream_options = {
|
||||
...(requestBody.stream_options || {}),
|
||||
@@ -126,13 +50,9 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 构建目标请求
|
||||
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config, deploymentName)
|
||||
|
||||
// 发送请求并获取响应
|
||||
const response = await fetch(proxyRequest)
|
||||
|
||||
// 处理流式响应
|
||||
if (stream) {
|
||||
const [streamForClient, streamForServer] = response.body?.tee() || []
|
||||
c.executionCtx.waitUntil(handleStreamResponse(c, streamForServer, saveUsage))
|
||||
@@ -143,7 +63,6 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理常规响应
|
||||
if (response.ok) {
|
||||
await checkoutUsageData(saveUsage, response)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Context } from "hono"
|
||||
import {
|
||||
handleStreamResponse,
|
||||
OpenAIResponsesResponse,
|
||||
OpenAIResponsesStreamEvent,
|
||||
extractUsageFromResponse,
|
||||
estimateUsageFromBodies,
|
||||
} from "./shared/usage-utils"
|
||||
} from "./shared/responses-stream-utils"
|
||||
|
||||
const buildProxyRequest = (
|
||||
request: Request,
|
||||
@@ -15,7 +15,6 @@ const buildProxyRequest = (
|
||||
const targetUrl = new URL(config.endpoint)
|
||||
|
||||
if (!config.endpoint.endsWith('#')) {
|
||||
// Azure OpenAI Responses uses /openai/v1/responses
|
||||
targetUrl.pathname = `/openai${url.pathname}`
|
||||
}
|
||||
|
||||
@@ -25,6 +24,8 @@ const buildProxyRequest = (
|
||||
|
||||
const targetHeaders = new Headers(request.headers)
|
||||
targetHeaders.delete("Authorization")
|
||||
targetHeaders.delete("Host")
|
||||
targetHeaders.delete("Cookie")
|
||||
targetHeaders.set("api-key", config.api_key)
|
||||
|
||||
return new Request(targetUrl, {
|
||||
@@ -34,95 +35,6 @@ const buildProxyRequest = (
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamData = async (
|
||||
lines: string[],
|
||||
usageSaved: { value: boolean },
|
||||
outputText: { value: string },
|
||||
outputLimit: number,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
if (usageSaved.value) return
|
||||
const processedLines = lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.startsWith('data:'))
|
||||
.map(line => line.replace('data:', '').trim())
|
||||
.filter(line => line !== '[DONE]')
|
||||
|
||||
for (const jsonContent of processedLines) {
|
||||
try {
|
||||
const event = JSON.parse(jsonContent) as OpenAIResponsesStreamEvent
|
||||
if (event.type === "response.output_text.delta") {
|
||||
const deltaText = typeof event.delta === "string"
|
||||
? event.delta
|
||||
: (typeof event.text === "string" ? event.text : "")
|
||||
if (deltaText) {
|
||||
if (outputText.value.length < outputLimit) {
|
||||
outputText.value += deltaText
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type !== "response.completed") {
|
||||
continue
|
||||
}
|
||||
const usage = extractUsageFromResponse(event.response)
|
||||
if (usage && !usageSaved.value) {
|
||||
await saveUsage(usage)
|
||||
usageSaved.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream data:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamResponse = async (
|
||||
c: Context<HonoCustomType>,
|
||||
streamForServer: ReadableStream<any> | undefined,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
const reader = streamForServer?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No reader found in response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ""
|
||||
const usageSaved = { value: false }
|
||||
const outputText = { value: "" }
|
||||
const outputLimit = 200_000
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
if (!chunk.includes('\n')) continue
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
await processStreamData(lines, usageSaved, outputText, outputLimit, saveUsage)
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
await processStreamData([buffer], usageSaved, outputText, outputLimit, saveUsage)
|
||||
}
|
||||
|
||||
if (!usageSaved.value) {
|
||||
const estimatedUsage = estimateUsageFromBodies(
|
||||
requestBody,
|
||||
undefined,
|
||||
outputText.value
|
||||
)
|
||||
if (estimatedUsage) {
|
||||
await saveUsage(estimatedUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
c: Context<HonoCustomType>,
|
||||
@@ -132,8 +44,6 @@ export default {
|
||||
): Promise<Response> {
|
||||
const { stream } = requestBody
|
||||
|
||||
// model 已在上层完成映射
|
||||
|
||||
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
|
||||
const response = await fetch(proxyRequest)
|
||||
|
||||
@@ -154,7 +64,7 @@ export default {
|
||||
if (usage) {
|
||||
await saveUsage(usage)
|
||||
} else {
|
||||
const estimatedUsage = estimateUsageFromBodies(reqJson, resJson)
|
||||
const estimatedUsage = estimateUsageFromBodies(requestBody, resJson)
|
||||
if (estimatedUsage) {
|
||||
await saveUsage(estimatedUsage)
|
||||
}
|
||||
|
||||
@@ -582,7 +582,7 @@ export default {
|
||||
|
||||
if (response.ok) {
|
||||
try {
|
||||
const resJson = await response.clone().json();
|
||||
const resJson = await response.clone().json<OpenAIResponse & Record<string, any>>();
|
||||
const usage = normalizeUsage(resJson?.usage);
|
||||
if (usage) {
|
||||
await saveUsage(usage);
|
||||
|
||||
+21
-269
@@ -1,83 +1,15 @@
|
||||
import { Context, Hono } from "hono"
|
||||
import { contentJson, fromHono, OpenAPIRoute } from 'chanfana';
|
||||
|
||||
import azureOpenaiProxy from "./azure-openai-proxy"
|
||||
import openaiProxy from "./openai-proxy"
|
||||
import claudeProxy from "./claude-proxy"
|
||||
import claudeToOpenaiProxy from "./claude-to-openai-proxy"
|
||||
import openaiResponsesProxy from "./openai-responses-proxy"
|
||||
import azureOpenaiResponsesProxy from "./azure-openai-responses-proxy"
|
||||
import utils, { findDeploymentMapping } from "../utils"
|
||||
import { TokenUtils } from "../admin/token_utils"
|
||||
import { CONSTANTS } from "../constants"
|
||||
import { z } from "zod";
|
||||
|
||||
import { resolveRouteId } from "./shared/route-policy"
|
||||
import { resolveChannel } from "./shared/channel-resolver"
|
||||
import { getProvider } from "./shared/provider-registry"
|
||||
import { ModelsEndpoint } from "./models"
|
||||
|
||||
export const api = fromHono(new Hono<HonoCustomType>())
|
||||
|
||||
const providerMap: Record<
|
||||
string,
|
||||
(
|
||||
c: Context<HonoCustomType>,
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
) => Promise<Response>
|
||||
> = {
|
||||
"azure-openai": azureOpenaiProxy.fetch,
|
||||
"openai": openaiProxy.fetch,
|
||||
"claude": claudeProxy.fetch,
|
||||
"claude-to-openai": claudeToOpenaiProxy.fetch,
|
||||
"openai-responses": openaiResponsesProxy.fetch,
|
||||
"azure-openai-responses": azureOpenaiResponsesProxy.fetch,
|
||||
};
|
||||
|
||||
const getApiKeyFromHeaders = (c: Context<HonoCustomType>): string | null => {
|
||||
const authHeader = c.req.raw.headers.get('Authorization');
|
||||
const xApiKey = c.req.raw.headers.get('x-api-key');
|
||||
|
||||
if (authHeader) {
|
||||
return authHeader.replace("Bearer ", "").trim();
|
||||
}
|
||||
if (xApiKey) {
|
||||
return xApiKey.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchTokenData = async (c: Context<HonoCustomType>, apiKey: string) => {
|
||||
const tokenResult = await c.env.DB.prepare(
|
||||
`SELECT * FROM api_token WHERE key = ?`
|
||||
).bind(apiKey).first();
|
||||
|
||||
if (!tokenResult || !tokenResult.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tokenData: JSON.parse(tokenResult.value as string) as ApiTokenData,
|
||||
usage: tokenResult.usage as number || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const fetchChannelsForToken = async (
|
||||
c: Context<HonoCustomType>,
|
||||
tokenData: ApiTokenData
|
||||
) => {
|
||||
const channelKeys = tokenData.channel_keys;
|
||||
|
||||
if (!channelKeys || channelKeys.length === 0) {
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config`
|
||||
).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
const channelQuery = channelKeys.map(() => '?').join(',');
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config WHERE key IN (${channelQuery})`
|
||||
).bind(...channelKeys).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
class ProxyEndpoint extends OpenAPIRoute {
|
||||
class UnifiedProxyEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['OpenAI Proxy'],
|
||||
request: {
|
||||
@@ -97,206 +29,26 @@ class ProxyEndpoint extends OpenAPIRoute {
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
// Support both OpenAI format (Authorization: Bearer xxx) and Claude format (x-api-key: xxx)
|
||||
const apiKey = getApiKeyFromHeaders(c);
|
||||
if (!apiKey) {
|
||||
return c.text("Authorization header or x-api-key not found", 401);
|
||||
const routeId = resolveRouteId(c.req.path)
|
||||
if (!routeId) {
|
||||
return c.text("Unknown route", 404)
|
||||
}
|
||||
|
||||
const tokenInfo = await fetchTokenData(c, apiKey);
|
||||
if (!tokenInfo) {
|
||||
return c.text("Invalid API key", 401);
|
||||
const result = await resolveChannel(c, routeId)
|
||||
if (result instanceof Response) return result
|
||||
|
||||
const { channel, requestBody, saveUsage } = result
|
||||
|
||||
const provider = getProvider(channel.config.type || "")
|
||||
if (!provider) {
|
||||
return c.text("Channel type not supported", 400)
|
||||
}
|
||||
|
||||
const { tokenData, usage } = tokenInfo;
|
||||
|
||||
// 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 channelsResult = await fetchChannelsForToken(c, tokenData);
|
||||
|
||||
if (!channelsResult.results || channelsResult.results.length === 0) {
|
||||
return c.text("No available channels for this token", 401);
|
||||
}
|
||||
|
||||
let requestBody: any;
|
||||
try {
|
||||
requestBody = await c.req.json();
|
||||
} catch (error) {
|
||||
return c.text("Invalid JSON body", 400);
|
||||
}
|
||||
const model = requestBody.model;
|
||||
if (!model) {
|
||||
return c.text("Model is required", 400);
|
||||
}
|
||||
|
||||
// Filter channels that support the requested model
|
||||
const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: { pattern: string, deployment: string } }> = [];
|
||||
|
||||
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)
|
||||
const mapping = findDeploymentMapping(config.deployment_mapper, model);
|
||||
if (mapping) {
|
||||
availableChannels.push({
|
||||
key: row.key,
|
||||
config: config,
|
||||
mapping: mapping
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableChannels.length === 0) {
|
||||
if (!channelsResult.results || channelsResult.results.length === 0) {
|
||||
return c.text("No available channels for this token", 404);
|
||||
}
|
||||
return c.text(`Model not mapped: ${model}. Please configure deployment_mapper.`, 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;
|
||||
|
||||
// 统一在上层完成模型名映射
|
||||
requestBody.model = selectedChannel.mapping.deployment;
|
||||
|
||||
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, requestBody,
|
||||
async (usage: Usage) => {
|
||||
try {
|
||||
// 使用转换后的模型名计费
|
||||
await TokenUtils.processUsage(c, apiKey, requestBody.model, targetChannelKey, targetChannelConfig, usage);
|
||||
} catch (error) {
|
||||
console.error('Error processing usage:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
return provider(c, channel.config, requestBody, saveUsage)
|
||||
}
|
||||
}
|
||||
|
||||
api.post("/v1/chat/completions", ProxyEndpoint)
|
||||
api.post("/v1/messages", ProxyEndpoint) // Claude API endpoint
|
||||
|
||||
class ResponsesProxyEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ['OpenAI Responses Proxy'],
|
||||
request: {
|
||||
headers: z.object({
|
||||
'Authorization': z.string().optional().describe("Token for authentication (OpenAI format)"),
|
||||
'x-api-key': z.string().optional().describe("API key for authentication (Claude format)"),
|
||||
}),
|
||||
body: contentJson({
|
||||
schema: z.any(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Successful response',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const apiKey = getApiKeyFromHeaders(c);
|
||||
if (!apiKey) {
|
||||
return c.text("Authorization header or x-api-key not found", 401);
|
||||
}
|
||||
|
||||
const tokenInfo = await fetchTokenData(c, apiKey);
|
||||
if (!tokenInfo) {
|
||||
return c.text("Invalid API key", 401);
|
||||
}
|
||||
|
||||
const { tokenData, usage } = tokenInfo;
|
||||
|
||||
if (usage >= tokenData.total_quota) {
|
||||
return c.text("Quota exceeded", 402);
|
||||
}
|
||||
|
||||
const channelsResult = await fetchChannelsForToken(c, tokenData);
|
||||
|
||||
if (!channelsResult.results || channelsResult.results.length === 0) {
|
||||
return c.text("No available channels for this token", 401);
|
||||
}
|
||||
|
||||
let requestBody: any;
|
||||
try {
|
||||
requestBody = await c.req.json();
|
||||
} catch (error) {
|
||||
return c.text("Invalid JSON body", 400);
|
||||
}
|
||||
const model = requestBody.model;
|
||||
if (!model) {
|
||||
return c.text("Model is required", 400);
|
||||
}
|
||||
|
||||
const allowedTypes: ChannelType[] = ["openai-responses", "azure-openai-responses"];
|
||||
const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: { pattern: string, deployment: string } }> = [];
|
||||
|
||||
for (const row of channelsResult.results) {
|
||||
const config = JSON.parse(row.value) as ChannelConfig;
|
||||
|
||||
if (!config.type || !allowedTypes.includes(config.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapping = findDeploymentMapping(config.deployment_mapper, model);
|
||||
if (mapping) {
|
||||
availableChannels.push({
|
||||
key: row.key,
|
||||
config: config,
|
||||
mapping: mapping
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableChannels.length === 0) {
|
||||
if (!channelsResult.results || channelsResult.results.length === 0) {
|
||||
return c.text("No available channels for this token", 404);
|
||||
}
|
||||
return c.text(`Model not mapped: ${model}. Please configure deployment_mapper.`, 400);
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * availableChannels.length);
|
||||
const selectedChannel = availableChannels[randomIndex];
|
||||
const targetChannelKey = selectedChannel.key;
|
||||
const targetChannelConfig = selectedChannel.config;
|
||||
|
||||
// 统一在上层完成模型名映射
|
||||
requestBody.model = selectedChannel.mapping.deployment;
|
||||
|
||||
const proxyFetch = providerMap[targetChannelConfig.type || ""];
|
||||
if (!proxyFetch) {
|
||||
return c.text("Channel type not supported", 400);
|
||||
}
|
||||
|
||||
return proxyFetch(c, targetChannelConfig, requestBody,
|
||||
async (usage: Usage) => {
|
||||
try {
|
||||
// 使用转换后的模型名计费
|
||||
await TokenUtils.processUsage(c, apiKey, requestBody.model, targetChannelKey, targetChannelConfig, usage);
|
||||
} catch (error) {
|
||||
console.error('Error processing usage:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
api.post("/v1/responses", ResponsesProxyEndpoint)
|
||||
|
||||
// Models endpoint
|
||||
import { ModelsEndpoint } from "./models"
|
||||
api.post("/v1/chat/completions", UnifiedProxyEndpoint)
|
||||
api.post("/v1/messages", UnifiedProxyEndpoint)
|
||||
api.post("/v1/responses", UnifiedProxyEndpoint)
|
||||
api.get("/v1/models", ModelsEndpoint)
|
||||
+10
-81
@@ -1,81 +1,7 @@
|
||||
import { Context } from "hono";
|
||||
import { OpenAPIRoute } from "chanfana";
|
||||
import { z } from "zod";
|
||||
|
||||
const getApiKeyFromHeaders = (c: Context<HonoCustomType>): string | null => {
|
||||
const authHeader = c.req.raw.headers.get('Authorization');
|
||||
const xApiKey = c.req.raw.headers.get('x-api-key');
|
||||
|
||||
if (authHeader) {
|
||||
return authHeader.replace("Bearer ", "").trim();
|
||||
}
|
||||
if (xApiKey) {
|
||||
return xApiKey.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchTokenData = async (c: Context<HonoCustomType>, apiKey: string) => {
|
||||
const tokenResult = await c.env.DB.prepare(
|
||||
`SELECT * FROM api_token WHERE key = ?`
|
||||
).bind(apiKey).first();
|
||||
|
||||
if (!tokenResult || !tokenResult.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tokenData: JSON.parse(tokenResult.value as string) as ApiTokenData,
|
||||
usage: tokenResult.usage as number || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const fetchChannelsForToken = async (
|
||||
c: Context<HonoCustomType>,
|
||||
tokenData: ApiTokenData
|
||||
) => {
|
||||
const channelKeys = tokenData.channel_keys;
|
||||
|
||||
if (!channelKeys || channelKeys.length === 0) {
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config`
|
||||
).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
const channelQuery = channelKeys.map(() => '?').join(',');
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config WHERE key IN (${channelQuery})`
|
||||
).bind(...channelKeys).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
const fetchAllChannels = async (c: Context<HonoCustomType>) => {
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config`
|
||||
).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
const fetchChannelsForRequest = async (
|
||||
c: Context<HonoCustomType>,
|
||||
apiKey: string | null
|
||||
) => {
|
||||
if (!apiKey) {
|
||||
return {
|
||||
channelsResult: await fetchAllChannels(c),
|
||||
};
|
||||
}
|
||||
|
||||
const tokenInfo = await fetchTokenData(c, apiKey);
|
||||
if (!tokenInfo) {
|
||||
return {
|
||||
errorResponse: c.text("Invalid API key", 401),
|
||||
};
|
||||
}
|
||||
|
||||
const { tokenData } = tokenInfo;
|
||||
return {
|
||||
channelsResult: await fetchChannelsForToken(c, tokenData),
|
||||
};
|
||||
}
|
||||
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./shared/auth";
|
||||
|
||||
export class ModelsEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
@@ -109,12 +35,17 @@ export class ModelsEndpoint extends OpenAPIRoute {
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const apiKey = getApiKeyFromHeaders(c);
|
||||
const { channelsResult, errorResponse } = await fetchChannelsForRequest(c, apiKey);
|
||||
|
||||
if (errorResponse) {
|
||||
return errorResponse;
|
||||
if (!apiKey) {
|
||||
return c.json({ object: "list", data: [] });
|
||||
}
|
||||
|
||||
const tokenInfo = await fetchTokenData(c, apiKey);
|
||||
if (!tokenInfo) {
|
||||
return c.text("Invalid API key", 401);
|
||||
}
|
||||
|
||||
const channelsResult = await fetchChannelsForToken(c, tokenInfo.tokenData);
|
||||
|
||||
if (!channelsResult || !channelsResult.results || channelsResult.results.length === 0) {
|
||||
return c.json({
|
||||
object: "list",
|
||||
@@ -122,7 +53,6 @@ export class ModelsEndpoint extends OpenAPIRoute {
|
||||
});
|
||||
}
|
||||
|
||||
// Collect all unique models from all available channels
|
||||
const modelsSet = new Set<string>();
|
||||
|
||||
for (const row of channelsResult.results) {
|
||||
@@ -134,7 +64,6 @@ export class ModelsEndpoint extends OpenAPIRoute {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to OpenAI models format
|
||||
const models = Array.from(modelsSet).sort().map((modelId) => ({
|
||||
id: modelId,
|
||||
object: "model" as const,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Context } from "hono"
|
||||
import { streamText } from 'hono/streaming'
|
||||
import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils"
|
||||
|
||||
// 辅助函数
|
||||
const buildProxyRequest = (
|
||||
request: Request,
|
||||
reqJson: any,
|
||||
@@ -13,6 +12,8 @@ const buildProxyRequest = (
|
||||
targetUrl.pathname = url.pathname
|
||||
|
||||
const targetHeaders = new Headers(request.headers)
|
||||
targetHeaders.delete("Host")
|
||||
targetHeaders.delete("Cookie")
|
||||
targetHeaders.set("Authorization", `Bearer ${config.api_key}`)
|
||||
|
||||
return new Request(targetUrl, {
|
||||
@@ -22,80 +23,6 @@ const buildProxyRequest = (
|
||||
})
|
||||
}
|
||||
|
||||
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 = async (
|
||||
lines: string[],
|
||||
usageSaved: { value: boolean },
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
if (usageSaved.value) return
|
||||
const processedLines = lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.startsWith('data:'))
|
||||
.map(line => line.replace('data: ', '').trim())
|
||||
.filter(line => line !== '[DONE]')
|
||||
|
||||
for (const jsonContent of processedLines) {
|
||||
try {
|
||||
const res = JSON.parse(jsonContent) as OpenAIResponse
|
||||
if (!res.usage) continue
|
||||
await saveUsage(res.usage)
|
||||
usageSaved.value = true
|
||||
break
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream data:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamResponse = async (
|
||||
c: Context<HonoCustomType>,
|
||||
streamForServer: ReadableStream<any> | undefined,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
const reader = streamForServer?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No reader found in response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ""
|
||||
const usageSaved = { value: false }
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
if (!chunk.includes('\n')) continue
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
await processStreamData(lines, usageSaved, saveUsage)
|
||||
}
|
||||
|
||||
// 处理最后剩余的数据
|
||||
if (buffer.trim()) {
|
||||
await processStreamData([buffer], usageSaved, saveUsage)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
c: Context<HonoCustomType>,
|
||||
@@ -105,7 +32,6 @@ export default {
|
||||
): Promise<Response> {
|
||||
const { stream } = requestBody;
|
||||
|
||||
// 强制包含使用数据
|
||||
if (stream) {
|
||||
requestBody.stream_options = {
|
||||
...(requestBody.stream_options || {}),
|
||||
@@ -113,15 +39,9 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// model 已在上层完成映射
|
||||
|
||||
// 构建目标请求
|
||||
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
|
||||
|
||||
// 发送请求并获取响应
|
||||
const response = await fetch(proxyRequest)
|
||||
|
||||
// 处理流式响应
|
||||
if (stream) {
|
||||
const [streamForClient, streamForServer] = response.body?.tee() || []
|
||||
c.executionCtx.waitUntil(handleStreamResponse(c, streamForServer, saveUsage))
|
||||
@@ -132,7 +52,6 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理常规响应
|
||||
if (response.ok) {
|
||||
await checkoutUsageData(saveUsage, response)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Context } from "hono"
|
||||
import {
|
||||
handleStreamResponse,
|
||||
OpenAIResponsesResponse,
|
||||
OpenAIResponsesStreamEvent,
|
||||
extractUsageFromResponse,
|
||||
estimateUsageFromBodies,
|
||||
} from "./shared/usage-utils"
|
||||
} from "./shared/responses-stream-utils"
|
||||
|
||||
const buildProxyRequest = (
|
||||
request: Request,
|
||||
@@ -17,6 +17,8 @@ const buildProxyRequest = (
|
||||
targetUrl.pathname = url.pathname
|
||||
|
||||
const targetHeaders = new Headers(request.headers)
|
||||
targetHeaders.delete("Host")
|
||||
targetHeaders.delete("Cookie")
|
||||
targetHeaders.set("Authorization", `Bearer ${config.api_key}`)
|
||||
|
||||
return new Request(targetUrl, {
|
||||
@@ -26,95 +28,6 @@ const buildProxyRequest = (
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamData = async (
|
||||
lines: string[],
|
||||
usageSaved: { value: boolean },
|
||||
outputText: { value: string },
|
||||
outputLimit: number,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
if (usageSaved.value) return
|
||||
const processedLines = lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.startsWith('data:'))
|
||||
.map(line => line.replace('data:', '').trim())
|
||||
.filter(line => line !== '[DONE]')
|
||||
|
||||
for (const jsonContent of processedLines) {
|
||||
try {
|
||||
const event = JSON.parse(jsonContent) as OpenAIResponsesStreamEvent
|
||||
if (event.type === "response.output_text.delta") {
|
||||
const deltaText = typeof event.delta === "string"
|
||||
? event.delta
|
||||
: (typeof event.text === "string" ? event.text : "")
|
||||
if (deltaText) {
|
||||
if (outputText.value.length < outputLimit) {
|
||||
outputText.value += deltaText
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type !== "response.completed") {
|
||||
continue
|
||||
}
|
||||
const usage = extractUsageFromResponse(event.response)
|
||||
if (usage && !usageSaved.value) {
|
||||
await saveUsage(usage)
|
||||
usageSaved.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream data:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamResponse = async (
|
||||
c: Context<HonoCustomType>,
|
||||
streamForServer: ReadableStream<any> | undefined,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
const reader = streamForServer?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No reader found in response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ""
|
||||
const usageSaved = { value: false }
|
||||
const outputText = { value: "" }
|
||||
const outputLimit = 200_000
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
if (!chunk.includes('\n')) continue
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
await processStreamData(lines, usageSaved, outputText, outputLimit, saveUsage)
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
await processStreamData([buffer], usageSaved, outputText, outputLimit, saveUsage)
|
||||
}
|
||||
|
||||
if (!usageSaved.value) {
|
||||
const estimatedUsage = estimateUsageFromBodies(
|
||||
requestBody,
|
||||
undefined,
|
||||
outputText.value
|
||||
)
|
||||
if (estimatedUsage) {
|
||||
await saveUsage(estimatedUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
c: Context<HonoCustomType>,
|
||||
@@ -122,15 +35,14 @@ export default {
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>,
|
||||
): Promise<Response> {
|
||||
const reqJson = requestBody
|
||||
const { stream } = reqJson
|
||||
const { stream } = requestBody
|
||||
|
||||
const proxyRequest = buildProxyRequest(c.req.raw, reqJson, config)
|
||||
const proxyRequest = buildProxyRequest(c.req.raw, requestBody, config)
|
||||
const response = await fetch(proxyRequest)
|
||||
|
||||
if (stream) {
|
||||
const [streamForClient, streamForServer] = response.body?.tee() || []
|
||||
c.executionCtx.waitUntil(handleStreamResponse(c, streamForServer, reqJson, saveUsage))
|
||||
c.executionCtx.waitUntil(handleStreamResponse(c, streamForServer, requestBody, saveUsage))
|
||||
return new Response(streamForClient, {
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
@@ -145,7 +57,7 @@ export default {
|
||||
if (usage) {
|
||||
await saveUsage(usage)
|
||||
} else {
|
||||
const estimatedUsage = estimateUsageFromBodies(reqJson, resJson)
|
||||
const estimatedUsage = estimateUsageFromBodies(requestBody, resJson)
|
||||
if (estimatedUsage) {
|
||||
await saveUsage(estimatedUsage)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Context } from "hono"
|
||||
|
||||
export const getApiKeyFromHeaders = (c: Context<HonoCustomType>): string | null => {
|
||||
const authHeader = c.req.raw.headers.get('Authorization');
|
||||
const xApiKey = c.req.raw.headers.get('x-api-key');
|
||||
|
||||
if (authHeader) {
|
||||
return authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : authHeader.trim();
|
||||
}
|
||||
if (xApiKey) {
|
||||
return xApiKey.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const fetchTokenData = async (c: Context<HonoCustomType>, apiKey: string) => {
|
||||
const tokenResult = await c.env.DB.prepare(
|
||||
`SELECT * FROM api_token WHERE key = ?`
|
||||
).bind(apiKey).first();
|
||||
|
||||
if (!tokenResult || !tokenResult.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tokenData: JSON.parse(tokenResult.value as string) as ApiTokenData,
|
||||
usage: tokenResult.usage as number || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const fetchChannelsForToken = async (
|
||||
c: Context<HonoCustomType>,
|
||||
tokenData: ApiTokenData
|
||||
) => {
|
||||
const channelKeys = tokenData.channel_keys;
|
||||
|
||||
if (!channelKeys || channelKeys.length === 0) {
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config`
|
||||
).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
const channelQuery = channelKeys.map(() => '?').join(',');
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config WHERE key IN (${channelQuery})`
|
||||
).bind(...channelKeys).all<ChannelConfigRow>();
|
||||
}
|
||||
|
||||
export const fetchAllChannels = async (c: Context<HonoCustomType>) => {
|
||||
return await c.env.DB.prepare(
|
||||
`SELECT key, value FROM channel_config`
|
||||
).all<ChannelConfigRow>();
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Context } from "hono"
|
||||
import { getApiKeyFromHeaders, fetchTokenData, fetchChannelsForToken } from "./auth"
|
||||
import { RouteId, getRoutePolicy } from "./route-policy"
|
||||
import { findDeploymentMapping } from "../../utils"
|
||||
import { TokenUtils } from "../../admin/token_utils"
|
||||
|
||||
export type ChannelResolution = {
|
||||
channel: { key: string; config: ChannelConfig }
|
||||
requestBody: any
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
}
|
||||
|
||||
export const resolveChannel = async (
|
||||
c: Context<HonoCustomType>,
|
||||
routeId: RouteId
|
||||
): Promise<ChannelResolution | Response> => {
|
||||
const apiKey = getApiKeyFromHeaders(c);
|
||||
if (!apiKey) {
|
||||
return c.text("Authorization header or x-api-key not found", 401);
|
||||
}
|
||||
|
||||
const tokenInfo = await fetchTokenData(c, apiKey);
|
||||
if (!tokenInfo) {
|
||||
return c.text("Invalid API key", 401);
|
||||
}
|
||||
|
||||
const { tokenData, usage } = tokenInfo;
|
||||
|
||||
if (usage >= tokenData.total_quota) {
|
||||
return c.text("Quota exceeded", 402);
|
||||
}
|
||||
|
||||
const channelsResult = await fetchChannelsForToken(c, tokenData);
|
||||
|
||||
if (!channelsResult.results || channelsResult.results.length === 0) {
|
||||
return c.text("No available channels for this token", 401);
|
||||
}
|
||||
|
||||
let requestBody: any;
|
||||
try {
|
||||
requestBody = await c.req.json();
|
||||
} catch (error) {
|
||||
return c.text("Invalid JSON body", 400);
|
||||
}
|
||||
const model = requestBody.model;
|
||||
if (!model) {
|
||||
return c.text("Model is required", 400);
|
||||
}
|
||||
|
||||
const policy = getRoutePolicy(routeId);
|
||||
const allowedTypes = policy.allowedTypes;
|
||||
|
||||
const availableChannels: Array<{ key: string, config: ChannelConfig, mapping: { pattern: string, deployment: string } }> = [];
|
||||
|
||||
for (const row of channelsResult.results) {
|
||||
const config = JSON.parse(row.value) as ChannelConfig;
|
||||
|
||||
if (allowedTypes && (!config.type || !allowedTypes.includes(config.type))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapping = findDeploymentMapping(config.deployment_mapper, model);
|
||||
if (mapping) {
|
||||
availableChannels.push({
|
||||
key: row.key,
|
||||
config: config,
|
||||
mapping: mapping
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableChannels.length === 0) {
|
||||
return c.text(`Model not mapped: ${model}. Please configure deployment_mapper.`, 400);
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * availableChannels.length);
|
||||
const selectedChannel = availableChannels[randomIndex];
|
||||
const targetChannelKey = selectedChannel.key;
|
||||
const targetChannelConfig = selectedChannel.config;
|
||||
|
||||
requestBody.model = selectedChannel.mapping.deployment;
|
||||
|
||||
if (!targetChannelConfig.type) {
|
||||
return c.text("Channel type invalid", 400);
|
||||
}
|
||||
|
||||
const saveUsage = async (usage: Usage) => {
|
||||
try {
|
||||
await TokenUtils.processUsage(c, apiKey, requestBody.model, targetChannelKey, targetChannelConfig, usage);
|
||||
} catch (error) {
|
||||
console.error('Error processing usage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
channel: { key: targetChannelKey, config: targetChannelConfig },
|
||||
requestBody,
|
||||
saveUsage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Context } from "hono"
|
||||
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
|
||||
export const processStreamData = async (
|
||||
lines: string[],
|
||||
usageSaved: { value: boolean },
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
if (usageSaved.value) return
|
||||
const processedLines = lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.startsWith('data:'))
|
||||
.map(line => line.replace('data: ', '').trim())
|
||||
.filter(line => line !== '[DONE]')
|
||||
|
||||
for (const jsonContent of processedLines) {
|
||||
try {
|
||||
const res = JSON.parse(jsonContent) as OpenAIResponse
|
||||
if (!res.usage) continue
|
||||
await saveUsage(res.usage)
|
||||
usageSaved.value = true
|
||||
break
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream data:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const handleStreamResponse = async (
|
||||
c: Context<HonoCustomType>,
|
||||
streamForServer: ReadableStream<any> | undefined,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
const reader = streamForServer?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No reader found in response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ""
|
||||
const usageSaved = { value: false }
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
if (!chunk.includes('\n')) continue
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
await processStreamData(lines, usageSaved, saveUsage)
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
await processStreamData([buffer], usageSaved, saveUsage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Context } from "hono"
|
||||
|
||||
import azureOpenaiProxy from "../azure-openai-proxy"
|
||||
import openaiProxy from "../openai-proxy"
|
||||
import claudeProxy from "../claude-proxy"
|
||||
import claudeToOpenaiProxy from "../claude-to-openai-proxy"
|
||||
import openaiResponsesProxy from "../openai-responses-proxy"
|
||||
import azureOpenaiResponsesProxy from "../azure-openai-responses-proxy"
|
||||
|
||||
type ProviderFetch = (
|
||||
c: Context<HonoCustomType>,
|
||||
config: ChannelConfig,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
) => Promise<Response>
|
||||
|
||||
const providerMap: Record<string, ProviderFetch> = {
|
||||
"azure-openai": azureOpenaiProxy.fetch,
|
||||
"openai": openaiProxy.fetch,
|
||||
"claude": claudeProxy.fetch,
|
||||
"claude-to-openai": claudeToOpenaiProxy.fetch,
|
||||
"openai-responses": openaiResponsesProxy.fetch,
|
||||
"azure-openai-responses": azureOpenaiResponsesProxy.fetch,
|
||||
}
|
||||
|
||||
export const getProvider = (type: string): ProviderFetch | undefined => {
|
||||
return providerMap[type]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Context } from "hono"
|
||||
import {
|
||||
OpenAIResponsesResponse,
|
||||
OpenAIResponsesStreamEvent,
|
||||
extractUsageFromResponse,
|
||||
estimateUsageFromBodies,
|
||||
} from "./usage-utils"
|
||||
|
||||
export const processStreamData = async (
|
||||
lines: string[],
|
||||
usageSaved: { value: boolean },
|
||||
outputText: { value: string },
|
||||
outputLimit: number,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
if (usageSaved.value) return
|
||||
const processedLines = lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.startsWith('data:'))
|
||||
.map(line => line.replace('data:', '').trim())
|
||||
.filter(line => line !== '[DONE]')
|
||||
|
||||
for (const jsonContent of processedLines) {
|
||||
try {
|
||||
const event = JSON.parse(jsonContent) as OpenAIResponsesStreamEvent
|
||||
if (event.type === "response.output_text.delta") {
|
||||
const deltaText = typeof event.delta === "string"
|
||||
? event.delta
|
||||
: (typeof event.text === "string" ? event.text : "")
|
||||
if (deltaText) {
|
||||
if (outputText.value.length < outputLimit) {
|
||||
outputText.value += deltaText
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type !== "response.completed") {
|
||||
continue
|
||||
}
|
||||
const usage = extractUsageFromResponse(event.response)
|
||||
if (usage && !usageSaved.value) {
|
||||
await saveUsage(usage)
|
||||
usageSaved.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream data:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const handleStreamResponse = async (
|
||||
c: Context<HonoCustomType>,
|
||||
streamForServer: ReadableStream<any> | undefined,
|
||||
requestBody: any,
|
||||
saveUsage: (usage: Usage) => Promise<void>
|
||||
): Promise<void> => {
|
||||
const reader = streamForServer?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No reader found in response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ""
|
||||
const usageSaved = { value: false }
|
||||
const outputText = { value: "" }
|
||||
const outputLimit = 200_000
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
if (!chunk.includes('\n')) continue
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
await processStreamData(lines, usageSaved, outputText, outputLimit, saveUsage)
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
await processStreamData([buffer], usageSaved, outputText, outputLimit, saveUsage)
|
||||
}
|
||||
|
||||
if (!usageSaved.value) {
|
||||
const estimatedUsage = estimateUsageFromBodies(
|
||||
requestBody,
|
||||
undefined,
|
||||
outputText.value
|
||||
)
|
||||
if (estimatedUsage) {
|
||||
await saveUsage(estimatedUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { OpenAIResponsesResponse, extractUsageFromResponse, estimateUsageFromBodies }
|
||||
@@ -0,0 +1,22 @@
|
||||
export type RouteId = "chat-completions" | "messages" | "responses"
|
||||
|
||||
type RoutePolicy = {
|
||||
allowedTypes: ChannelType[] | null
|
||||
}
|
||||
|
||||
const ROUTE_POLICIES: Record<RouteId, RoutePolicy> = {
|
||||
"chat-completions": { allowedTypes: null },
|
||||
"messages": { allowedTypes: null },
|
||||
"responses": { allowedTypes: ["openai-responses", "azure-openai-responses"] },
|
||||
}
|
||||
|
||||
export const resolveRouteId = (pathname: string): RouteId | null => {
|
||||
if (pathname.endsWith("/chat/completions")) return "chat-completions"
|
||||
if (pathname.endsWith("/messages")) return "messages"
|
||||
if (pathname.endsWith("/responses")) return "responses"
|
||||
return null
|
||||
}
|
||||
|
||||
export const getRoutePolicy = (routeId: RouteId): RoutePolicy => {
|
||||
return ROUTE_POLICIES[routeId]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Mock upstream server that simulates OpenAI / Azure OpenAI / OpenAI Responses APIs.
|
||||
* Usage: npx tsx tests/mock-upstream.ts
|
||||
* Listens on port 9999.
|
||||
*/
|
||||
|
||||
const PORT = 9999
|
||||
|
||||
// --- OpenAI Chat Completions (non-stream) ---
|
||||
const openaiChatResponse = (model: string) => ({
|
||||
id: "chatcmpl-mock-001",
|
||||
object: "chat.completion",
|
||||
created: 1700000000,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Mock response from " + model },
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 },
|
||||
})
|
||||
|
||||
// --- OpenAI Chat Completions (stream) ---
|
||||
const openaiChatStreamChunks = (model: string) => [
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-mock-001", object: "chat.completion.chunk", model, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], usage: null })}\n\n`,
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-mock-001", object: "chat.completion.chunk", model, choices: [{ index: 0, delta: { content: "Mock " }, finish_reason: null }], usage: null })}\n\n`,
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-mock-001", object: "chat.completion.chunk", model, choices: [{ index: 0, delta: { content: "stream " }, finish_reason: null }], usage: null })}\n\n`,
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-mock-001", object: "chat.completion.chunk", model, choices: [{ index: 0, delta: { content: "response" }, finish_reason: null }], usage: null })}\n\n`,
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-mock-001", object: "chat.completion.chunk", model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } })}\n\n`,
|
||||
`data: [DONE]\n\n`,
|
||||
]
|
||||
|
||||
// --- OpenAI Responses (non-stream) ---
|
||||
const openaiResponsesResponse = (model: string) => ({
|
||||
id: "resp-mock-001",
|
||||
object: "response",
|
||||
created_at: 1700000000,
|
||||
status: "completed",
|
||||
model,
|
||||
output: [{
|
||||
id: "msg-mock-001",
|
||||
type: "message",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Mock responses output" }],
|
||||
role: "assistant",
|
||||
}],
|
||||
usage: { input_tokens: 8, output_tokens: 5, total_tokens: 13 },
|
||||
})
|
||||
|
||||
// --- OpenAI Responses (stream) ---
|
||||
const openaiResponsesStreamChunks = (model: string) => {
|
||||
const respId = "resp-mock-001"
|
||||
const msgId = "msg-mock-001"
|
||||
return [
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: "response.created", response: { id: respId, object: "response", model, status: "in_progress", output: [], usage: null } })}\n\n`,
|
||||
`event: response.output_item.added\ndata: ${JSON.stringify({ type: "response.output_item.added", item: { id: msgId, type: "message", status: "in_progress", content: [], role: "assistant" }, output_index: 0 })}\n\n`,
|
||||
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: "Mock ", item_id: msgId, output_index: 0, content_index: 0 })}\n\n`,
|
||||
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: "stream", item_id: msgId, output_index: 0, content_index: 0 })}\n\n`,
|
||||
`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { id: respId, object: "response", model, status: "completed", output: [{ id: msgId, type: "message", status: "completed", content: [{ type: "output_text", text: "Mock stream" }], role: "assistant" }], usage: { input_tokens: 8, output_tokens: 4, total_tokens: 12 } } })}\n\n`,
|
||||
]
|
||||
}
|
||||
|
||||
// --- Claude Messages (non-stream) ---
|
||||
const claudeMessagesResponse = (model: string) => ({
|
||||
id: "msg-mock-claude-001",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text: "Mock Claude response" }],
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 12, output_tokens: 6 },
|
||||
})
|
||||
|
||||
// --- Claude Messages (stream) ---
|
||||
const claudeMessagesStreamChunks = (model: string) => [
|
||||
`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "msg-mock-claude-001", type: "message", role: "assistant", model, content: [], stop_reason: null, usage: { input_tokens: 12, output_tokens: 0 } } })}\n\n`,
|
||||
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`,
|
||||
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Mock Claude stream" } })}\n\n`,
|
||||
`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`,
|
||||
`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 4 } })}\n\n`,
|
||||
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
||||
]
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
const path = url.pathname
|
||||
const body = req.method === "POST" ? await req.json().catch(() => ({})) : {}
|
||||
const model = (body as any).model || "mock-model"
|
||||
const isStream = (body as any).stream === true
|
||||
|
||||
console.log(`[MOCK] ${req.method} ${path} model=${model} stream=${isStream}`)
|
||||
|
||||
// OpenAI Chat Completions
|
||||
if (path.endsWith("/chat/completions")) {
|
||||
if (isStream) {
|
||||
const chunks = openaiChatStreamChunks(model)
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(new TextEncoder().encode(chunk))
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
}
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
return new Response(stream, {
|
||||
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
||||
})
|
||||
}
|
||||
return Response.json(openaiChatResponse(model))
|
||||
}
|
||||
|
||||
// OpenAI Responses API
|
||||
if (path.endsWith("/responses")) {
|
||||
if (isStream) {
|
||||
const chunks = openaiResponsesStreamChunks(model)
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(new TextEncoder().encode(chunk))
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
}
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
return new Response(stream, {
|
||||
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
||||
})
|
||||
}
|
||||
return Response.json(openaiResponsesResponse(model))
|
||||
}
|
||||
|
||||
// Claude Messages API
|
||||
if (path.endsWith("/messages")) {
|
||||
if (isStream) {
|
||||
const chunks = claudeMessagesStreamChunks(model)
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(new TextEncoder().encode(chunk))
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
}
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
return new Response(stream, {
|
||||
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
||||
})
|
||||
}
|
||||
return Response.json(claudeMessagesResponse(model))
|
||||
}
|
||||
|
||||
return new Response("Mock: unknown route " + path, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[MOCK] Upstream mock server running on http://localhost:${PORT}`)
|
||||
Executable
+207
@@ -0,0 +1,207 @@
|
||||
#!/bin/bash
|
||||
# E2E tests against mock upstream
|
||||
# Prerequisites: mock-upstream running on :9999, wrangler dev on :8788
|
||||
|
||||
API="http://localhost:8788"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
assert() {
|
||||
local name="$1" expected="$2" actual="$3"
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if echo "$actual" | grep -q "$expected"; then
|
||||
echo " ✅ #$TOTAL $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ #$TOTAL $name"
|
||||
echo " expected: $expected"
|
||||
echo " actual: $(echo "$actual" | head -1)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_http() {
|
||||
local name="$1" expected_code="$2" expected_body="$3" actual="$4"
|
||||
local code=$(echo "$actual" | tail -1)
|
||||
local body=$(echo "$actual" | sed '$d')
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if [[ "$code" == "$expected_code" ]] && echo "$body" | grep -q "$expected_body"; then
|
||||
echo " ✅ #$TOTAL $name [HTTP $code]"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ #$TOTAL $name [HTTP $code]"
|
||||
echo " expected: HTTP $expected_code + '$expected_body'"
|
||||
echo " actual: $(echo "$body" | head -1)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
do_get() { curl -s -w "\n%{http_code}" "$@"; }
|
||||
do_post() { curl -s -w "\n%{http_code}" -X POST "$@"; }
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " E2E Tests with Mock Upstream"
|
||||
echo "========================================="
|
||||
|
||||
# ---- /v1/models ----
|
||||
echo ""
|
||||
echo "--- GET /v1/models ---"
|
||||
|
||||
R=$(do_get "$API/v1/models")
|
||||
assert_http "no key → empty list" "200" '"data":\[\]' "$R"
|
||||
|
||||
R=$(do_get -H "Authorization: Bearer bad" "$API/v1/models")
|
||||
assert_http "invalid key → 401" "401" "Invalid API key" "$R"
|
||||
|
||||
R=$(do_get -H "Authorization: Bearer sk-mock-all" "$API/v1/models")
|
||||
assert_http "full token → has gpt-4o" "200" "gpt-4o" "$R"
|
||||
|
||||
R=$(do_get -H "x-api-key: sk-mock-openai" "$API/v1/models")
|
||||
assert_http "limited token → has gpt-4 only" "200" "gpt-4" "$R"
|
||||
|
||||
# ---- /v1/chat/completions errors ----
|
||||
echo ""
|
||||
echo "--- POST /v1/chat/completions (errors) ---"
|
||||
|
||||
R=$(do_post "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "no auth → 401" "401" "Authorization header" "$R"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer bad" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "invalid key → 401" "401" "Invalid API key" "$R"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d 'not-json')
|
||||
assert_http "invalid JSON → 400" "400" "Invalid JSON body" "$R"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"messages":[]}')
|
||||
assert_http "no model → 400" "400" "Model is required" "$R"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"nonexistent","messages":[]}')
|
||||
assert_http "unmapped model → 400" "400" "Model not mapped" "$R"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"claude-3-opus","messages":[]}')
|
||||
assert_http "limited token, wrong model → 400" "400" "Model not mapped" "$R"
|
||||
|
||||
# ---- /v1/chat/completions via openai provider (non-stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/chat/completions (openai, non-stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "openai non-stream → 200 + usage" "200" "Mock response" "$R"
|
||||
|
||||
# ---- /v1/chat/completions via openai provider (stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/chat/completions (openai, stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "openai stream → 200 + SSE" "200" "data:" "$R"
|
||||
|
||||
# ---- /v1/chat/completions via azure-openai (non-stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/chat/completions (azure-openai, non-stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o-azure","messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "azure non-stream → 200" "200" "Mock response" "$R"
|
||||
|
||||
# ---- /v1/chat/completions via azure-openai (stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/chat/completions (azure-openai, stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o-azure","stream":true,"messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "azure stream → 200 + SSE" "200" "data:" "$R"
|
||||
|
||||
# ---- /v1/messages via claude (non-stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/messages (claude, non-stream) ---"
|
||||
|
||||
R=$(do_post -H "x-api-key: sk-mock-all" "$API/v1/messages" -H "Content-Type: application/json" -d '{"model":"claude-3-opus","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "claude non-stream → 200" "200" "Mock Claude response" "$R"
|
||||
|
||||
# ---- /v1/messages via claude (stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/messages (claude, stream) ---"
|
||||
|
||||
R=$(do_post -H "x-api-key: sk-mock-all" "$API/v1/messages" -H "Content-Type: application/json" -d '{"model":"claude-3-opus","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "claude stream → 200 + SSE" "200" "event:" "$R"
|
||||
|
||||
# ---- /v1/messages via claude-to-openai (non-stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/messages (claude-to-openai, non-stream) ---"
|
||||
|
||||
R=$(do_post -H "x-api-key: sk-mock-all" "$API/v1/messages" -H "Content-Type: application/json" -d '{"model":"claude-via-openai","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "c2o non-stream → 200" "200" "message" "$R"
|
||||
|
||||
# ---- /v1/responses errors ----
|
||||
echo ""
|
||||
echo "--- POST /v1/responses (errors) ---"
|
||||
|
||||
R=$(do_post "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o-resp","input":"hi"}')
|
||||
assert_http "no auth → 401" "401" "Authorization header" "$R"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o","input":"hi"}')
|
||||
assert_http "non-responses model → 400" "400" "Model not mapped" "$R"
|
||||
|
||||
# ---- /v1/responses via openai-responses (non-stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/responses (openai-responses, non-stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o-resp","input":"hi"}')
|
||||
assert_http "responses non-stream → 200 + usage" "200" "Mock responses output" "$R"
|
||||
|
||||
# ---- /v1/responses via openai-responses (stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/responses (openai-responses, stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o-resp","stream":true,"input":"hi"}')
|
||||
assert_http "responses stream → 200 + SSE" "200" "event:" "$R"
|
||||
|
||||
# ---- /v1/responses via azure-openai-responses (non-stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/responses (azure-openai-responses, non-stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o-azure-resp","input":"hi"}')
|
||||
assert_http "azure responses non-stream → 200" "200" "Mock responses output" "$R"
|
||||
|
||||
# ---- /v1/responses via azure-openai-responses (stream) ----
|
||||
echo ""
|
||||
echo "--- POST /v1/responses (azure-openai-responses, stream) ---"
|
||||
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o-azure-resp","stream":true,"input":"hi"}')
|
||||
assert_http "azure responses stream → 200 + SSE" "200" "event:" "$R"
|
||||
|
||||
# ---- Quota ----
|
||||
echo ""
|
||||
echo "--- Quota check ---"
|
||||
|
||||
npx wrangler d1 execute awsl-one-api --local --command "UPDATE api_token SET usage = 99999999 WHERE key = 'sk-mock-all'" > /dev/null 2>&1
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[]}')
|
||||
assert_http "quota exceeded → 402" "402" "Quota exceeded" "$R"
|
||||
npx wrangler d1 execute awsl-one-api --local --command "UPDATE api_token SET usage = 0 WHERE key = 'sk-mock-all'" > /dev/null 2>&1
|
||||
|
||||
# ---- Error recovery ----
|
||||
echo ""
|
||||
echo "--- Error recovery ---"
|
||||
|
||||
do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d 'bad' > /dev/null 2>&1
|
||||
R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}')
|
||||
assert_http "recover after error → 200" "200" "Mock response" "$R"
|
||||
|
||||
# ---- Summary ----
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " Results: $PASS/$TOTAL PASS, $FAIL FAIL"
|
||||
echo "========================================="
|
||||
|
||||
# Cleanup test data
|
||||
echo ""
|
||||
echo "Cleaning up test data..."
|
||||
for key in test-openai test-azure test-claude test-c2o test-responses test-azure-resp; do
|
||||
npx wrangler d1 execute awsl-one-api --local --command "DELETE FROM channel_config WHERE key = '$key'" > /dev/null 2>&1
|
||||
done
|
||||
for key in sk-mock-all sk-mock-openai sk-mock-quota; do
|
||||
npx wrangler d1 execute awsl-one-api --local --command "DELETE FROM api_token WHERE key = '$key'" > /dev/null 2>&1
|
||||
done
|
||||
echo "Cleanup done."
|
||||
|
||||
exit $FAIL
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Setup test data in local D1 for mock upstream testing
|
||||
# All channels point to http://localhost:9999
|
||||
|
||||
MOCK="http://localhost:9999"
|
||||
|
||||
echo "Setting up test channels and tokens..."
|
||||
|
||||
# Channel: openai type
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-openai', '{\"name\":\"test-openai\",\"type\":\"openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o\":\"gpt-4o\",\"gpt-4\":\"gpt-4\"}}')"
|
||||
|
||||
# Channel: azure-openai type
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure', '{\"name\":\"test-azure\",\"type\":\"azure-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2024-02-01\",\"deployment_mapper\":{\"gpt-4o-azure\":\"gpt-4o-deployment\"}}')"
|
||||
|
||||
# Channel: claude type
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-claude', '{\"name\":\"test-claude\",\"type\":\"claude\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-3-opus\":\"claude-3-opus-20240229\"}}')"
|
||||
|
||||
# Channel: claude-to-openai type
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-c2o', '{\"name\":\"test-c2o\",\"type\":\"claude-to-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-via-openai\":\"claude-3-opus\"}}')"
|
||||
|
||||
# Channel: openai-responses type
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-responses', '{\"name\":\"test-responses\",\"type\":\"openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o-resp\":\"gpt-4o\"}}')"
|
||||
|
||||
# Channel: azure-openai-responses type
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure-resp', '{\"name\":\"test-azure-resp\",\"type\":\"azure-openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2025-01-01\",\"deployment_mapper\":{\"gpt-4o-azure-resp\":\"gpt-4o-deployment\"}}')"
|
||||
|
||||
# Token: access to all test channels only
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-all', '{\"name\":\"mock-all\",\"channel_keys\":[\"test-openai\",\"test-azure\",\"test-claude\",\"test-c2o\",\"test-responses\",\"test-azure-resp\"],\"total_quota\":99999999}', 0)"
|
||||
|
||||
# Token: limited to openai only
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-openai', '{\"name\":\"mock-openai\",\"channel_keys\":[\"test-openai\"],\"total_quota\":99999999}', 0)"
|
||||
|
||||
# Token: quota almost full
|
||||
npx wrangler d1 execute awsl-one-api --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-quota', '{\"name\":\"mock-quota\",\"channel_keys\":[\"test-openai\"],\"total_quota\":100}', 99)"
|
||||
|
||||
echo "Test data setup complete!"
|
||||
Reference in new issue
Block a user