| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "regexp"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
- "github.com/tidwall/gjson"
- )
- // monitorHTTPClient 共享一个 http.Client,避免每次检测重建 transport。
- // 自定义 Transport 在 dial 时强制再次校验 IP,防止 DNS rebinding 绕过 validateEndpoint。
- var monitorHTTPClient = newSSRFSafeHTTPClient(monitorRequestTimeout)
- // monitorPingHTTPClient 用于 endpoint origin 的 HEAD ping,超时更短。
- var monitorPingHTTPClient = newSSRFSafeHTTPClient(monitorPingTimeout)
- // newSSRFSafeHTTPClient 返回一个使用 safeDialContext 的 http.Client。
- // 仅供监控模块对外发起请求使用——所有目标都应是公网 endpoint。
- func newSSRFSafeHTTPClient(timeout time.Duration) *http.Client {
- tr := &http.Transport{
- DialContext: safeDialContext,
- ForceAttemptHTTP2: true,
- MaxIdleConns: 16,
- IdleConnTimeout: monitorIdleConnTimeout,
- TLSHandshakeTimeout: monitorTLSHandshakeTimeout,
- ResponseHeaderTimeout: monitorResponseHeaderTimeout,
- }
- return &http.Client{Timeout: timeout, Transport: servertiming.WrapRoundTripper(tr)}
- }
- // CheckOptions 承载一次检测的自定义入参。
- // 所有字段都是可选(零值即等价于"用默认行为")。
- type CheckOptions struct {
- // APIMode 仅对 OpenAI provider 生效;空串等同 chat_completions。
- APIMode string
- // ExtraHeaders 用户自定义 HTTP 头(merge 到 adapter 默认 headers,用户优先)。
- ExtraHeaders map[string]string
- // BodyOverrideMode: off | merge | replace
- BodyOverrideMode string
- // BodyOverride 在 merge 模式下做浅合并(key 命中黑名单时静默丢弃),
- // 在 replace 模式下直接当作完整 body。
- BodyOverride map[string]any
- }
- // runCheckForModel 对单个 (provider, model) 做一次完整检测。
- // 不返回 error:所有失败都包装进 CheckResult.Status=error/failed。
- //
- // opts 承载模板 / 监控快照带来的自定义配置。nil 等同于 "off + 无 extra headers"。
- func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model string, opts *CheckOptions) *CheckResult {
- res := &CheckResult{
- Model: model,
- Status: MonitorStatusError,
- CheckedAt: time.Now(),
- }
- challenge := generateChallenge()
- mode := bodyOverrideMode(opts)
- start := time.Now()
- respText, rawBody, statusCode, err := callProvider(ctx, provider, endpoint, apiKey, model, challenge.Prompt, opts)
- latency := time.Since(start)
- latencyMs := int(latency / time.Millisecond)
- res.LatencyMs = &latencyMs
- if err != nil {
- res.Status = MonitorStatusError
- res.Message = truncateMessage(sanitizeErrorMessage(err.Error()))
- return res
- }
- if statusCode < 200 || statusCode >= 300 {
- // 错误路径:用 rawBody 而非 respText(gjson textPath 抽取在错误响应里通常为空,
- // 会丢掉真正的上游错误信息,例如 `{"error":{"message":"No available accounts ..."}}`)。
- res.Status = MonitorStatusError
- bodySnippet := truncateForErrorBody(rawBody)
- res.Message = truncateMessage(sanitizeErrorMessage(fmt.Sprintf("upstream HTTP %d: %s", statusCode, bodySnippet)))
- return res
- }
- // Replace 模式:跳过 challenge 校验(用户 body 是静态的,challenge 没法嵌入)。
- // 改用「HTTP 2xx + 响应文本(adapter.textPath 抽取)非空」作为 operational 判定。
- // 响应文本为空则降级为 failed(视为上游回了 200 但没实际内容)。
- if mode == MonitorBodyOverrideModeReplace {
- if strings.TrimSpace(respText) == "" {
- res.Status = MonitorStatusFailed
- res.Message = truncateMessage("replace-mode: upstream returned 2xx with empty text")
- return res
- }
- return finalizeOperationalOrDegraded(res, latency, latencyMs)
- }
- if !validateChallenge(respText, challenge.Expected) {
- res.Status = MonitorStatusFailed
- res.Message = truncateMessage(sanitizeErrorMessage(fmt.Sprintf("challenge mismatch (expected %s, got %q)", challenge.Expected, respText)))
- return res
- }
- return finalizeOperationalOrDegraded(res, latency, latencyMs)
- }
- // finalizeOperationalOrDegraded 负责走到最后一步的 operational/degraded 判定。
- // 拆出来是为了让 runCheckForModel 不超过 30 行。
- func finalizeOperationalOrDegraded(res *CheckResult, latency time.Duration, latencyMs int) *CheckResult {
- if latency >= monitorDegradedThreshold {
- res.Status = MonitorStatusDegraded
- res.Message = truncateMessage(fmt.Sprintf("slow response: %dms", latencyMs))
- return res
- }
- res.Status = MonitorStatusOperational
- return res
- }
- // bodyOverrideMode 归一取 opts.BodyOverrideMode,nil opts / 空串都视为 off。
- func bodyOverrideMode(opts *CheckOptions) string {
- if opts == nil || opts.BodyOverrideMode == "" {
- return MonitorBodyOverrideModeOff
- }
- return opts.BodyOverrideMode
- }
- // pingEndpointOrigin 对 endpoint 的 origin (scheme://host) 发起 HEAD 请求,返回耗时。
- // 失败时返回 nil(不影响主状态判定)。
- func pingEndpointOrigin(ctx context.Context, endpoint string) *int {
- origin, err := extractOrigin(endpoint)
- if err != nil || origin == "" {
- return nil
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodHead, origin, nil)
- if err != nil {
- return nil
- }
- start := time.Now()
- resp, err := monitorPingHTTPClient.Do(req)
- if err != nil {
- return nil
- }
- defer func() { _ = resp.Body.Close() }()
- _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, monitorPingDiscardMaxBytes))
- ms := int(time.Since(start) / time.Millisecond)
- return &ms
- }
- // providerAdapter 描述某个 provider 在 challenge 检测中需要的几件事:
- // - 拼出请求路径(含 model 占位)
- // - 序列化请求体
- // - 构造鉴权头
- // - 从响应 JSON 中提取文本(默认按 gjson path;需要时可自定义)
- //
- // 加新 provider 只需要在 providerAdapters 里增加一个条目,无需触碰 callProvider / validateProvider。
- type providerAdapter struct {
- buildPath func(model string) string
- buildBody func(model, prompt string) ([]byte, error)
- buildHeaders func(apiKey string) map[string]string
- textPath string // gjson 提取响应文本的 path
- extractText func([]byte) string
- }
- // providerAdapters 全部已支持的 provider。键值即 MonitorProvider* 字符串。
- //
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerAdapters = map[string]providerAdapter{
- MonitorProviderOpenAI: providerOpenAIChatAdapter,
- MonitorProviderGrok: providerGrokChatAdapter,
- // 国产 3 家(配额模式引入):均为 OpenAI 兼容 Chat Completions,
- // 仅智谱路径前缀不同(/api/paas/v4/chat/completions)。
- MonitorProviderKimi: providerKimiChatAdapter,
- MonitorProviderZhipu: providerZhipuChatAdapter,
- MonitorProviderDeepseek: providerDeepseekChatAdapter,
- MonitorProviderMiniMax: providerMiniMaxChatAdapter,
- MonitorProviderAnthropic: {
- buildPath: func(string) string { return providerAnthropicPath },
- buildBody: func(model, prompt string) ([]byte, error) {
- return json.Marshal(map[string]any{
- "model": model,
- "messages": []map[string]string{{"role": "user", "content": prompt}},
- "max_tokens": monitorChallengeMaxTokens,
- })
- },
- buildHeaders: func(apiKey string) map[string]string {
- return map[string]string{
- "x-api-key": apiKey,
- "anthropic-version": monitorAnthropicAPIVersion,
- }
- },
- extractText: extractAnthropicMonitorText,
- },
- MonitorProviderGemini: {
- // Gemini 把 model 名写在 URL path 上:/v1beta/models/{model}:generateContent
- buildPath: func(model string) string { return fmt.Sprintf(providerGeminiPathTemplate, model) },
- buildBody: func(_, prompt string) ([]byte, error) {
- return json.Marshal(map[string]any{
- "contents": []map[string]any{
- {"role": "user", "parts": []map[string]any{{"text": prompt}}},
- },
- "generationConfig": map[string]any{"maxOutputTokens": monitorChallengeMaxTokens},
- })
- },
- // 使用 x-goog-api-key header 而不是 ?key= query,避免 *url.Error 把 key 回填到错误日志。
- buildHeaders: func(apiKey string) map[string]string {
- return map[string]string{"x-goog-api-key": apiKey}
- },
- textPath: "candidates.0.content.parts.0.text",
- },
- }
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerOpenAIChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerGrokChatAdapter = newOpenAICompatibleChatAdapter(providerGrokPath)
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerKimiChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath)
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerMiniMaxChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath)
- func newOpenAICompatibleChatAdapter(path string) providerAdapter {
- return providerAdapter{
- buildPath: func(string) string { return path },
- buildBody: func(model, prompt string) ([]byte, error) {
- return json.Marshal(map[string]any{
- "model": model,
- "messages": []map[string]string{{"role": "user", "content": prompt}},
- "max_tokens": monitorChallengeMaxTokens,
- "stream": false,
- })
- },
- buildHeaders: func(apiKey string) map[string]string {
- return map[string]string{"Authorization": "Bearer " + apiKey}
- },
- textPath: "choices.0.message.content",
- }
- }
- //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。
- var providerOpenAIResponsesAdapter = providerAdapter{
- buildPath: func(string) string { return providerOpenAIResponsesPath },
- buildBody: func(model, prompt string) ([]byte, error) {
- return json.Marshal(map[string]any{
- "model": model,
- "instructions": "You are a channel health-check endpoint. Answer the arithmetic challenge exactly and briefly.",
- "input": prompt,
- "max_output_tokens": monitorChallengeMaxTokens,
- "stream": false,
- })
- },
- buildHeaders: func(apiKey string) map[string]string {
- return map[string]string{"Authorization": "Bearer " + apiKey}
- },
- textPath: "output.0.content.0.text",
- }
- // providerAdapterFor 按 provider + api_mode 选择具体 adapter。
- func providerAdapterFor(provider, apiMode string) (providerAdapter, string, bool) {
- if provider == MonitorProviderOpenAI && defaultAPIMode(apiMode) == MonitorAPIModeResponses {
- return providerOpenAIResponsesAdapter, MonitorAPIModeResponses, true
- }
- adapter, ok := providerAdapters[provider]
- return adapter, MonitorAPIModeChatCompletions, ok
- }
- // callProvider 通过 providerAdapters 分发到具体实现。
- // opts 承载用户的自定义 headers / body 覆盖(可为 nil)。
- //
- // 返回值:
- // - extractedText: 按 textPath 抽出的成功文本,仅在 status 2xx 时有意义;非 2xx 时通常为空串
- // - rawBody: 完整响应体的字符串形式(已被 monitorResponseMaxBytes 截断),用于错误路径保留上游真实回包
- // - status: HTTP 状态码
- // - err: 网络 / 序列化错误
- func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt string, opts *CheckOptions) (extractedText, rawBody string, status int, err error) {
- requestedAPIMode := checkAPIMode(opts)
- if err := validateAPIMode(provider, requestedAPIMode); err != nil {
- return "", "", 0, err
- }
- adapter, apiMode, ok := providerAdapterFor(provider, requestedAPIMode)
- if !ok {
- return "", "", 0, fmt.Errorf("unsupported provider %q", provider)
- }
- body, err := buildRequestBody(adapter, provider, apiMode, model, prompt, opts)
- if err != nil {
- return "", "", 0, err
- }
- headers := mergeHeaders(adapter.buildHeaders(apiKey), opts)
- full := joinURL(endpoint, adapter.buildPath(model))
- respBytes, status, err := postRawJSON(ctx, full, body, headers)
- if err != nil {
- return "", "", status, err
- }
- if provider == MonitorProviderOpenAI && apiMode == MonitorAPIModeResponses {
- return extractOpenAIResponsesText(respBytes), string(respBytes), status, nil
- }
- return extractMonitorResponseText(adapter, respBytes), string(respBytes), status, nil
- }
- func extractMonitorResponseText(adapter providerAdapter, respBytes []byte) string {
- if adapter.extractText != nil {
- return adapter.extractText(respBytes)
- }
- return gjson.GetBytes(respBytes, adapter.textPath).String()
- }
- func extractAnthropicMonitorText(respBytes []byte) string {
- content := gjson.GetBytes(respBytes, "content")
- if !content.IsArray() {
- return ""
- }
- parts := make([]string, 0, 1)
- content.ForEach(func(_, item gjson.Result) bool {
- if item.Get("type").String() != "text" {
- return true
- }
- text := strings.TrimSpace(item.Get("text").String())
- if text != "" {
- parts = append(parts, text)
- }
- return true
- })
- return strings.Join(parts, "\n")
- }
- // extractOpenAIResponsesText 聚合 Responses API 的最终 assistant 文本。
- // Responses 的 output 数组顺序由模型决定:reasoning / tool-call item 可能排在 message 前面,
- // 因此不能假设文本永远在 output.0.content.0.text。
- func extractOpenAIResponsesText(respBytes []byte) string {
- if text := gjson.GetBytes(respBytes, "output_text").String(); strings.TrimSpace(text) != "" {
- return text
- }
- var texts []string
- outputs := gjson.GetBytes(respBytes, "output")
- if outputs.IsArray() {
- outputs.ForEach(func(_, output gjson.Result) bool {
- outputType := output.Get("type").String()
- if outputType != "" && outputType != "message" {
- return true
- }
- content := output.Get("content")
- if !content.IsArray() {
- return true
- }
- content.ForEach(func(_, block gjson.Result) bool {
- blockType := block.Get("type").String()
- if blockType != "" && blockType != "output_text" {
- return true
- }
- if text := block.Get("text").String(); strings.TrimSpace(text) != "" {
- texts = append(texts, text)
- }
- return true
- })
- return true
- })
- }
- if len(texts) > 0 {
- return strings.Join(texts, "")
- }
- return gjson.GetBytes(respBytes, providerOpenAIResponsesAdapter.textPath).String()
- }
- // mergeHeaders 把用户自定义 headers 合并到 adapter 默认 headers 上。
- // 用户值覆盖默认;命中黑名单(hop-by-hop / 由 http.Client 自管的)的 key 静默丢弃。
- func mergeHeaders(base map[string]string, opts *CheckOptions) map[string]string {
- if opts == nil || len(opts.ExtraHeaders) == 0 {
- return base
- }
- out := make(map[string]string, len(base)+len(opts.ExtraHeaders))
- for k, v := range base {
- out[k] = v
- }
- for k, v := range opts.ExtraHeaders {
- if IsForbiddenHeaderName(k) {
- continue
- }
- out[k] = v
- }
- return out
- }
- // buildRequestBody 根据 body_override_mode 构造请求 body。
- //
- // - off: adapter 默认 body
- // - merge: adapter 默认 body 与 BodyOverride 浅合并;BodyOverride 中命中
- // bodyMergeKeyDenyList[provider] 的 key 会被静默丢弃,避免破坏 challenge / model 路由
- // - replace: 直接 marshal BodyOverride 作为完整 body
- //
- // 任何 mode 返回的 []byte 都已经是合法 JSON,可直接送入 postRawJSON。
- func buildRequestBody(adapter providerAdapter, provider, apiMode, model, prompt string, opts *CheckOptions) ([]byte, error) {
- mode := bodyOverrideMode(opts)
- if mode == MonitorBodyOverrideModeReplace {
- if opts == nil || len(opts.BodyOverride) == 0 {
- return nil, fmt.Errorf("replace mode: body_override is empty")
- }
- if err := validateReplaceRequestBody(provider, apiMode, opts.BodyOverride); err != nil {
- return nil, err
- }
- body, err := json.Marshal(opts.BodyOverride)
- if err != nil {
- return nil, fmt.Errorf("marshal body_override (replace): %w", err)
- }
- return body, nil
- }
- defaultBody, err := adapter.buildBody(model, prompt)
- if err != nil {
- return nil, fmt.Errorf("marshal default body: %w", err)
- }
- if mode != MonitorBodyOverrideModeMerge || opts == nil || len(opts.BodyOverride) == 0 {
- return defaultBody, nil
- }
- var defaultMap map[string]any
- if err := json.Unmarshal(defaultBody, &defaultMap); err != nil {
- return nil, fmt.Errorf("unmarshal default body for merge: %w", err)
- }
- deny := bodyMergeKeyDenyList[bodyMergeDenyKey(provider, apiMode)]
- for k, v := range opts.BodyOverride {
- if deny[k] {
- continue
- }
- defaultMap[k] = v
- }
- merged, err := json.Marshal(defaultMap)
- if err != nil {
- return nil, fmt.Errorf("marshal merged body: %w", err)
- }
- return merged, nil
- }
- // bodyMergeKeyDenyList 在 merge 模式下,禁止用户覆盖这些 provider-specific 的关键字段。
- // 思路抄 check-cx 的 EXCLUDED_METADATA_KEYS:保护 challenge / model 路由不被用户误伤。
- // 用户想动这些字段就用 replace 模式(已知会跳 challenge 校验)。
- //
- //nolint:gochecknoglobals // 静态查表,初始化后不变。
- var bodyMergeKeyDenyList = map[string]map[string]bool{
- MonitorProviderOpenAI + ":" + MonitorAPIModeChatCompletions: {"model": true, "messages": true, "stream": true},
- MonitorProviderOpenAI + ":" + MonitorAPIModeResponses: {"model": true, "instructions": true, "input": true, "stream": true},
- MonitorProviderGrok: {"model": true, "messages": true, "stream": true},
- MonitorProviderAnthropic: {"model": true, "messages": true},
- MonitorProviderGemini: {"contents": true},
- // 国产 3 家与 OpenAI Chat Completions 同构。
- MonitorProviderKimi: {"model": true, "messages": true, "stream": true},
- MonitorProviderZhipu: {"model": true, "messages": true, "stream": true},
- MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true},
- MonitorProviderMiniMax: {"model": true, "messages": true, "stream": true},
- }
- func checkAPIMode(opts *CheckOptions) string {
- if opts == nil {
- return MonitorAPIModeChatCompletions
- }
- return defaultAPIMode(opts.APIMode)
- }
- func bodyMergeDenyKey(provider, apiMode string) string {
- if provider == MonitorProviderOpenAI {
- return provider + ":" + defaultAPIMode(apiMode)
- }
- return provider
- }
- // isOpenAICompatibleChatProvider 该 provider 的探活请求是否为 OpenAI Chat
- // Completions 同构(replace 模式的 body 校验按 messages 必填处理)。
- func isOpenAICompatibleChatProvider(provider string) bool {
- switch provider {
- case MonitorProviderOpenAI, MonitorProviderGrok,
- MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, MonitorProviderMiniMax:
- return true
- default:
- return false
- }
- }
- func validateReplaceRequestBody(provider, apiMode string, body map[string]any) error {
- if !isOpenAICompatibleChatProvider(provider) {
- return nil
- }
- switch defaultAPIMode(apiMode) {
- case MonitorAPIModeResponses:
- if strings.TrimSpace(stringFromAny(body["instructions"])) == "" || !hasNonEmptyBodyValue(body["input"]) {
- return fmt.Errorf("replace mode responses body: instructions and input are required")
- }
- case MonitorAPIModeChatCompletions:
- if !hasNonEmptyBodyValue(body["messages"]) {
- return fmt.Errorf("replace mode chat_completions body: messages are required")
- }
- }
- return nil
- }
- func stringFromAny(v any) string {
- s, _ := v.(string)
- return s
- }
- func hasNonEmptyBodyValue(v any) bool {
- switch val := v.(type) {
- case nil:
- return false
- case string:
- return strings.TrimSpace(val) != ""
- case []any:
- return len(val) > 0
- case []map[string]any:
- return len(val) > 0
- case []map[string]string:
- return len(val) > 0
- default:
- return true
- }
- }
- // postRawJSON 发送 POST + 已序列化好的 JSON 字节,限制响应体大小,返回响应字节、HTTP status、错误。
- // adapter 自行 marshal 是为了精确控制字段顺序与类型,所以这里直接收 []byte 而不是 any。
- func postRawJSON(ctx context.Context, fullURL string, payload []byte, headers map[string]string) ([]byte, int, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload))
- if err != nil {
- return nil, 0, fmt.Errorf("build request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- for k, v := range headers {
- req.Header.Set(k, v)
- }
- resp, err := monitorHTTPClient.Do(req)
- if err != nil {
- return nil, 0, fmt.Errorf("do request: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- respBody, err := io.ReadAll(io.LimitReader(resp.Body, monitorResponseMaxBytes))
- if err != nil {
- return nil, resp.StatusCode, fmt.Errorf("read body: %w", err)
- }
- return respBody, resp.StatusCode, nil
- }
- // joinURL 把 base origin 与 path 拼成完整 URL。
- // 容忍 base 末尾有/无斜杠,path 必带前导斜杠。
- func joinURL(base, path string) string {
- base = strings.TrimRight(base, "/")
- if !strings.HasPrefix(path, "/") {
- path = "/" + path
- }
- return base + path
- }
- // extractOrigin 从一个 endpoint URL 中提取 scheme://host[:port] 部分。
- func extractOrigin(endpoint string) (string, error) {
- u, err := url.Parse(endpoint)
- if err != nil {
- return "", err
- }
- if u.Scheme == "" || u.Host == "" {
- return "", errors.New("endpoint missing scheme or host")
- }
- return u.Scheme + "://" + u.Host, nil
- }
- // monitorSensitiveQueryParamRegex 匹配 URL query 中可能泄露凭证的参数:
- // key / api_key / api-key / access_token / token / authorization / x-api-key。
- // 大小写不敏感,匹配 `?name=value` 或 `&name=value` 形式(value 截到 & 或字符串末尾)。
- var monitorSensitiveQueryParamRegex = regexp.MustCompile(`(?i)([?&](?:key|api[_-]?key|access[_-]?token|token|authorization|x-api-key)=)[^&\s"']+`)
- // monitorAPIKeyPatterns 匹配常见 provider 的 API key 字面量。
- // 顺序敏感:sk-ant- 必须放在 sk- 之前,否则会被通用 sk- 模式先消费。
- var monitorAPIKeyPatterns = []struct {
- pattern *regexp.Regexp
- replace string
- }{
- // Anthropic(带前缀,必须先匹配):sk-ant-xxxxxxx
- {regexp.MustCompile(`sk-ant-[A-Za-z0-9_-]{20,}`), "sk-ant-***REDACTED***"},
- // OpenAI / Anthropic 通用 sk-: sk-xxxxxxx
- {regexp.MustCompile(`sk-[A-Za-z0-9-]{20,}`), "sk-***REDACTED***"},
- // xAI API Key:xai-xxxxxxx
- {regexp.MustCompile(`xai-[A-Za-z0-9_-]{6,}`), "xai-***REDACTED***"},
- // Gemini / Google API Key:固定前缀 + 35 位
- {regexp.MustCompile(`AIza[A-Za-z0-9_-]{35}`), "AIza***REDACTED***"},
- // JWT 三段式(Bearer 后常出现):eyJxxx.eyJxxx.signature
- {regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`), "eyJ***REDACTED.JWT***"},
- }
- // sanitizeErrorMessage 擦除错误/响应文本中可能泄露的 API key。
- // 处理两类来源:
- // 1. URL query 中的 ?key= / ?api_key= 等(Go *url.Error 会回填完整 URL)
- // 2. 上游 HTTP body 文本里直接出现的 sk-* / xai-* / AIza* / JWT 等密钥碎片
- //
- // 注意:与 gemini_messages_compat_service.go 的 sanitizeUpstreamErrorMessage 关注点类似但参数集更广,
- // 监控模块独立维护,避免互相耦合。
- func sanitizeErrorMessage(msg string) string {
- if msg == "" {
- return msg
- }
- msg = monitorSensitiveQueryParamRegex.ReplaceAllString(msg, `${1}REDACTED`)
- for _, p := range monitorAPIKeyPatterns {
- msg = p.pattern.ReplaceAllString(msg, p.replace)
- }
- return msg
- }
- // truncateMessage 把消息按 monitorMessageMaxBytes 截断,避免 DB 列溢出与日志过长。
- func truncateMessage(msg string) string {
- if len(msg) <= monitorMessageMaxBytes {
- return msg
- }
- const ellipsis = "...(truncated)"
- cutoff := monitorMessageMaxBytes - len(ellipsis)
- if cutoff < 0 {
- cutoff = 0
- }
- return msg[:cutoff] + ellipsis
- }
- // truncateForErrorBody 把上游错误响应 body 压到 monitorErrorBodySnippetMaxBytes 以内,
- // 并顺手把连续空白折成一个空格:上游 HTML 错误页常含大量缩进/换行,保留会浪费预算。
- // 被 truncateMessage 做最终总截断兜底,所以这里只负责 body 自身的精简。
- func truncateForErrorBody(body string) string {
- body = strings.Join(strings.Fields(body), " ")
- if len(body) <= monitorErrorBodySnippetMaxBytes {
- return body
- }
- const ellipsis = "...(body truncated)"
- cutoff := monitorErrorBodySnippetMaxBytes - len(ellipsis)
- if cutoff < 0 {
- cutoff = 0
- }
- return body[:cutoff] + ellipsis
- }
|