feat(api): API 测试 增强为 操练场,支持更多参数配置与调试

- 添加了对多个 API 参数的支持,包括 temperature、top_p、frequency_penalty 等。
- 改进了请求体的构建逻辑,支持更复杂的请求参数。
- 增加了对频道的支持,确保 API 调用时的频道权限验证。
- 优化了消息处理和响应显示,支持流式响应和消息编辑功能。
This commit is contained in:
Jeff Gu committed 2026-04-25 16:21:57 +08:00
1 parent cbe5d9d8f7
commit fa5fed56d9
9 files changed
+1031 -313

No files matched your search

+70 -5
View File
@@ -38,6 +38,19 @@ type RequestBehavior = {
skipUnauthorizedHandler?: boolean
}
type TestApiOptions = {
channelKey?: string
}
type TestApiStreamOptions = TestApiOptions & {
signal?: AbortSignal
onChunk: (chunk: string) => void
}
type TestApiStreamResult = {
status: number
}
const isAdminEndpoint = (endpoint: string): boolean => endpoint.startsWith('/api/admin/')
export function setErrorHandlers(handlers: {
@@ -290,15 +303,20 @@ export const apiClient = {
),
// API Test - 使用自定义 token,不走通用拦截器
testApi: async (endpoint: string, token: string, body: unknown): Promise<TestResponse> => {
testApi: async (endpoint: string, token: string, body: unknown, options: TestApiOptions = {}): Promise<TestResponse> => {
const url = `${BASE_URL}${endpoint}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
}
if (options.channelKey) {
headers['x-channel-key'] = options.channelKey
}
const response = await fetch(url, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
headers,
body: JSON.stringify(body),
})
@@ -320,6 +338,53 @@ export const apiClient = {
return responseInterceptor(response)
},
testApiStream: async (endpoint: string, token: string, body: unknown, options: TestApiStreamOptions): Promise<TestApiStreamResult> => {
const url = `${BASE_URL}${endpoint}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
}
if (options.channelKey) {
headers['x-channel-key'] = options.channelKey
}
const response = await fetch(url, {
method: 'POST',
credentials: 'omit',
signal: options.signal,
headers,
body: JSON.stringify(body),
})
if (!response.ok) {
await responseInterceptor(response)
}
if (!response.body) {
throw new ApiError('Response stream is not available', response.status)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
options.onChunk(decoder.decode(value, { stream: true }))
}
const tail = decoder.decode()
if (tail) {
options.onChunk(tail)
}
} finally {
reader.releaseLock()
}
return { status: response.status }
},
// Auth check
checkAuth: () => request<ApiResponse>('/api/admin/channel', { method: 'GET' }),
+2 -2
View File
@@ -143,8 +143,8 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(option.value)}
>
<span className="text-sm font-medium">{label}</span>
{showDescription && <span className="text-xs text-muted-foreground">{option.description}</span>}
<span className="w-full truncate text-sm font-medium">{label}</span>
{showDescription && <span className="w-full truncate text-xs text-muted-foreground">{option.description}</span>}
</button>
);
})}
+33 -8
View File
@@ -51,7 +51,7 @@
"tokens": "Tokens",
"pricing": "Pricing",
"usageLogs": "Usage Logs",
"apiTest": "API Test",
"apiTest": "Playground",
"settings": "Settings",
"theme": "Theme",
"language": "Language",
@@ -381,25 +381,50 @@
"detailChannel": "Channel"
},
"apiTest": {
"title": "API Test",
"description": "Test API connections and configurations",
"title": "Playground",
"description": "Debug multi-turn conversations with a selected token, model, and channel",
"request": "Request",
"response": "Response",
"endpoint": "Endpoint",
"apiToken": "API Token",
"model": "Model",
"modelPlaceholder": "Select or enter model name",
"selectTokenFirst": "Select an API token first",
"modelRequired": "Please select a model",
"channel": "Channel",
"autoChannel": "Auto-select channel",
"noMatchingChannels": "No matching channels",
"requestBody": "Request Body",
"send": "Send Request",
"playgroundParams": "Playground Parameters",
"temperature": "Temperature",
"topP": "Top P",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"stream": "Stream",
"maxTokens": "Max Tokens",
"seed": "Seed",
"paramsUnsupported": "This endpoint does not support visual parameters. You can edit the custom request body directly.",
"conversation": "Conversation",
"conversationEmpty": "Select an API token and model, then send your first message.",
"messagePlaceholder": "Type a message, Ctrl/⌘ + Enter to send",
"user": "User",
"assistant": "Assistant",
"editMessage": "Edit",
"retryMessage": "Retry",
"viewRawResponse": "Response Body",
"rawResponse": "Raw Response Body",
"emptyReadableResponse": "The response has no readable text content. Open the raw response body for details.",
"responseFailed": "Failed",
"streaming": "Streaming",
"stopGeneration": "Stop Generation",
"generationStopped": "Generation stopped",
"send": "Send",
"sending": "Sending...",
"noMatchingTokens": "No matching tokens",
"noMatchingModels": "No matching models",
"tokenRequired": "Please enter an API token",
"bodyJsonError": "Invalid request body JSON",
"copyResponse": "Copy response",
"audioSuccess": "Audio response received",
"downloadAudio": "Download Audio",
"responseEmpty": "Send a request to see the response here"
"copyResponse": "Copy response"
},
"settings": {
"title": "Settings",
+33 -8
View File
@@ -51,7 +51,7 @@
"tokens": "令牌管理",
"pricing": "定价管理",
"usageLogs": "使用日志",
"apiTest": "API 测试",
"apiTest": "操练场",
"settings": "系统设置",
"theme": "主题",
"language": "语言",
@@ -381,25 +381,50 @@
"detailChannel": "渠道"
},
"apiTest": {
"title": "API 测试",
"description": "测试 API 连接和配置",
"title": "操练场",
"description": "用指定令牌、模型和渠道进行多轮会话调试",
"request": "请求",
"response": "响应",
"endpoint": "端点",
"apiToken": "API 令牌",
"model": "模型",
"modelPlaceholder": "选择或输入模型名称",
"selectTokenFirst": "请先选择 API 令牌",
"modelRequired": "请选择模型",
"channel": "渠道",
"autoChannel": "自动选择渠道",
"noMatchingChannels": "没有匹配的渠道",
"requestBody": "请求体",
"send": "发送请求",
"playgroundParams": "操练参数",
"temperature": "Temperature",
"topP": "Top P",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"stream": "流式输出",
"maxTokens": "Max Tokens",
"seed": "Seed",
"paramsUnsupported": "当前端点不支持可视化调参,可直接编辑自定义请求体。",
"conversation": "会话",
"conversationEmpty": "选择 API 令牌和模型后,发送第一条消息开始操练。",
"messagePlaceholder": "输入消息,Ctrl/⌘ + Enter 发送",
"user": "用户",
"assistant": "助手",
"editMessage": "编辑",
"retryMessage": "重发",
"viewRawResponse": "响应体",
"rawResponse": "真实响应体",
"emptyReadableResponse": "响应中没有可直接展示的文字内容,请查看真实响应体。",
"responseFailed": "失败",
"streaming": "流式输出中",
"stopGeneration": "停止生成",
"generationStopped": "已停止生成",
"send": "发送",
"sending": "发送中...",
"noMatchingTokens": "没有匹配的令牌",
"noMatchingModels": "没有匹配的模型",
"tokenRequired": "请输入 API 令牌",
"bodyJsonError": "请求体 JSON 格式错误",
"copyResponse": "复制响应",
"audioSuccess": "音频返回成功",
"downloadAudio": "下载音频",
"responseEmpty": "发送请求后在此查看响应"
"copyResponse": "复制响应"
},
"settings": {
"title": "系统设置",
+33 -8
View File
@@ -51,7 +51,7 @@
"tokens": "令牌管理",
"pricing": "定價管理",
"usageLogs": "使用日誌",
"apiTest": "API 測試",
"apiTest": "操練場",
"settings": "系統設定",
"theme": "主題",
"language": "語言",
@@ -381,25 +381,50 @@
"detailChannel": "渠道"
},
"apiTest": {
"title": "API 測試",
"description": "測試 API 連接和設定",
"title": "操練場",
"description": "用指定令牌、模型和渠道進行多輪會話調試",
"request": "請求",
"response": "回應",
"endpoint": "端點",
"apiToken": "API 令牌",
"model": "模型",
"modelPlaceholder": "選擇或輸入模型名稱",
"selectTokenFirst": "請先選擇 API 令牌",
"modelRequired": "請選擇模型",
"channel": "渠道",
"autoChannel": "自動選擇渠道",
"noMatchingChannels": "沒有匹配的渠道",
"requestBody": "請求體",
"send": "傳送請求",
"playgroundParams": "操練參數",
"temperature": "Temperature",
"topP": "Top P",
"frequencyPenalty": "Frequency Penalty",
"presencePenalty": "Presence Penalty",
"stream": "串流輸出",
"maxTokens": "Max Tokens",
"seed": "Seed",
"paramsUnsupported": "目前端點不支援視覺化調參,可直接編輯自訂請求體。",
"conversation": "會話",
"conversationEmpty": "選擇 API 令牌和模型後,傳送第一條訊息開始操練。",
"messagePlaceholder": "輸入訊息,Ctrl/⌘ + Enter 傳送",
"user": "使用者",
"assistant": "助手",
"editMessage": "編輯",
"retryMessage": "重發",
"viewRawResponse": "響應體",
"rawResponse": "真實響應體",
"emptyReadableResponse": "響應中沒有可直接展示的文字內容,請查看真實響應體。",
"responseFailed": "失敗",
"streaming": "串流輸出中",
"stopGeneration": "停止生成",
"generationStopped": "已停止生成",
"send": "傳送",
"sending": "傳送中...",
"noMatchingTokens": "沒有匹配的令牌",
"noMatchingModels": "沒有匹配的模型",
"tokenRequired": "請輸入 API 令牌",
"bodyJsonError": "請求體 JSON 格式錯誤",
"copyResponse": "複製回應",
"audioSuccess": "音訊返回成功",
"downloadAudio": "下載音訊",
"responseEmpty": "傳送請求後在此查看回應"
"copyResponse": "複製回應"
},
"settings": {
"title": "系統設定",
+20 -3
View File
@@ -113,18 +113,35 @@ export const getUniqueModelNamesFromChannels = (channels: Channel[]): string[] =
return Array.from(modelNames).sort()
}
export const getModelNamesForToken = (tokenKey: string, tokens: Token[], channels: Channel[]): string[] => {
export const getChannelsForToken = (tokenKey: string, tokens: Token[], channels: Channel[]): Channel[] => {
const matchedToken = tokens.find((token) => token.key === tokenKey)
if (!matchedToken) {
return getUniqueModelNamesFromChannels(channels)
return []
}
const tokenConfig = parseTokenConfig(matchedToken)
const allowedChannelKeys = tokenConfig.channel_keys || []
const targetChannels = allowedChannelKeys.length === 0
return allowedChannelKeys.length === 0
? channels
: channels.filter((channel) => allowedChannelKeys.includes(channel.key))
}
export const channelSupportsModel = (channel: Channel, modelName: string): boolean => {
if (!modelName) {
return true
}
const config = parseChannelConfig(channel)
return getChannelModels(config)
.filter((model) => isChannelModelEnabled(model))
.some((model) => model.name === modelName)
}
export const getModelNamesForChannels = (channels: Channel[]): string[] => getUniqueModelNamesFromChannels(channels)
export const getModelNamesForToken = (tokenKey: string, tokens: Token[], channels: Channel[]): string[] => {
const targetChannels = getChannelsForToken(tokenKey, tokens, channels)
return getUniqueModelNamesFromChannels(targetChannels)
}
File diff suppressed because it is too large. Load diff
+3
View File
@@ -106,6 +106,9 @@ const convertClaudeToOpenAIRequest = (reqJson: any): any => {
max_tokens: reqJson.max_tokens,
temperature: reqJson.temperature,
top_p: reqJson.top_p,
frequency_penalty: reqJson.frequency_penalty,
presence_penalty: reqJson.presence_penalty,
seed: reqJson.seed,
};
if (Array.isArray(reqJson.stop_sequences) && reqJson.stop_sequences.length > 0) {
+15
View File
@@ -120,6 +120,21 @@ export const resolveChannel = async (
return c.text(`Model not supported: ${requestedModel}. Please configure models.`, 400);
}
const requestedChannelKey = c.req.raw.headers.get('x-channel-key')?.trim();
if (requestedChannelKey) {
const hasTokenAccessToChannel = channelsResult.results.some((row) => row.key === requestedChannelKey);
if (!hasTokenAccessToChannel) {
return c.text(`Token is not allowed to use channel: ${requestedChannelKey}`, 403);
}
const selectedChannel = availableChannels.find((channel) => channel.key === requestedChannelKey);
if (!selectedChannel) {
return c.text(`Channel does not support this route or model: ${requestedChannelKey}`, 400);
}
availableChannels = [selectedChannel];
}
if (!TokenUtils.hasRemainingQuota(tokenData.total_quota, usage)) {
const freeChannels = await Promise.all(
availableChannels.map(async (channel) => ({