feat(api): 调整API端点补全逻辑

This commit is contained in:
Jeff Gu committed 2026-04-10 14:20:01 +08:00
1 parent 0d6983fda4
commit 7ff59e153a
5 files changed
+130 -9

No files matched your search

+117 -2
View File
@@ -33,8 +33,8 @@ type ModelEditorMode = 'visual' | 'json'
type ModelRow = { id: string; name: string }
const channelTypes = [
{ value: 'azure-openai', label: 'Azure OpenAI' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'azure-openai', label: 'Azure OpenAI' },
{ value: 'azure-openai-audio', label: 'Azure OpenAI Audio' },
{ value: 'openai-audio', label: 'OpenAI Audio' },
{ value: 'claude', label: 'Claude' },
@@ -50,7 +50,7 @@ const createEmptyModelRow = (): ModelRow => ({
const createDefaultChannelFormData = (): ChannelConfig => ({
name: '',
type: 'azure-openai',
type: 'openai',
endpoint: '',
api_keys: [],
auto_retry: true,
@@ -254,6 +254,117 @@ const parseModelsFromJson = (value: string): { models: ChannelModelMapping[]; er
return { models }
}
const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, '')
const joinPath = (...segments: string[]): string => {
const cleanedSegments = segments
.map((segment) => trimSlashes(segment))
.filter((segment) => segment.length > 0)
return `/${cleanedSegments.join('/')}`
}
const getChannelRequestPreviewPath = (type: string | undefined): string => {
switch (type) {
case 'claude':
return '/v1/messages'
case 'openai-audio':
case 'azure-openai-audio':
return '/v1/audio/speech'
case 'openai-responses':
case 'azure-openai-responses':
return '/v1/responses'
case 'openai':
case 'azure-openai':
case 'claude-to-openai':
default:
return '/v1/chat/completions'
}
}
const isOpenAIStyleChannelType = (type: string | undefined): boolean => {
return ['openai', 'openai-audio', 'openai-responses', 'claude-to-openai'].includes(type || '')
}
const isAzureChannelType = (type: string | undefined): boolean => {
return ['azure-openai', 'azure-openai-audio', 'azure-openai-responses'].includes(type || '')
}
const buildPrefixedEndpointPreview = (
endpoint: string,
requestPath: string,
prefixToStrip = '/v1'
): string => {
const targetUrl = new URL(endpoint)
const currentBasePath = trimSlashes(targetUrl.pathname)
const normalizedPrefix = trimSlashes(prefixToStrip)
const baseAlreadyContainsPrefix =
normalizedPrefix.length > 0 && currentBasePath.endsWith(normalizedPrefix)
const explicitBasePath = endpoint.endsWith('/')
let normalizedRequestPath = requestPath
if ((baseAlreadyContainsPrefix || explicitBasePath) && normalizedRequestPath.startsWith(prefixToStrip)) {
normalizedRequestPath = normalizedRequestPath.slice(prefixToStrip.length)
}
targetUrl.pathname = joinPath(currentBasePath, normalizedRequestPath)
return targetUrl.toString()
}
const buildAzureEndpointPreview = (endpoint: string, requestPath: string): string => {
const targetUrl = new URL(endpoint)
const currentBasePath = trimSlashes(targetUrl.pathname)
const normalizedRequestPath = requestPath.replace(/^\/v1/, '')
const explicitBasePath = endpoint.endsWith('/')
const azureBasePath = currentBasePath.endsWith('openai/v1')
? currentBasePath
: currentBasePath.endsWith('openai')
? explicitBasePath
? currentBasePath
: joinPath(currentBasePath, 'v1')
: explicitBasePath
? joinPath(currentBasePath, 'openai')
: joinPath(currentBasePath, 'openai/v1')
targetUrl.pathname = joinPath(azureBasePath, normalizedRequestPath)
return targetUrl.toString()
}
const buildClaudeEndpointPreview = (endpoint: string, requestPath: string): string => {
return buildPrefixedEndpointPreview(endpoint, requestPath)
}
const buildFallbackEndpointPreview = (endpoint: string, requestPath: string): string => {
const normalizedEndpoint = endpoint.trim().replace(/\/+$/, '')
return `${normalizedEndpoint}${requestPath}`
}
const getChannelEndpointPreview = (type: string | undefined, endpoint: string): string => {
const trimmedEndpoint = endpoint.trim()
const requestPath = getChannelRequestPreviewPath(type)
if (!trimmedEndpoint) {
return requestPath;
}
try {
if (type === 'claude') {
return buildClaudeEndpointPreview(trimmedEndpoint, requestPath)
}
if (isAzureChannelType(type)) {
return buildAzureEndpointPreview(trimmedEndpoint, requestPath)
}
if (isOpenAIStyleChannelType(type)) {
return buildPrefixedEndpointPreview(trimmedEndpoint, requestPath)
}
} catch {
return buildFallbackEndpointPreview(trimmedEndpoint, requestPath)
}
return buildFallbackEndpointPreview(trimmedEndpoint, requestPath)
}
export function Channels() {
const [view, setView] = useState<'list' | 'form'>('list')
const [editMode, setEditMode] = useState<EditMode>('form')
@@ -561,6 +672,7 @@ export function Channels() {
channel.key.toLowerCase().includes(searchQuery.toLowerCase())
)
})
const endpointPreview = getChannelEndpointPreview(formData.type, formData.endpoint)
if (view === 'list') {
return (
@@ -788,6 +900,9 @@ export function Channels() {
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
placeholder="https://your-resource.openai.azure.com/"
/>
<p className="text-xs text-muted-foreground">
预览:{endpointPreview || formData.type}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">API 密钥 <span className="text-destructive">*</span></Label>
+1 -1
View File
@@ -85,7 +85,7 @@ const buildModelsFetchRequest = (
headers.set("api-key", apiKey);
break;
case "claude":
targetUrl = new URL("v1/models", config.endpoint);
targetUrl = buildPrefixedTargetUrl(config.endpoint, "/v1/models");
headers.set("x-api-key", apiKey);
headers.set("anthropic-version", DEFAULT_CLAUDE_API_VERSION);
break;
+2 -4
View File
@@ -1,5 +1,6 @@
import { Context } from "hono"
import { DEFAULT_CLAUDE_API_VERSION } from "../channel-config"
import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url"
/**
* Claude API Proxy Provider
@@ -55,12 +56,9 @@ const buildProxyRequest = (
config: ChannelConfig
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname)
const apiKey = config.api_key || ""
// Claude API uses /v1/messages endpoint
targetUrl.pathname = url.pathname
const targetHeaders = new Headers(request.headers)
// Remove Authorization header (client may send OpenAI format: "Authorization: Bearer sk-xxx")
+8 -1
View File
@@ -30,9 +30,16 @@ export const buildAzureTargetUrlFromPath = (
const requestPath = path.replace(/^\/v1/, "");
const currentBasePath = trimSlashes(targetUrl.pathname);
const explicitBasePath = endpoint.endsWith("/");
const azureBasePath = currentBasePath.endsWith("openai/v1")
? currentBasePath
: joinPath(currentBasePath, "openai/v1");
: currentBasePath.endsWith("openai")
? explicitBasePath
? currentBasePath
: joinPath(currentBasePath, "v1")
: explicitBasePath
? joinPath(currentBasePath, "openai")
: joinPath(currentBasePath, "openai/v1");
targetUrl.pathname = joinPath(azureBasePath, requestPath);
+2 -1
View File
@@ -25,9 +25,10 @@ export const buildPrefixedTargetUrl = (
const normalizedPrefix = trimSlashes(prefixToStrip);
const baseAlreadyContainsPrefix = normalizedPrefix.length > 0
&& currentBasePath.endsWith(normalizedPrefix);
const explicitBasePath = endpoint.endsWith("/");
let normalizedRequestPath = requestPath;
if (baseAlreadyContainsPrefix && normalizedRequestPath.startsWith(prefixToStrip)) {
if ((baseAlreadyContainsPrefix || explicitBasePath) && normalizedRequestPath.startsWith(prefixToStrip)) {
normalizedRequestPath = normalizedRequestPath.slice(prefixToStrip.length);
}