Merge pull request #168 from Sxuan-Coder/main

feat(openai): 支持"自定义路径"endpoint 以兼容非标准 OpenAI 兼容服务
This commit is contained in:
leokun
2026-07-06 10:38:28 +08:00
committed by GitHub
6 changed files with 100 additions and 18 deletions
@@ -7,6 +7,7 @@ import {
createEmptyModelAdapter, createEmptyModelAdapter,
normalizeModelAdapter, normalizeModelAdapter,
OPENAI_ENDPOINT_CHAT_COMPLETIONS, OPENAI_ENDPOINT_CHAT_COMPLETIONS,
OPENAI_ENDPOINT_CUSTOM,
OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON, OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
} from "@/state/appState"; } from "@/state/appState";
@@ -35,6 +36,7 @@ const anthropicThinkingEffortOptions = [
const openAIEndpointOptions = [ const openAIEndpointOptions = [
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" }, { label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" }, { label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
{ label: "自定义路径", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
]; ];
const fieldTips = { const fieldTips = {
+11 -3
View File
@@ -27,6 +27,7 @@ const SUPPORTED_ANTHROPIC_THINKING_EFFORTS = new Set(["low", "medium", "high", "
export const ANTHROPIC_THINKING_EFFORT_DEFAULT = "xhigh"; export const ANTHROPIC_THINKING_EFFORT_DEFAULT = "xhigh";
export const OPENAI_ENDPOINT_RESPONSES = "/v1/responses"; export const OPENAI_ENDPOINT_RESPONSES = "/v1/responses";
export const OPENAI_ENDPOINT_CHAT_COMPLETIONS = "/v1/chat/completions"; export const OPENAI_ENDPOINT_CHAT_COMPLETIONS = "/v1/chat/completions";
export const OPENAI_ENDPOINT_CUSTOM = "/custom";
export const OPENAI_EXTRA_PARAMS_DEFAULT_JSON = `{ export const OPENAI_EXTRA_PARAMS_DEFAULT_JSON = `{
"service_tier": "priority" "service_tier": "priority"
}`; }`;
@@ -34,7 +35,7 @@ export const EXTRA_PARAMS_DEFAULT_JSON = `{
}`; }`;
export const CUSTOM_HEADERS_DEFAULT_JSON = `{ export const CUSTOM_HEADERS_DEFAULT_JSON = `{
}`; }`;
const SUPPORTED_OPENAI_ENDPOINTS = new Set([OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_CHAT_COMPLETIONS]); const SUPPORTED_OPENAI_ENDPOINTS = new Set([OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_CHAT_COMPLETIONS, OPENAI_ENDPOINT_CUSTOM]);
const SUPPORTED_ROUTE_MODES = new Set(["local", "upstream"]); const SUPPORTED_ROUTE_MODES = new Set(["local", "upstream"]);
const PROXY_STATE_EVENT = "proxy:state"; const PROXY_STATE_EVENT = "proxy:state";
const USER_CONFIG_CHANGED_EVENT = "user-config:changed"; const USER_CONFIG_CHANGED_EVENT = "user-config:changed";
@@ -282,6 +283,9 @@ export function createEmptyModelAdapter() {
}; };
} }
// normalizeOpenAIEndpoint 归一化 endpoint 路径。
// 支持三个预设值:/v1/responses、/v1/chat/completions、/custom(自定义路径)。
// 选 /custom 时,用户需在接口地址栏填写完整请求 URL。
function normalizeOpenAIEndpoint(value) { function normalizeOpenAIEndpoint(value) {
const text = asString(value).toLowerCase(); const text = asString(value).toLowerCase();
if (!text) { if (!text) {
@@ -290,6 +294,10 @@ function normalizeOpenAIEndpoint(value) {
return SUPPORTED_OPENAI_ENDPOINTS.has(text) ? text : ""; return SUPPORTED_OPENAI_ENDPOINTS.has(text) ? text : "";
} }
function isValidOpenAIEndpoint(value) {
return normalizeOpenAIEndpoint(value) !== "";
}
function validateJSONObject(value, label) { function validateJSONObject(value, label) {
const text = asString(value); const text = asString(value);
if (!text) { if (!text) {
@@ -426,8 +434,8 @@ export function validateModelAdapters(source) {
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) { if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
return `${prefix} 的推理强度仅支持 low、medium、high、xhigh`; return `${prefix} 的推理强度仅支持 low、medium、high、xhigh`;
} }
if (adapter.type === "openai" && !SUPPORTED_OPENAI_ENDPOINTS.has(adapter.openAIEndpoint)) { if (adapter.type === "openai" && !isValidOpenAIEndpoint(adapter.openAIEndpoint)) {
return `${prefix} 的 OpenAI 端点仅支持 /v1/responses/v1/chat/completions`; return `${prefix} 的 OpenAI 端点仅支持 /v1/responses/v1/chat/completions 或以 / 开头的自定义路径`;
} }
if (adapter.type === "openai" && adapter.openAIExtraParamsEnabled) { if (adapter.type === "openai" && adapter.openAIExtraParamsEnabled) {
const extraParamsError = validateOpenAIExtraParamsJSON(adapter.openAIExtraParamsJSON); const extraParamsError = validateOpenAIExtraParamsJSON(adapter.openAIExtraParamsJSON);
+3 -1
View File
@@ -17,6 +17,7 @@ import {
isModelAdapterTestResultStale, isModelAdapterTestResultStale,
normalizeModelAdapter, normalizeModelAdapter,
OPENAI_ENDPOINT_CHAT_COMPLETIONS, OPENAI_ENDPOINT_CHAT_COMPLETIONS,
OPENAI_ENDPOINT_CUSTOM,
OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON, OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest, runModelAdapterTest,
@@ -50,6 +51,7 @@ const anthropicThinkingEffortOptions = [
const openAIEndpointOptions = [ const openAIEndpointOptions = [
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" }, { label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" }, { label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
{ label: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
]; ];
const editorIndex = ref(-1); const editorIndex = ref(-1);
@@ -128,7 +130,7 @@ const fieldTips = {
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。", contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。", reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。", maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。",
openAIEndpoint: "OpenAI 兼容接口使用的协议端点。未选择时默认使用 /v1/responses。", openAIEndpoint: "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。", openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
customHeaders: "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。", customHeaders: "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
anthropicExtraParams: "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。", anthropicExtraParams: "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。",
+61 -9
View File
@@ -304,15 +304,67 @@ func OpenAIEndpointURL(baseURL string, endpoint string) string {
if !strings.HasPrefix(normalizedEndpoint, "/") { if !strings.HasPrefix(normalizedEndpoint, "/") {
normalizedEndpoint = "/" + normalizedEndpoint normalizedEndpoint = "/" + normalizedEndpoint
} }
// 规则0:自定义路径模式
// - baseURL 已含 endpoint 后缀(/chat/completions 或 /responses)→ 直接用 base
// - 否则追加 /chat/completions(默认协议形态,覆盖 Z.AI /v4 等场景)
if normalizedEndpoint == modelchannel.OpenAIEndpointCustom {
if OpenAIEndpointFromBaseURL(base) != "" {
return base
}
return base + "/chat/completions"
}
// 规则1baseURL 已含 endpoint 后缀 → 直接用 base
if OpenAIEndpointFromBaseURL(base) != "" { if OpenAIEndpointFromBaseURL(base) != "" {
return base return base
} }
if strings.HasSuffix(base, "/v1") && strings.HasPrefix(normalizedEndpoint, "/v1/") { // 规则2baseURL 以 /vN 结尾时,剥离 endpoint 的版本前缀(/v1/、/v2/ 等)
return base + strings.TrimPrefix(normalizedEndpoint, "/v1") // 这样 base=.../v4 + endpoint=/v1/chat/completions → .../v4/chat/completions
if _, ok := trailingVersionSegment(base); ok {
if rest, stripped := stripEndpointVersionPrefix(normalizedEndpoint); stripped {
return base + rest
}
} }
// 规则3:兜底原样拼接
return base + normalizedEndpoint return base + normalizedEndpoint
} }
// trailingVersionSegment 检测 URL 末尾是否以 /vN 形式结尾(N 为数字),
// 返回版本段(如 "v4")和是否匹配。用于通用版本段去重。
func trailingVersionSegment(base string) (string, bool) {
idx := strings.LastIndex(base, "/")
if idx < 0 {
return "", false
}
seg := base[idx+1:]
if len(seg) < 2 || seg[0] != 'v' {
return "", false
}
for i := 1; i < len(seg); i++ {
if seg[i] < '0' || seg[i] > '9' {
return "", false
}
}
return seg, true
}
// stripEndpointVersionPrefix 剥离 endpoint 路径开头的版本段前缀(/vN/),
// 返回剩余路径和是否成功剥离。
// /v1/chat/completions → ("/chat/completions", true)
// /chat/completions → ("", false)
func stripEndpointVersionPrefix(endpoint string) (string, bool) {
if len(endpoint) < 4 || endpoint[0] != '/' || endpoint[1] != 'v' {
return "", false
}
i := 2
for i < len(endpoint) && endpoint[i] >= '0' && endpoint[i] <= '9' {
i++
}
if i == 2 || i >= len(endpoint) || endpoint[i] != '/' {
return "", false
}
return endpoint[i:], true
}
func ResolveOpenAIEndpoint(baseURL string, endpoint string) string { func ResolveOpenAIEndpoint(baseURL string, endpoint string) string {
if endpointFromURL := OpenAIEndpointFromBaseURL(baseURL); endpointFromURL != "" { if endpointFromURL := OpenAIEndpointFromBaseURL(baseURL); endpointFromURL != "" {
return endpointFromURL return endpointFromURL
@@ -377,11 +429,11 @@ func (adapter *OpenAIAdapter) Stream(ctx context.Context, req StreamRequest, sin
req.OpenAIEndpoint = endpoint req.OpenAIEndpoint = endpoint
if req.RequestKnobs != nil { if req.RequestKnobs != nil {
req.RequestKnobs["openai_endpoint"] = endpoint req.RequestKnobs["openai_endpoint"] = endpoint
if endpoint == modelchannel.OpenAIEndpointResponses { if modelchannel.OpenAIEndpointShape(endpoint) == "responses" {
req.RequestKnobs["max_output_tokens"] = req.MaxTokens req.RequestKnobs["max_output_tokens"] = req.MaxTokens
} }
} }
if endpoint == modelchannel.OpenAIEndpointResponses { if modelchannel.OpenAIEndpointShape(endpoint) == "responses" {
return adapter.streamResponses(ctx, req, baseURL, apiKey, modelID, sink) return adapter.streamResponses(ctx, req, baseURL, apiKey, modelID, sink)
} }
return adapter.streamChatCompletions(ctx, req, baseURL, apiKey, modelID, sink) return adapter.streamChatCompletions(ctx, req, baseURL, apiKey, modelID, sink)
@@ -425,14 +477,14 @@ func (adapter *OpenAIAdapter) streamChatCompletions(ctx context.Context, req Str
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err)) recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err return err
} }
applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, modelchannel.OpenAIEndpointChatCompletions) applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, req.OpenAIEndpoint)
if err := ApplyOpenAIExtraParams(bodyMap, req.OpenAIExtraParamsEnabled, req.OpenAIExtraParamsJSON); err != nil { if err := ApplyOpenAIExtraParams(bodyMap, req.OpenAIExtraParamsEnabled, req.OpenAIExtraParamsJSON); err != nil {
finishedAt = time.Now().UTC() finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err)) recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err return err
} }
body = bodyMap body = bodyMap
requestURL := OpenAIEndpointURL(baseURL, modelchannel.OpenAIEndpointChatCompletions) requestURL := OpenAIEndpointURL(baseURL, req.OpenAIEndpoint)
recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body) recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body)
payload, err := json.Marshal(body) payload, err := json.Marshal(body)
@@ -901,7 +953,7 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err)) recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err return err
} }
applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, modelchannel.OpenAIEndpointResponses) applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, req.OpenAIEndpoint)
if err := ApplyOpenAIExtraParams(bodyMap, req.OpenAIExtraParamsEnabled, req.OpenAIExtraParamsJSON); err != nil { if err := ApplyOpenAIExtraParams(bodyMap, req.OpenAIExtraParamsEnabled, req.OpenAIExtraParamsJSON); err != nil {
finishedAt = time.Now().UTC() finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err)) recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
@@ -909,7 +961,7 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq
} }
body = bodyMap body = bodyMap
requestURL := OpenAIEndpointURL(baseURL, modelchannel.OpenAIEndpointResponses) requestURL := OpenAIEndpointURL(baseURL, req.OpenAIEndpoint)
recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body) recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body)
payload, err := json.Marshal(body) payload, err := json.Marshal(body)
@@ -1819,7 +1871,7 @@ func applyOpenAIThinkingDisable(body map[string]any, req StreamRequest, baseURL
delete(body, "reasoning_effort") delete(body, "reasoning_effort")
setRequestKnob(req, "thinking_disabled_provider_param", "enable_thinking") setRequestKnob(req, "thinking_disabled_provider_param", "enable_thinking")
case "reasoning_none": case "reasoning_none":
if endpoint == modelchannel.OpenAIEndpointResponses { if modelchannel.OpenAIEndpointShape(endpoint) == "responses" {
body["reasoning"] = map[string]any{"effort": "none"} body["reasoning"] = map[string]any{"effort": "none"}
} else { } else {
body["reasoning_effort"] = "none" body["reasoning_effort"] = "none"
+1 -1
View File
@@ -154,7 +154,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
case next.Type == "openai" && next.ReasoningEffort == "": case next.Type == "openai" && next.ReasoningEffort == "":
return nil, errors.New("模型适配器 reasoningEffort 仅支持 low、medium、high、xhigh") return nil, errors.New("模型适配器 reasoningEffort 仅支持 low、medium、high、xhigh")
case next.Type == "openai" && next.OpenAIEndpoint == "": case next.Type == "openai" && next.OpenAIEndpoint == "":
return nil, errors.New("模型适配器 openAIEndpoint 仅支持 /v1/responses/v1/chat/completions") return nil, errors.New("模型适配器 openAIEndpoint 仅支持 /v1/responses/v1/chat/completions 或 /custom(自定义路径)")
case next.Type == "openai" && next.OpenAIExtraParamsEnabled: case next.Type == "openai" && next.OpenAIExtraParamsEnabled:
if err := validateJSONMap(next.OpenAIExtraParamsJSON, "openAIExtraParamsJSON"); err != nil { if err := validateJSONMap(next.OpenAIExtraParamsJSON, "openAIExtraParamsJSON"); err != nil {
return nil, err return nil, err
+22 -4
View File
@@ -13,6 +13,7 @@ const ChannelIDHexLength = 16
const ( const (
OpenAIEndpointResponses = "/v1/responses" OpenAIEndpointResponses = "/v1/responses"
OpenAIEndpointChatCompletions = "/v1/chat/completions" OpenAIEndpointChatCompletions = "/v1/chat/completions"
OpenAIEndpointCustom = "/custom"
) )
func NormalizeBaseURL(raw string) (string, error) { func NormalizeBaseURL(raw string) (string, error) {
@@ -39,20 +40,37 @@ func NormalizeBaseURL(raw string) (string, error) {
return normalized, nil return normalized, nil
} }
// NormalizeOpenAIEndpoint 归一化 OpenAI endpoint 路径。
// 支持三个预设值:/v1/responses、/v1/chat/completions、/custom(自定义路径)。
// 选 /custom 时,用户需在接口地址栏填写完整请求 URL。
func NormalizeOpenAIEndpoint(providerType string, endpoint string) string { func NormalizeOpenAIEndpoint(providerType string, endpoint string) string {
if strings.TrimSpace(strings.ToLower(providerType)) != "openai" { if strings.TrimSpace(strings.ToLower(providerType)) != "openai" {
return "" return ""
} }
switch strings.ToLower(strings.TrimSpace(endpoint)) { normalized := strings.TrimSpace(endpoint)
case "", OpenAIEndpointResponses: switch normalized {
case "":
return OpenAIEndpointResponses return OpenAIEndpointResponses
case OpenAIEndpointChatCompletions: case OpenAIEndpointResponses, OpenAIEndpointChatCompletions, OpenAIEndpointCustom:
return OpenAIEndpointChatCompletions return normalized
default: default:
return "" return ""
} }
} }
// OpenAIEndpointShape 根据 endpoint 路径末段推断协议形态。
// 返回 "responses"Responses API)或 "chat/completions"Chat Completions API)。
// 这样 /v1/chat/completions、/v4/chat/completions、/chat/completions 都走同一协议分支。
func OpenAIEndpointShape(endpoint string) string {
lower := strings.ToLower(strings.TrimSpace(endpoint))
switch {
case strings.HasSuffix(lower, "/responses"):
return "responses"
default:
return "chat/completions"
}
}
func BuildLegacyChannelID(baseURL string, modelID string, apiKey string, name string) string { func BuildLegacyChannelID(baseURL string, modelID string, apiKey string, name string) string {
return buildChannelID([]string{ return buildChannelID([]string{
strings.TrimSpace(baseURL), strings.TrimSpace(baseURL),