From c50e7d06061257c796d5bca7a4a8c7ec4ed56825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E7=8E=84?= <1421148240@qq.com> Date: Sat, 4 Jul 2026 18:51:41 +0800 Subject: [PATCH] feat: add support for custom OpenAI endpoint path - Introduced a new endpoint option for OpenAI integrations, allowing users to specify a custom path. - Updated relevant components and validation logic to accommodate the new endpoint. - Enhanced documentation and error messages to reflect the addition of the custom endpoint option. --- frontend/src/components/ModelAdapterModal.vue | 2 + frontend/src/state/appState.js | 14 +++- frontend/src/views/ModelEditor.vue | 4 +- internal/backend/agent/model/openai.go | 47 ++++++++--- .../agent/model/openai_endpoint_test.go | 79 +++++++++++++++++++ internal/backend/server/config/types.go | 2 +- internal/modelchannel/identity.go | 26 +++++- internal/modelchannel/identity_test.go | 70 ++++++++++++++++ 8 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 internal/backend/agent/model/openai_endpoint_test.go create mode 100644 internal/modelchannel/identity_test.go diff --git a/frontend/src/components/ModelAdapterModal.vue b/frontend/src/components/ModelAdapterModal.vue index 4e36eba..2e784ab 100644 --- a/frontend/src/components/ModelAdapterModal.vue +++ b/frontend/src/components/ModelAdapterModal.vue @@ -7,6 +7,7 @@ import { createEmptyModelAdapter, normalizeModelAdapter, OPENAI_ENDPOINT_CHAT_COMPLETIONS, + OPENAI_ENDPOINT_CUSTOM, OPENAI_ENDPOINT_RESPONSES, OPENAI_EXTRA_PARAMS_DEFAULT_JSON, } from "@/state/appState"; @@ -35,6 +36,7 @@ const anthropicThinkingEffortOptions = [ const openAIEndpointOptions = [ { 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: "自定义路径", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" }, ]; const fieldTips = { diff --git a/frontend/src/state/appState.js b/frontend/src/state/appState.js index 01143da..858ec96 100644 --- a/frontend/src/state/appState.js +++ b/frontend/src/state/appState.js @@ -27,6 +27,7 @@ const SUPPORTED_ANTHROPIC_THINKING_EFFORTS = new Set(["low", "medium", "high", " export const ANTHROPIC_THINKING_EFFORT_DEFAULT = "xhigh"; export const OPENAI_ENDPOINT_RESPONSES = "/v1/responses"; export const OPENAI_ENDPOINT_CHAT_COMPLETIONS = "/v1/chat/completions"; +export const OPENAI_ENDPOINT_CUSTOM = "/custom"; export const OPENAI_EXTRA_PARAMS_DEFAULT_JSON = `{ "service_tier": "priority" }`; @@ -34,7 +35,7 @@ export const EXTRA_PARAMS_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 PROXY_STATE_EVENT = "proxy:state"; 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) { const text = asString(value).toLowerCase(); if (!text) { @@ -290,6 +294,10 @@ function normalizeOpenAIEndpoint(value) { return SUPPORTED_OPENAI_ENDPOINTS.has(text) ? text : ""; } +function isValidOpenAIEndpoint(value) { + return normalizeOpenAIEndpoint(value) !== ""; +} + function validateJSONObject(value, label) { const text = asString(value); if (!text) { @@ -426,8 +434,8 @@ export function validateModelAdapters(source) { if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) { return `${prefix} 的推理强度仅支持 low、medium、high、xhigh`; } - if (adapter.type === "openai" && !SUPPORTED_OPENAI_ENDPOINTS.has(adapter.openAIEndpoint)) { - return `${prefix} 的 OpenAI 端点仅支持 /v1/responses 或 /v1/chat/completions`; + if (adapter.type === "openai" && !isValidOpenAIEndpoint(adapter.openAIEndpoint)) { + return `${prefix} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径`; } if (adapter.type === "openai" && adapter.openAIExtraParamsEnabled) { const extraParamsError = validateOpenAIExtraParamsJSON(adapter.openAIExtraParamsJSON); diff --git a/frontend/src/views/ModelEditor.vue b/frontend/src/views/ModelEditor.vue index 02f1e0d..e373b75 100644 --- a/frontend/src/views/ModelEditor.vue +++ b/frontend/src/views/ModelEditor.vue @@ -17,6 +17,7 @@ import { isModelAdapterTestResultStale, normalizeModelAdapter, OPENAI_ENDPOINT_CHAT_COMPLETIONS, + OPENAI_ENDPOINT_CUSTOM, OPENAI_ENDPOINT_RESPONSES, OPENAI_EXTRA_PARAMS_DEFAULT_JSON, runModelAdapterTest, @@ -50,6 +51,7 @@ const anthropicThinkingEffortOptions = [ const openAIEndpointOptions = [ { 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: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" }, ]; const editorIndex = ref(-1); @@ -128,7 +130,7 @@ const fieldTips = { contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。", reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。", maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。", - openAIEndpoint: "OpenAI 兼容接口使用的协议端点。未选择时默认使用 /v1/responses。", + openAIEndpoint: "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。", openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。", customHeaders: "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。", anthropicExtraParams: "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。", diff --git a/internal/backend/agent/model/openai.go b/internal/backend/agent/model/openai.go index 751b2b3..4ce669e 100644 --- a/internal/backend/agent/model/openai.go +++ b/internal/backend/agent/model/openai.go @@ -304,15 +304,44 @@ func OpenAIEndpointURL(baseURL string, endpoint string) string { if !strings.HasPrefix(normalizedEndpoint, "/") { normalizedEndpoint = "/" + normalizedEndpoint } + // 规则0:自定义路径模式 → 直接用 baseURL 作为完整请求地址 + if normalizedEndpoint == modelchannel.OpenAIEndpointCustom { + return base + } + // 规则1:baseURL 已含 endpoint 后缀 → 直接用 base if OpenAIEndpointFromBaseURL(base) != "" { return base } - if strings.HasSuffix(base, "/v1") && strings.HasPrefix(normalizedEndpoint, "/v1/") { - return base + strings.TrimPrefix(normalizedEndpoint, "/v1") + // 规则2:通用版本段去重(/v1 /v2 /v3 /v4 ... 任意版本号) + if version, ok := trailingVersionSegment(base); ok { + prefix := "/" + version + "/" + if strings.HasPrefix(normalizedEndpoint, prefix) { + return base + strings.TrimPrefix(normalizedEndpoint, "/"+version) + } } + // 规则3:兜底原样拼接 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 +} + func ResolveOpenAIEndpoint(baseURL string, endpoint string) string { if endpointFromURL := OpenAIEndpointFromBaseURL(baseURL); endpointFromURL != "" { return endpointFromURL @@ -377,11 +406,11 @@ func (adapter *OpenAIAdapter) Stream(ctx context.Context, req StreamRequest, sin req.OpenAIEndpoint = endpoint if req.RequestKnobs != nil { req.RequestKnobs["openai_endpoint"] = endpoint - if endpoint == modelchannel.OpenAIEndpointResponses { + if modelchannel.OpenAIEndpointShape(endpoint) == "responses" { 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.streamChatCompletions(ctx, req, baseURL, apiKey, modelID, sink) @@ -425,14 +454,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)) 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 { finishedAt = time.Now().UTC() recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err)) return err } body = bodyMap - requestURL := OpenAIEndpointURL(baseURL, modelchannel.OpenAIEndpointChatCompletions) + requestURL := OpenAIEndpointURL(baseURL, req.OpenAIEndpoint) recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body) payload, err := json.Marshal(body) @@ -901,7 +930,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)) 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 { finishedAt = time.Now().UTC() recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err)) @@ -909,7 +938,7 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq } body = bodyMap - requestURL := OpenAIEndpointURL(baseURL, modelchannel.OpenAIEndpointResponses) + requestURL := OpenAIEndpointURL(baseURL, req.OpenAIEndpoint) recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body) payload, err := json.Marshal(body) @@ -1819,7 +1848,7 @@ func applyOpenAIThinkingDisable(body map[string]any, req StreamRequest, baseURL delete(body, "reasoning_effort") setRequestKnob(req, "thinking_disabled_provider_param", "enable_thinking") case "reasoning_none": - if endpoint == modelchannel.OpenAIEndpointResponses { + if modelchannel.OpenAIEndpointShape(endpoint) == "responses" { body["reasoning"] = map[string]any{"effort": "none"} } else { body["reasoning_effort"] = "none" diff --git a/internal/backend/agent/model/openai_endpoint_test.go b/internal/backend/agent/model/openai_endpoint_test.go new file mode 100644 index 0000000..81d438e --- /dev/null +++ b/internal/backend/agent/model/openai_endpoint_test.go @@ -0,0 +1,79 @@ +package modeladapter + +import "testing" + +func TestTrailingVersionSegment(t *testing.T) { + cases := []struct { + base string + wantSeg string + wantOK bool + }{ + {"https://api.openai.com/v1", "v1", true}, + {"https://api.z.ai/api/coding/paas/v4", "v4", true}, + {"https://example.com/v2", "v2", true}, + {"https://example.com/v12", "v12", true}, + + // 非版本段 + {"https://api.openai.com", "", false}, + {"https://example.com/chat", "", false}, + {"https://example.com/api", "", false}, + {"https://example.com/v", "", false}, + {"https://example.com/vx", "", false}, + {"", "", false}, + } + for _, tc := range cases { + t.Run(tc.base, func(t *testing.T) { + gotSeg, gotOK := trailingVersionSegment(tc.base) + if gotSeg != tc.wantSeg || gotOK != tc.wantOK { + t.Fatalf("trailingVersionSegment(%q) = (%q, %v), want (%q, %v)", tc.base, gotSeg, gotOK, tc.wantSeg, tc.wantOK) + } + }) + } +} + +func TestOpenAIEndpointURL(t *testing.T) { + cases := []struct { + name string + baseURL string + endpoint string + want string + }{ + // === issue #166 核心场景:选"自定义路径",baseURL 填完整地址 === + {"custom_zai_full_chat", "https://api.z.ai/api/coding/paas/v4/chat/completions", "/custom", "https://api.z.ai/api/coding/paas/v4/chat/completions"}, + {"custom_zai_full_responses", "https://api.z.ai/api/coding/paas/v4/responses", "/custom", "https://api.z.ai/api/coding/paas/v4/responses"}, + + // === 存量场景回归:OpenAI 官方 /v1 === + {"openai_v1_responses_dedup", "https://api.openai.com/v1", "/v1/responses", "https://api.openai.com/v1/responses"}, + {"openai_v1_chat_dedup", "https://api.openai.com/v1", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"}, + // baseURL 不带版本号 + {"openai_noversion_chat", "https://api.openai.com", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"}, + + // === baseURL 已含完整 endpoint 后缀 → 直接用 base === + {"baseurl_has_chat_suffix", "https://api.openai.com/v1/chat/completions", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"}, + {"baseurl_has_responses_suffix", "https://api.openai.com/v1/responses", "/v1/responses", "https://api.openai.com/v1/responses"}, + + // === 空 endpoint 默认 responses === + {"empty_endpoint_defaults", "https://api.openai.com/v1", "", "https://api.openai.com/v1/responses"}, + + // === 通用版本段去重:/v2 /v3 === + {"v2_dedup", "https://example.com/v2", "/v2/chat/completions", "https://example.com/v2/chat/completions"}, + {"v3_dedup", "https://example.com/v3", "/v3/responses", "https://example.com/v3/responses"}, + + // === 不同版本号不去重:base /v4 + endpoint /v1 === + {"different_version_no_dedup", "https://example.com/v4", "/v1/chat/completions", "https://example.com/v4/v1/chat/completions"}, + + // === 尾部斜杠清理 === + {"trailing_slash_base", "https://api.openai.com/v1/", "/v1/responses", "https://api.openai.com/v1/responses"}, + + // === /custom + baseURL 不含已知后缀 → 直接返回 base === + {"custom_no_suffix", "https://api.example.com/some/path", "/custom", "https://api.example.com/some/path"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := OpenAIEndpointURL(tc.baseURL, tc.endpoint) + if got != tc.want { + t.Fatalf("OpenAIEndpointURL(%q, %q)\n got %s\n want %s", tc.baseURL, tc.endpoint, got, tc.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/backend/server/config/types.go b/internal/backend/server/config/types.go index 94fe1cb..f2c192a 100644 --- a/internal/backend/server/config/types.go +++ b/internal/backend/server/config/types.go @@ -154,7 +154,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon case next.Type == "openai" && next.ReasoningEffort == "": return nil, errors.New("模型适配器 reasoningEffort 仅支持 low、medium、high、xhigh") 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: if err := validateJSONMap(next.OpenAIExtraParamsJSON, "openAIExtraParamsJSON"); err != nil { return nil, err diff --git a/internal/modelchannel/identity.go b/internal/modelchannel/identity.go index 7084bfc..da498bc 100644 --- a/internal/modelchannel/identity.go +++ b/internal/modelchannel/identity.go @@ -13,6 +13,7 @@ const ChannelIDHexLength = 16 const ( OpenAIEndpointResponses = "/v1/responses" OpenAIEndpointChatCompletions = "/v1/chat/completions" + OpenAIEndpointCustom = "/custom" ) func NormalizeBaseURL(raw string) (string, error) { @@ -39,20 +40,37 @@ func NormalizeBaseURL(raw string) (string, error) { return normalized, nil } +// NormalizeOpenAIEndpoint 归一化 OpenAI endpoint 路径。 +// 支持三个预设值:/v1/responses、/v1/chat/completions、/custom(自定义路径)。 +// 选 /custom 时,用户需在接口地址栏填写完整请求 URL。 func NormalizeOpenAIEndpoint(providerType string, endpoint string) string { if strings.TrimSpace(strings.ToLower(providerType)) != "openai" { return "" } - switch strings.ToLower(strings.TrimSpace(endpoint)) { - case "", OpenAIEndpointResponses: + normalized := strings.TrimSpace(endpoint) + switch normalized { + case "": return OpenAIEndpointResponses - case OpenAIEndpointChatCompletions: - return OpenAIEndpointChatCompletions + case OpenAIEndpointResponses, OpenAIEndpointChatCompletions, OpenAIEndpointCustom: + return normalized default: 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 { return buildChannelID([]string{ strings.TrimSpace(baseURL), diff --git a/internal/modelchannel/identity_test.go b/internal/modelchannel/identity_test.go new file mode 100644 index 0000000..7335546 --- /dev/null +++ b/internal/modelchannel/identity_test.go @@ -0,0 +1,70 @@ +package modelchannel + +import "testing" + +func TestNormalizeOpenAIEndpoint(t *testing.T) { + cases := []struct { + name string + providerType string + endpoint string + want string + }{ + // 非 openai 类型始终返回空 + {"anthropic_ignored", "anthropic", "/v1/responses", ""}, + {"empty_type_ignored", "", "/v1/responses", ""}, + + // 预设值 + {"empty_defaults_to_responses", "openai", "", OpenAIEndpointResponses}, + {"preset_responses", "openai", "/v1/responses", OpenAIEndpointResponses}, + {"preset_chat_completions", "openai", "/v1/chat/completions", OpenAIEndpointChatCompletions}, + {"preset_custom", "openai", "/custom", OpenAIEndpointCustom}, + + // 非法值:不再允许任意自定义路径,只接受三个预设值 + {"arbitrary_path_rejected", "openai", "/v4/chat/completions", ""}, + {"arbitrary_path_rejected_2", "openai", "/chat/completions", ""}, + {"missing_slash", "openai", "v1/responses", ""}, + {"slash_only", "openai", "/", ""}, + {"whitespace", "openai", " ", OpenAIEndpointResponses}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := NormalizeOpenAIEndpoint(tc.providerType, tc.endpoint) + if got != tc.want { + t.Fatalf("NormalizeOpenAIEndpoint(%q, %q) = %q, want %q", tc.providerType, tc.endpoint, got, tc.want) + } + }) + } +} + +func TestOpenAIEndpointShape(t *testing.T) { + cases := []struct { + endpoint string + want string + }{ + // Responses 形态 + {"/v1/responses", "responses"}, + {"/v4/responses", "responses"}, + {"/responses", "responses"}, + + // Chat Completions 形态 + {"/v1/chat/completions", "chat/completions"}, + {"/v4/chat/completions", "chat/completions"}, + {"/chat/completions", "chat/completions"}, + + // /custom 不含已知后缀 → 兜底走 chat/completions + {"/custom", "chat/completions"}, + + // 兜底默认 + {"", "chat/completions"}, + } + + for _, tc := range cases { + t.Run(tc.endpoint, func(t *testing.T) { + got := OpenAIEndpointShape(tc.endpoint) + if got != tc.want { + t.Fatalf("OpenAIEndpointShape(%q) = %q, want %q", tc.endpoint, got, tc.want) + } + }) + } +} \ No newline at end of file