mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
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.
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 请求体。同名字段以这里为准。",
|
||||||
|
|||||||
@@ -304,15 +304,44 @@ func OpenAIEndpointURL(baseURL string, endpoint string) string {
|
|||||||
if !strings.HasPrefix(normalizedEndpoint, "/") {
|
if !strings.HasPrefix(normalizedEndpoint, "/") {
|
||||||
normalizedEndpoint = "/" + normalizedEndpoint
|
normalizedEndpoint = "/" + normalizedEndpoint
|
||||||
}
|
}
|
||||||
|
// 规则0:自定义路径模式 → 直接用 baseURL 作为完整请求地址
|
||||||
|
if normalizedEndpoint == modelchannel.OpenAIEndpointCustom {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
// 规则1:baseURL 已含 endpoint 后缀 → 直接用 base
|
||||||
if OpenAIEndpointFromBaseURL(base) != "" {
|
if OpenAIEndpointFromBaseURL(base) != "" {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(base, "/v1") && strings.HasPrefix(normalizedEndpoint, "/v1/") {
|
// 规则2:通用版本段去重(/v1 /v2 /v3 /v4 ... 任意版本号)
|
||||||
return base + strings.TrimPrefix(normalizedEndpoint, "/v1")
|
if version, ok := trailingVersionSegment(base); ok {
|
||||||
|
prefix := "/" + version + "/"
|
||||||
|
if strings.HasPrefix(normalizedEndpoint, prefix) {
|
||||||
|
return base + strings.TrimPrefix(normalizedEndpoint, "/"+version)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 规则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
|
||||||
|
}
|
||||||
|
|
||||||
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 +406,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 +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))
|
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 +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))
|
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 +938,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 +1848,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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user