diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 1ed5de6..8455458 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -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('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/" /> +

+ 预览:{endpointPreview || formData.type} +

diff --git a/src/admin/channel_api.ts b/src/admin/channel_api.ts index 67388a2..3a699c9 100644 --- a/src/admin/channel_api.ts +++ b/src/admin/channel_api.ts @@ -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; diff --git a/src/providers/claude-proxy.ts b/src/providers/claude-proxy.ts index 8da79f1..ecc73cc 100644 --- a/src/providers/claude-proxy.ts +++ b/src/providers/claude-proxy.ts @@ -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") diff --git a/src/providers/shared/azure-target-url.ts b/src/providers/shared/azure-target-url.ts index 58ac623..5a7c948 100644 --- a/src/providers/shared/azure-target-url.ts +++ b/src/providers/shared/azure-target-url.ts @@ -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); diff --git a/src/providers/shared/prefixed-target-url.ts b/src/providers/shared/prefixed-target-url.ts index a8fb385..0493a48 100644 --- a/src/providers/shared/prefixed-target-url.ts +++ b/src/providers/shared/prefixed-target-url.ts @@ -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); }