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..4012831 100644 --- a/internal/backend/agent/model/openai.go +++ b/internal/backend/agent/model/openai.go @@ -304,15 +304,67 @@ func OpenAIEndpointURL(baseURL string, endpoint string) string { if !strings.HasPrefix(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" + } + // 规则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:baseURL 以 /vN 结尾时,剥离 endpoint 的版本前缀(/v1/、/v2/ 等) + // 这样 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 } +// 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 { if endpointFromURL := OpenAIEndpointFromBaseURL(baseURL); endpointFromURL != "" { return endpointFromURL @@ -377,11 +429,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 +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)) 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 +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)) 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 +961,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 +1871,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/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),