Files
sub2api/backend/internal/service/gateway_systemone.go
shaw 9eb394e3e9 fix(typesafe): 堵住校验解析差异与审计盲区,收紧模型列表口径
- System One 校验拒绝重复键与大小写/Unicode 折叠变体键(请求顶层、
  questions、问题对象):encoding/json 大小写不敏感且取最后一个重复键,
  而原始 body 原样转发,此前 {"model":"x","MODEL":"jev-latest"} 可绕过
  jev-latest 限制、模型白名单与流式限制。
- Prompt 审计与旧审核同时收集对象键名、问题 ID 与未知扩展字段(不含规范
  字段名与已校验的 type),此前把内容放进键名即可完全绕过审计。
- 上游成功响应的 usage/model 宽松解码(浮点、数字字符串),避免上游已
  计费而网关整体 502 不记账;解码失败记录 response_error ops 事件。
- 上游 413 与 400/422 同视为请求错误:透传状态码、不切换、不改账号状态。
- TypeSafe base_url 末尾的 /v1 自动剥离,避免拼出 /v1/v1/systemone;
  /v1/systemone 改挂文本请求体上限。
- Composite 静态兜底模型列表、管理端候选列表恢复为不含 jev-latest;
  Codex 清单永不列出 TypeSafe 模型;TypeSafe 分组候选默认 jev-latest。
- 创建账号从 Grok 切到 TypeSafe 时重置为白名单模式;错误透传平台列表补 typesafe。
2026-10-02 13:51:58 +08:00

183 lines
6.5 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"mime"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/gin-gonic/gin"
)
type SystemOneForwardResult struct {
ForwardResult
StatusCode int
Body []byte
ContentType string
}
type SystemOneUpstreamError struct {
StatusCode int
}
const TypeSafeCredentialRejectedReason GatewayFailureReason = "typesafe_api_key_rejected"
func (e *SystemOneUpstreamError) Error() string {
return fmt.Sprintf("typesafe upstream rejected request with status %d", e.StatusCode)
}
func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, account *Account, body []byte) (*SystemOneForwardResult, error) {
started := time.Now()
if account == nil || !account.IsTypeSafe() || account.Type != AccountTypeAPIKey {
return nil, errors.New("invalid typesafe account")
}
key := account.GetTypeSafeAPIKey()
if key == "" {
return nil, errors.New("typesafe api key is missing")
}
baseURL, err := s.validateUpstreamBaseURL(account.GetTypeSafeBaseURL())
if err != nil {
return nil, err
}
req, err := typesafe.NewSystemOneRequest(ctx, baseURL, key, body)
if err != nil {
return nil, err
}
upstreamURL := req.URL.Scheme + "://" + req.URL.Host + req.URL.Path
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
if err != nil {
return nil, s.handleUpstreamTransportError(ctx, c, account, err, OpsUpstreamErrorEvent{
Passthrough: true,
UpstreamURL: upstreamURL,
})
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, s.handleSystemOneErrorResponse(ctx, c, account, resp, upstreamURL)
}
decoded, err := typesafe.DecodeSystemOneResponse(resp.Body)
if err != nil {
// The upstream accepted (and may have charged) this request but the
// gateway cannot relay it; keep an ops trail for reconciliation.
setOpsUpstreamError(c, resp.StatusCode, err.Error(), "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Passthrough: true,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: upstreamURL,
Kind: "response_error",
Message: err.Error(),
})
return nil, err
}
return &SystemOneForwardResult{
ForwardResult: ForwardResult{
RequestID: resp.Header.Get("x-request-id"),
UpstreamHeaders: resp.Header.Clone(),
Usage: ClaudeUsage{InputTokens: decoded.Usage.InputTokens, OutputTokens: decoded.Usage.OutputTokens},
Model: typesafe.JevLatestModel,
UpstreamResponseModel: decoded.Model,
Duration: time.Since(started),
},
StatusCode: resp.StatusCode,
Body: decoded.Body,
ContentType: systemOneResponseContentType(resp.Header.Get("Content-Type")),
}, nil
}
// IsSystemOneRequestErrorStatus reports upstream statuses that describe the
// caller's own payload (malformed, unprocessable, or too large).
func IsSystemOneRequestErrorStatus(status int) bool {
switch status {
case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity:
return true
default:
return false
}
}
// handleSystemOneErrorResponse applies the shared account error policy to a
// non-2xx System One response. 400/413/422 describe the caller's own payload, so
// they never touch account state (a tenant must not be able to disable an
// account with bad input) and are not retried elsewhere. Every other status
// goes through the account error policy (custom error codes, temporary
// unschedulable rules, pool mode) and fails over when the status is retryable
// or the policy took the account out of rotation.
func (s *GatewayService) handleSystemOneErrorResponse(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, upstreamURL string) error {
respBody, _ := s.readUpstreamErrorBody(resp)
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "")
event := OpsUpstreamErrorEvent{
Passthrough: true,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: upstreamURL,
Kind: "http_error",
Message: upstreamMsg,
}
if IsSystemOneRequestErrorStatus(resp.StatusCode) {
appendOpsUpstreamError(c, event)
return &SystemOneUpstreamError{StatusCode: resp.StatusCode}
}
shouldDisable := false
if s.rateLimitService != nil {
shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, typesafe.JevLatestModel)
}
if !shouldDisable && !s.shouldFailoverUpstreamError(resp.StatusCode) {
appendOpsUpstreamError(c, event)
return &SystemOneUpstreamError{StatusCode: resp.StatusCode}
}
event.Kind = "failover"
appendOpsUpstreamError(c, event)
failoverErr := &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
ResponseHeaders: resp.Header.Clone(),
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
if resp.StatusCode == http.StatusUnauthorized {
failoverErr.Stage = GatewayFailureStageAccountAuth
failoverErr.Scope = GatewayFailureScopeAccount
failoverErr.Reason = TypeSafeCredentialRejectedReason
failoverErr.NextAccountAction = NextAccountRetry
}
return failoverErr
}
// systemOneResponseContentType keeps the upstream JSON media type (and its
// charset) but never relays a non-JSON type for a body already validated as
// JSON, so the gateway origin cannot be made to serve it as HTML.
func systemOneResponseContentType(raw string) string {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(raw))
if err != nil {
return "application/json"
}
if mediaType == "application/json" || (strings.HasPrefix(mediaType, "application/") && strings.HasSuffix(mediaType, "+json")) {
return strings.TrimSpace(raw)
}
return "application/json"
}