mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
Fix four issues flagged by copilot-pull-request-reviewer on PR #2143: 1. Probe URL missing /v1 prefix (openai_apikey_responses_probe.go) Replaced bare TrimSuffix + "/responses" with buildOpenAIResponsesURL(), which handles bare domain → /v1/responses correctly. Affected: - ProbeOpenAIAPIKeyResponsesSupport (probe URL) - TestAccount endpoint (apiURL for APIKey accounts) 2. Create endpoint not triggering probe (account_handler.go) Capture created account from idempotent closure and call scheduleOpenAIResponsesProbe after success, same pattern as BatchCreate and Update. 3. Tests (openai_gateway_chat_completions_raw_test.go) Added TestBuildOpenAIChatCompletionsURL (7 cases covering bare domain, /v1 suffix, trailing slash, third-party domains, whitespace) and TestBuildOpenAIResponsesURL_ProbeURL (6 cases locking the probe URL construction for bare-domain inputs). All unit tests pass; go build ./cmd/server/ clean.
150 lines
5.5 KiB
Go
150 lines
5.5 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||
)
|
||
|
||
// openaiResponsesProbeTimeout 是探测请求的超时时长。
|
||
// 探测必须快速失败——超时不应阻塞账号创建/更新流程。
|
||
const openaiResponsesProbeTimeout = 8 * time.Second
|
||
|
||
// openaiResponsesProbePayload 是探测使用的最小 Responses 请求体。
|
||
// 仅作能力探测,不期望响应内容质量;Stream=false 减少 SSE 解析开销。
|
||
//
|
||
// 注意:探测的目标是区分"端点存在"与"端点不存在"——只要上游返回非 404 的
|
||
// 4xx/5xx(如 400 invalid_request_error / 401 unauthorized / 422 等),
|
||
// 都视为"端点存在 → 支持 Responses"。仅 404 / 405 视为"端点不存在"。
|
||
func openaiResponsesProbePayload(modelID string) []byte {
|
||
if strings.TrimSpace(modelID) == "" {
|
||
modelID = openai.DefaultTestModel
|
||
}
|
||
body, _ := json.Marshal(map[string]any{
|
||
"model": modelID,
|
||
"input": []map[string]any{
|
||
{
|
||
"role": "user",
|
||
"content": []map[string]any{
|
||
{"type": "input_text", "text": "hi"},
|
||
},
|
||
},
|
||
},
|
||
"instructions": openai.DefaultInstructions,
|
||
"stream": false,
|
||
})
|
||
return body
|
||
}
|
||
|
||
// ProbeOpenAIAPIKeyResponsesSupport 探测 OpenAI APIKey 账号上游是否支持
|
||
// /v1/responses 端点,并将结果持久化到 accounts.extra.openai_responses_supported。
|
||
//
|
||
// 调用时机:账号创建/更新后,且仅当 platform=openai && type=apikey 时。
|
||
//
|
||
// 探测策略(参见包文档 internal/pkg/openai_compat):
|
||
// - 上游 404 / 405 → 不支持,写 false
|
||
// - 上游 2xx / 其他 4xx(401/422/400 等)/ 5xx → 支持,写 true
|
||
// - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
|
||
// (后续请求仍按"现状即证据"默认走 Responses)
|
||
//
|
||
// 该方法是幂等的:重复调用会以最新探测结果覆盖标记。
|
||
//
|
||
// 关于失败处理:探测本身的失败不应阻塞账号创建——账号能创建/更新成功就够了,
|
||
// 探测结果只影响后续路由优化。所有错误都仅记录日志,不向调用方传播。
|
||
func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Context, accountID int64) {
|
||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||
if err != nil {
|
||
logger.LegacyPrintf("service.openai_probe", "probe_load_account_failed: account_id=%d err=%v", accountID, err)
|
||
return
|
||
}
|
||
if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey {
|
||
// 仅 OpenAI APIKey 账号需要探测;其他账号类型无能力差异。
|
||
return
|
||
}
|
||
|
||
apiKey := account.GetOpenAIApiKey()
|
||
if apiKey == "" {
|
||
logger.LegacyPrintf("service.openai_probe", "probe_skip_no_apikey: account_id=%d", accountID)
|
||
return
|
||
}
|
||
baseURL := account.GetOpenAIBaseURL()
|
||
if baseURL == "" {
|
||
baseURL = "https://api.openai.com"
|
||
}
|
||
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
||
if err != nil {
|
||
logger.LegacyPrintf("service.openai_probe", "probe_invalid_baseurl: account_id=%d base_url=%q err=%v", accountID, baseURL, err)
|
||
return
|
||
}
|
||
|
||
probeURL := buildOpenAIResponsesURL(normalizedBaseURL)
|
||
|
||
probeCtx, cancel := context.WithTimeout(ctx, openaiResponsesProbeTimeout)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, probeURL, bytes.NewReader(openaiResponsesProbePayload("")))
|
||
if err != nil {
|
||
logger.LegacyPrintf("service.openai_probe", "probe_build_request_failed: account_id=%d err=%v", accountID, err)
|
||
return
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
req.Header.Set("Accept", "application/json")
|
||
|
||
proxyURL := ""
|
||
if account.ProxyID != nil && account.Proxy != nil {
|
||
proxyURL = account.Proxy.URL()
|
||
}
|
||
|
||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||
if err != nil {
|
||
// 网络层失败:不写标记,保持 unknown,下次重试或由网关 fallback 处理
|
||
logger.LegacyPrintf("service.openai_probe", "probe_request_failed: account_id=%d url=%s err=%v", accountID, probeURL, err)
|
||
return
|
||
}
|
||
defer func() {
|
||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||
_ = resp.Body.Close()
|
||
}()
|
||
|
||
supported := isResponsesEndpointSupportedByStatus(resp.StatusCode)
|
||
|
||
if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
|
||
openai_compat.ExtraKeyResponsesSupported: supported,
|
||
}); err != nil {
|
||
logger.LegacyPrintf("service.openai_probe", "probe_persist_failed: account_id=%d supported=%v err=%v", accountID, supported, err)
|
||
return
|
||
}
|
||
|
||
logger.LegacyPrintf("service.openai_probe",
|
||
"probe_done: account_id=%d base_url=%s status=%d supported=%v",
|
||
accountID, normalizedBaseURL, resp.StatusCode, supported,
|
||
)
|
||
}
|
||
|
||
// isResponsesEndpointSupportedByStatus 根据探测响应的 HTTP 状态码判定上游
|
||
// 是否暴露 /v1/responses 端点。
|
||
//
|
||
// 关键观察:第三方 OpenAI 兼容上游(DeepSeek/Kimi 等)对未知端点统一返回 404
|
||
// 或 405;而 OpenAI 官方/有 Responses 实现的上游会因为请求体最简(缺字段)
|
||
// 返回 400/422 等业务错误,但端点本身存在。
|
||
//
|
||
// 因此:仅 404 和 405 视为"端点不存在",其他 status 视为"端点存在"。
|
||
//
|
||
// 5xx 也视为"端点存在"——上游偶发故障不应误判为不支持。
|
||
func isResponsesEndpointSupportedByStatus(status int) bool {
|
||
switch status {
|
||
case http.StatusNotFound, http.StatusMethodNotAllowed:
|
||
return false
|
||
}
|
||
return true
|
||
}
|