Files
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

248 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"net/http"
"regexp"
"strings"
"sync"
"sync/atomic"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/google/uuid"
)
// codexUpstreamMinVersion 上游 /backend-api/codex 接受的最低 version 头:
// 若请求携带 version 且低于该值,上游直接 404issue #39012026-07 实测)。
const codexUpstreamMinVersion = "0.144.0"
// codexClientVersionMaxLen 官方版本号均为短 ASCII 串,远低于此上限。
const codexClientVersionMaxLen = 64
// codexClientVersionPattern 允许 0.146.0 与 0.147.0-alpha.4 两类官方形态。
var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`)
// NormalizeCodexClientVersion 校验并归一化 Codex 客户端版本号,非法值返回空串。
// 该值会被拼进出站 User-Agent 与 version 头,必须拒绝任意字节,避免管理员误填或
// 自动同步拿到异常值时把不可控内容透给上游。
func NormalizeCodexClientVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" || len(version) > codexClientVersionMaxLen || !codexClientVersionPattern.MatchString(version) {
return ""
}
return version
}
// buildCodexCLIUserAgent 按版本号拼出规范 Codex TUI User-Agent。
// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。
func buildCodexCLIUserAgent(version string) string {
if version = NormalizeCodexClientVersion(version); version == "" {
return codexCLIUserAgent
}
return openai.CodexDefaultOriginator + "/" + version + codexCLIUserAgentSuffix
}
// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份,
// 由 gateway.disable_codex_identity_enforcement 在服务构造时取反发布。
// 默认开启:上游在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到
// HTTP 200 + 流内 server_is_overloaded,本次请求即失败;强制统一出口可确保没有
// 请求带着第三方或陈旧身份出站。关闭后退回「仅按最终 UA 配对 originator」的收口语义。
var codexIdentityEnforcement = func() *atomic.Bool {
v := &atomic.Bool{}
v.Store(true)
return v
}()
// SetCodexIdentityEnforcementEnabled 发布 Codex 出站身份强制统一开关。
// enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置,
// 故由持有配置的服务在构造时发布进程级快照。
func SetCodexIdentityEnforcementEnabled(enabled bool) {
codexIdentityEnforcement.Store(enabled)
}
// codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。
// 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。
type codexCanonicalUserAgentResolver func() string
var (
codexCanonicalUAMu sync.RWMutex
codexCanonicalUAResolver codexCanonicalUserAgentResolver
)
// SetCodexCanonicalUserAgentResolver 注入规范 User-Agent 解析器。
// 未注入或解析结果非法时回退到编译期常量 codexCLIUserAgent。
func SetCodexCanonicalUserAgentResolver(resolver func() string) {
codexCanonicalUAMu.Lock()
defer codexCanonicalUAMu.Unlock()
codexCanonicalUAResolver = resolver
}
// CodexCanonicalUserAgent 返回当前生效的规范 Codex User-Agent。
// 取值走与推理相同的解析链:面板 UA 指纹 + 面板/自动同步版本号 + 编译期兜底。
// 供无账号句柄的出站路径(OAuth 换 Token / 刷新)使用。
func CodexCanonicalUserAgent() string {
return resolveCodexOutboundIdentity("").userAgent
}
// CodexCanonicalAuthIdentity 返回凭据面(auth.openai.com:换 Token / 刷新 / whoami
// 出站请求的身份对:规范 User-Agent 与配套 originator,与推理解析链同源。
// 凭据面不发 version 头——真实 Codex 客户端在该面只携带 originator 与 User-Agent
// codex-rs login/default_client.rs 的 default_headers()),version 门槛
// issue #3901)只存在于 /backend-api/codex 推理面。
func CodexCanonicalAuthIdentity() (userAgent, originator string) {
identity := resolveCodexOutboundIdentity("")
return identity.userAgent, identity.originator
}
// ApplyCodexCanonicalAuthIdentity 为凭据面出站请求写入身份对(不含 version)。
func ApplyCodexCanonicalAuthIdentity(h http.Header) {
if h == nil {
return
}
userAgent, originator := CodexCanonicalAuthIdentity()
h.Set("user-agent", userAgent)
h.Set("originator", originator)
}
// CodexCanonicalClientVersion 返回当前生效的 Codex 客户端版本号。
func CodexCanonicalClientVersion() string {
return resolveCodexOutboundIdentity("").version
}
// codexCanonicalUserAgent 返回出站规范 User-Agent。
func codexCanonicalUserAgent() string {
codexCanonicalUAMu.RLock()
resolver := codexCanonicalUAResolver
codexCanonicalUAMu.RUnlock()
if resolver != nil {
if ua := strings.TrimSpace(resolver()); ua != "" {
return ua
}
}
return codexCLIUserAgent
}
// codexOutboundIdentity 出站身份三元组,三者必须同源自洽:
// originator 与 User-Agent 首段配套(否则上游 404issue #3901),
// version 等于 User-Agent 的版本段且不低于上游门槛。
type codexOutboundIdentity struct {
userAgent string
originator string
version string
}
// resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。
// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 TUI 身份。
//
// 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹,
// 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份
// 永久钉死在陈旧版本上,绕过版本自动同步,落回上游优先降载的那一侧。
// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity {
canonical := codexCanonicalUserAgent()
ua := strings.TrimSpace(candidateUA)
if ua == "" {
ua = canonical
}
originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
if !ok {
if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok {
originator, pairedUA = openai.CodexDefaultOriginator, codexCLIUserAgent
}
}
// 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见
// SettingService.GetOpenAICodexClientVersion)。UA 与 version 头由此同源派生。
version := codexClientVersionFromUA(canonical)
if rebuilt := openai.SetCodexUserAgentVersion(pairedUA, version); rebuilt != "" {
pairedUA = rebuilt
}
return codexOutboundIdentity{userAgent: pairedUA, originator: originator, version: version}
}
// codexClientVersionFromUA 取 UA 的版本段作为生效版本;
// 非法或低于上游门槛(低于则上游 404,issue #3901)时回退编译期常量。
func codexClientVersionFromUA(ua string) string {
version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(ua))
if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 {
return codexCLIVersion
}
return version
}
// ensureCodexIdentityHeaders 补齐 OAuthChatGPT 内部接口)出站请求所需的 Codex 身份头。
// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。
func ensureCodexIdentityHeaders(h http.Header) {
if h == nil {
return
}
identity := resolveCodexOutboundIdentity("")
if strings.TrimSpace(h.Get("user-agent")) == "" {
h.Set("user-agent", identity.userAgent)
}
if strings.TrimSpace(h.Get("originator")) == "" {
h.Set("originator", identity.originator)
}
if strings.TrimSpace(h.Get("version")) == "" {
h.Set("version", identity.version)
}
h.Set("OpenAI-Beta", "responses=experimental")
}
// applyOpenAICodexProbeHeaders 为合成探测请求补齐 Codex 身份和引擎指纹。
func applyOpenAICodexProbeHeaders(h http.Header) {
if h == nil {
return
}
ensureCodexIdentityHeaders(h)
h.Set("X-Codex-Window-ID", uuid.NewString())
}
// enforceCodexIdentityHeaders 收口 OAuthChatGPT 内部接口)出站请求的客户端身份头。
// 见 enforceCodexIdentityHeadersWithUA;无账号级自定义 User-Agent 时使用本函数。
func enforceCodexIdentityHeaders(h http.Header) {
enforceCodexIdentityHeadersWithUA(h, "")
}
// enforceCodexIdentityHeadersWithUA 强制统一 OAuth 出站身份:User-Agent / originator / version
// 一律改写为网关的规范身份,客户端自报身份不参与构造。上游在容量紧张时按客户端身份分优先级
// 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着
// 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。
//
// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但只贡献客户端名与
// OS / 架构 / 终端指纹——版本段与 originator 都由规范身份重建,不允许出现自相矛盾或陈旧的身份。
//
// 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对
// originator + version 门槛校正」的收口语义,供上游策略变动时回滚。
//
// 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator,
// 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。
// 必须在所有 User-Agent 改写之后调用。
func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) {
if h == nil || h.Get("originator") == "" {
return
}
if !codexIdentityEnforcement.Load() {
pairCodexIdentityHeaders(h)
return
}
identity := resolveCodexOutboundIdentity(overrideUA)
h.Set("user-agent", identity.userAgent)
h.Set("originator", identity.originator)
h.Set("version", identity.version)
}
// pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份,
// 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。
func pairCodexIdentityHeaders(h http.Header) {
originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent"))
if !ok {
identity := resolveCodexOutboundIdentity("")
originator, pairedUA = identity.originator, identity.userAgent
h.Set("version", identity.version)
}
h.Set("user-agent", pairedUA)
h.Set("originator", originator)
if v := strings.TrimSpace(h.Get("version")); v != "" && CompareVersions(v, codexUpstreamMinVersion) < 0 {
h.Set("version", resolveCodexOutboundIdentity("").version)
}
}