mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #6917 from ouchihao/codex/issue-2149-monitor-base-path
fix(monitor): support upstream base URL paths
This commit is contained in:
@@ -556,13 +556,21 @@ func postRawJSON(ctx context.Context, fullURL string, payload []byte, headers ma
|
||||
return respBody, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// joinURL 把 base origin 与 path 拼成完整 URL。
|
||||
// 容忍 base 末尾有/无斜杠,path 必带前导斜杠。
|
||||
// joinURL 保留 base 的上游路径前缀,并避免重复追加已有的 API 路径前缀。
|
||||
// 使用 EscapedPath 匹配完整路径段,避免把 hostname 或编码斜杠当作路径。
|
||||
func joinURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if u, err := url.Parse(base); err == nil {
|
||||
basePath := u.EscapedPath()
|
||||
for end := strings.LastIndex(path, "/"); end > 0; end = strings.LastIndex(path[:end], "/") {
|
||||
if strings.HasSuffix(basePath, path[:end]) {
|
||||
return base + path[end:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ var (
|
||||
"CHANNEL_MONITOR_ENDPOINT_SCHEME", "endpoint must use https scheme",
|
||||
)
|
||||
ErrChannelMonitorEndpointPath = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_ENDPOINT_PATH", "endpoint must be base origin only (no path/query/fragment)",
|
||||
"CHANNEL_MONITOR_ENDPOINT_PATH", "endpoint must not contain query parameters or a fragment",
|
||||
)
|
||||
ErrChannelMonitorEndpointPrivate = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_ENDPOINT_PRIVATE", "endpoint must be a public host",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateMonitorEndpoint_BasePath(t *testing.T) {
|
||||
for _, endpoint := range []string{
|
||||
"https://8.8.8.8",
|
||||
"https://8.8.8.8/anthropic",
|
||||
"https://8.8.8.8/anthropic/",
|
||||
"https://8.8.8.8/anthropic/v1",
|
||||
"https://8.8.8.8/tenant%2Fone/anthropic",
|
||||
} {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
require.NoError(t, validateEndpoint(endpoint))
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
endpoint string
|
||||
want error
|
||||
}{
|
||||
{"http://8.8.8.8/anthropic", ErrChannelMonitorEndpointScheme},
|
||||
{"https://8.8.8.8/anthropic?key=secret", ErrChannelMonitorEndpointPath},
|
||||
{"https://8.8.8.8/anthropic#fragment", ErrChannelMonitorEndpointPath},
|
||||
{"https://127.0.0.1/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://10.0.0.1/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://169.254.169.254/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://[::1]/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://[fd00::1]/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https:///anthropic", ErrChannelMonitorInvalidEndpoint},
|
||||
} {
|
||||
t.Run(tc.endpoint, func(t *testing.T) {
|
||||
require.ErrorIs(t, validateEndpoint(tc.endpoint), tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinMonitorURL_DoesNotMatchHostOrEncodedSlash(t *testing.T) {
|
||||
require.Equal(t, "https://v1/v1/messages", joinURL("https://v1", "/v1/messages"))
|
||||
require.Equal(t, "https://relay.example/tenant%2Fv1/v1/messages",
|
||||
joinURL("https://relay.example/tenant%2Fv1", "/v1/messages"))
|
||||
}
|
||||
|
||||
func TestNormalizeMonitorEndpoint_PreservesBasePath(t *testing.T) {
|
||||
require.Equal(t, "https://api.deepseek.com/anthropic",
|
||||
normalizeEndpoint(" https://api.deepseek.com/anthropic/ "))
|
||||
require.Equal(t, "https://relay.example/tenant%2Fone/anthropic/v1",
|
||||
normalizeEndpoint("https://relay.example/tenant%2Fone/anthropic/v1/"))
|
||||
}
|
||||
|
||||
func TestCallProvider_BasePath(t *testing.T) {
|
||||
swapMonitorHTTPClient(t)
|
||||
requests := make(chan string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests <- r.RequestURI
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
provider string
|
||||
apiMode string
|
||||
basePath string
|
||||
wantPath string
|
||||
}{
|
||||
{"anthropic origin", MonitorProviderAnthropic, "", "", "/v1/messages"},
|
||||
{"anthropic prefix", MonitorProviderAnthropic, "", "/anthropic", "/anthropic/v1/messages"},
|
||||
{"anthropic trailing slash", MonitorProviderAnthropic, "", "/anthropic/", "/anthropic/v1/messages"},
|
||||
{"anthropic version", MonitorProviderAnthropic, "", "/anthropic/v1", "/anthropic/v1/messages"},
|
||||
{"version lookalike", MonitorProviderAnthropic, "", "/anthropic/v10", "/anthropic/v10/v1/messages"},
|
||||
{"encoded prefix", MonitorProviderAnthropic, "", "/tenant%2Fone/anthropic", "/tenant%2Fone/anthropic/v1/messages"},
|
||||
{"openai chat version", MonitorProviderOpenAI, "", "/relay/v1/", "/relay/v1/chat/completions"},
|
||||
{"openai responses version", MonitorProviderOpenAI, MonitorAPIModeResponses, "/relay/v1", "/relay/v1/responses"},
|
||||
{"gemini version", MonitorProviderGemini, "", "/relay/v1beta", "/relay/v1beta/models/test-model:generateContent"},
|
||||
{"zhipu version", MonitorProviderZhipu, "", "/api/paas/v4", "/api/paas/v4/chat/completions"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, status, err := callProvider(context.Background(), tc.provider,
|
||||
server.URL+tc.basePath, "test-key", "test-model", "hello", &CheckOptions{APIMode: tc.apiMode})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, tc.wantPath, <-requests)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,7 @@ func validateJitter(jitterSec, intervalSec int) error {
|
||||
|
||||
// validateEndpoint 校验 endpoint:
|
||||
// - scheme 强制 https(拒绝 http,避免明文凭证 + 部分 SSRF 利用面)
|
||||
// - 必须为 origin(无 path/query/fragment),防止用户填 https://api.openai.com/v1
|
||||
// 导致 joinURL 拼出 /v1/v1/chat/completions
|
||||
// - 允许上游路径前缀(如 /anthropic),不允许 query/fragment
|
||||
// - hostname 不能是 localhost/metadata 等已知元数据 hostname
|
||||
// - 解析所有 IP,任一落在 loopback/RFC1918/link-local/ULA 段即拒绝(防 SSRF)
|
||||
//
|
||||
@@ -141,9 +140,6 @@ func validateEndpoint(ep string) error {
|
||||
if u.Host == "" {
|
||||
return ErrChannelMonitorInvalidEndpoint
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return ErrChannelMonitorEndpointPath
|
||||
}
|
||||
if u.RawQuery != "" || u.Fragment != "" {
|
||||
return ErrChannelMonitorEndpointPath
|
||||
}
|
||||
@@ -161,8 +157,8 @@ func validateEndpoint(ep string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeEndpoint 去除前后空白与末尾 `/`,保证存储统一为 origin。
|
||||
// validateEndpoint 已确保格式合法(仅 origin),这里只做最终归一化。
|
||||
// normalizeEndpoint 去除前后空白与末尾 `/`,保留上游路径前缀。
|
||||
// validateEndpoint 已确保格式合法,这里只做最终归一化。
|
||||
func normalizeEndpoint(ep string) string {
|
||||
ep = strings.TrimSpace(ep)
|
||||
ep = strings.TrimRight(ep, "/")
|
||||
|
||||
@@ -129,7 +129,7 @@ export interface CreateParams {
|
||||
name: string
|
||||
provider: Provider
|
||||
api_mode?: APIMode
|
||||
/** 探活模式必填(base origin);quota 模式可留空 */
|
||||
/** 探活模式必填(可含路径前缀的 HTTPS base URL);quota 模式可留空 */
|
||||
endpoint: string
|
||||
/** 探活模式必填;quota 模式可留空 */
|
||||
api_key: string
|
||||
|
||||
Reference in New Issue
Block a user