openai_ws_client.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "crypto/x509"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/url"
  11. "strings"
  12. "sync"
  13. "sync/atomic"
  14. "time"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  16. openaiwsv2 "github.com/Wei-Shaw/sub2api/internal/service/openai_ws_v2"
  17. coderws "github.com/coder/websocket"
  18. "github.com/coder/websocket/wsjson"
  19. )
  20. const openAIWSMessageReadLimitBytes int64 = 16 * 1024 * 1024
  21. const (
  22. openAIWSProxyTransportMaxIdleConns = 128
  23. openAIWSProxyTransportMaxIdleConnsPerHost = 64
  24. openAIWSProxyTransportIdleConnTimeout = 90 * time.Second
  25. openAIWSProxyClientCacheMaxEntries = 256
  26. openAIWSProxyClientCacheIdleTTL = 15 * time.Minute
  27. )
  28. type OpenAIWSTransportMetricsSnapshot struct {
  29. ProxyClientCacheHits int64 `json:"proxy_client_cache_hits"`
  30. ProxyClientCacheMisses int64 `json:"proxy_client_cache_misses"`
  31. TransportReuseRatio float64 `json:"transport_reuse_ratio"`
  32. }
  33. // openAIWSClientConn 抽象 WS 客户端连接,便于替换底层实现。
  34. type openAIWSClientConn interface {
  35. WriteJSON(ctx context.Context, value any) error
  36. ReadMessage(ctx context.Context) ([]byte, error)
  37. Ping(ctx context.Context) error
  38. Close() error
  39. }
  40. // openAIWSIdlePingCapable is intentionally separate from openAIWSClientConn.
  41. // A pool probe happens while no goroutine is reading an idle connection, which
  42. // is not safe for every WebSocket implementation.
  43. type openAIWSIdlePingCapable interface {
  44. SupportsIdlePingWithoutReader() bool
  45. }
  46. // openAIWSClientDialer 抽象 WS 建连器。
  47. type openAIWSClientDialer interface {
  48. Dial(ctx context.Context, wsURL string, headers http.Header, proxyURL string) (openAIWSClientConn, int, http.Header, error)
  49. }
  50. type openAIWSTransportMetricsDialer interface {
  51. SnapshotTransportMetrics() OpenAIWSTransportMetricsSnapshot
  52. }
  53. func newDefaultOpenAIWSClientDialer() openAIWSClientDialer {
  54. return &coderOpenAIWSClientDialer{
  55. proxyClients: make(map[string]*openAIWSProxyClientEntry),
  56. }
  57. }
  58. type coderOpenAIWSClientDialer struct {
  59. proxyMu sync.Mutex
  60. proxyClients map[string]*openAIWSProxyClientEntry
  61. proxyHits atomic.Int64
  62. proxyMisses atomic.Int64
  63. // System roots are used in production. Local handshake tests inject their CA.
  64. tlsRootCAs *x509.CertPool
  65. tlsProxyRootCAs *x509.CertPool
  66. }
  67. type openAIWSTLSContextKey struct{}
  68. type openAIWSTLSConfig struct {
  69. accountID int64
  70. profile *tlsfingerprint.Profile
  71. }
  72. func withOpenAIWSTLSProfile(ctx context.Context, accountID int64, profile *tlsfingerprint.Profile) context.Context {
  73. if ctx == nil {
  74. ctx = context.Background()
  75. }
  76. if profile == nil {
  77. return ctx
  78. }
  79. return context.WithValue(ctx, openAIWSTLSContextKey{}, openAIWSTLSConfig{accountID: accountID, profile: profile.Clone()})
  80. }
  81. // Include the full target and proxy in the digest without keeping credentials in
  82. // cache keys or logs. Profile contents, rather than its display name, define TLS.
  83. func openAIWSTransportKey(accountID int64, target, proxy string, profile *tlsfingerprint.Profile) string {
  84. if profile == nil {
  85. return ""
  86. }
  87. value := fmt.Sprintf("%d\x00%s\x00%s\x00%s", accountID, strings.TrimSpace(target), strings.TrimSpace(proxy), profile.CacheKey())
  88. return fmt.Sprintf("mode1:%x", sha256.Sum256([]byte(value)))
  89. }
  90. // openAIWSHandshakeError keeps a bounded, non-logged HTTP error body so the
  91. // Agent Identity recovery path can distinguish an invalid task from other
  92. // 401 handshake failures.
  93. type openAIWSHandshakeError struct {
  94. Body []byte
  95. Err error
  96. }
  97. func (e *openAIWSHandshakeError) Error() string {
  98. if e == nil || e.Err == nil {
  99. return "openai ws handshake failed"
  100. }
  101. return e.Err.Error()
  102. }
  103. func (e *openAIWSHandshakeError) Unwrap() error {
  104. if e == nil {
  105. return nil
  106. }
  107. return e.Err
  108. }
  109. type openAIWSProxyClientEntry struct {
  110. client *http.Client
  111. lastUsedUnixNano int64
  112. }
  113. func (d *coderOpenAIWSClientDialer) Dial(
  114. ctx context.Context,
  115. wsURL string,
  116. headers http.Header,
  117. proxyURL string,
  118. ) (openAIWSClientConn, int, http.Header, error) {
  119. targetURL := strings.TrimSpace(wsURL)
  120. if targetURL == "" {
  121. return nil, 0, nil, errors.New("ws url is empty")
  122. }
  123. opts := &coderws.DialOptions{
  124. HTTPHeader: cloneHeader(headers),
  125. CompressionMode: coderws.CompressionContextTakeover,
  126. }
  127. tlsConfig, protected := ctx.Value(openAIWSTLSContextKey{}).(openAIWSTLSConfig)
  128. if protected {
  129. parsedTarget, err := url.Parse(targetURL)
  130. if err != nil || parsedTarget.Scheme != "wss" || parsedTarget.Host == "" {
  131. return nil, 0, nil, errors.New("mode1 websocket requires a valid wss target")
  132. }
  133. client, err := d.fingerprintHTTPClient(tlsConfig, targetURL, proxyURL)
  134. if err != nil {
  135. return nil, 0, nil, err
  136. }
  137. opts.HTTPClient = client
  138. } else if proxy := strings.TrimSpace(proxyURL); proxy != "" {
  139. proxyClient, err := d.proxyHTTPClient(proxy)
  140. if err != nil {
  141. return nil, 0, nil, err
  142. }
  143. opts.HTTPClient = proxyClient
  144. }
  145. conn, resp, err := coderws.Dial(ctx, targetURL, opts)
  146. if err != nil {
  147. status := 0
  148. respHeaders := http.Header(nil)
  149. if resp != nil {
  150. status = resp.StatusCode
  151. respHeaders = cloneHeader(resp.Header)
  152. }
  153. var body []byte
  154. if resp != nil && resp.Body != nil {
  155. body, _ = io.ReadAll(io.LimitReader(resp.Body, 8<<10))
  156. _ = resp.Body.Close()
  157. }
  158. return nil, status, respHeaders, &openAIWSHandshakeError{Body: body, Err: err}
  159. }
  160. // coder/websocket 默认单消息读取上限为 32KB,Codex WS 事件(如 rate_limits/大 delta)
  161. // 可能超过该阈值,需显式提高上限,避免本地 read_fail(message too big)。
  162. conn.SetReadLimit(openAIWSMessageReadLimitBytes)
  163. respHeaders := http.Header(nil)
  164. if resp != nil {
  165. respHeaders = cloneHeader(resp.Header)
  166. }
  167. return &coderOpenAIWSClientConn{conn: conn}, 0, respHeaders, nil
  168. }
  169. func (d *coderOpenAIWSClientDialer) fingerprintHTTPClient(cfg openAIWSTLSConfig, target, proxy string) (*http.Client, error) {
  170. if d == nil || cfg.accountID <= 0 || cfg.profile == nil {
  171. return nil, errors.New("invalid mode1 websocket TLS configuration")
  172. }
  173. var proxyURL *url.URL
  174. if strings.TrimSpace(proxy) != "" {
  175. var err error
  176. proxyURL, err = url.Parse(strings.TrimSpace(proxy))
  177. if err != nil {
  178. return nil, errors.New("invalid mode1 websocket proxy URL")
  179. }
  180. }
  181. key := openAIWSTransportKey(cfg.accountID, target, proxy, cfg.profile)
  182. now := time.Now().UnixNano()
  183. d.proxyMu.Lock()
  184. defer d.proxyMu.Unlock()
  185. if entry := d.proxyClients[key]; entry != nil && entry.client != nil {
  186. entry.lastUsedUnixNano = now
  187. d.proxyHits.Add(1)
  188. return entry.client, nil
  189. }
  190. d.cleanupProxyClientsLocked(now)
  191. transport, err := tlsfingerprint.NewHTTPTransport(cfg.profile, proxyURL, tlsfingerprint.TransportOptions{
  192. RootCAs: d.tlsRootCAs, ProxyRootCAs: d.tlsProxyRootCAs, HandshakeTimeout: 10 * time.Second,
  193. })
  194. if err != nil {
  195. return nil, fmt.Errorf("mode1 websocket TLS transport: %w", err)
  196. }
  197. transport.MaxIdleConns = openAIWSProxyTransportMaxIdleConns
  198. transport.MaxIdleConnsPerHost = openAIWSProxyTransportMaxIdleConnsPerHost
  199. transport.IdleConnTimeout = openAIWSProxyTransportIdleConnTimeout
  200. client := &http.Client{Transport: transport}
  201. if d.proxyClients == nil {
  202. d.proxyClients = make(map[string]*openAIWSProxyClientEntry)
  203. }
  204. d.proxyClients[key] = &openAIWSProxyClientEntry{client: client, lastUsedUnixNano: now}
  205. d.ensureProxyClientCapacityLocked()
  206. d.proxyMisses.Add(1)
  207. return client, nil
  208. }
  209. func (d *coderOpenAIWSClientDialer) proxyHTTPClient(proxy string) (*http.Client, error) {
  210. if d == nil {
  211. return nil, errors.New("openai ws dialer is nil")
  212. }
  213. normalizedProxy := strings.TrimSpace(proxy)
  214. if normalizedProxy == "" {
  215. return nil, errors.New("proxy url is empty")
  216. }
  217. parsedProxyURL, err := url.Parse(normalizedProxy)
  218. if err != nil {
  219. return nil, fmt.Errorf("invalid proxy url: %w", err)
  220. }
  221. now := time.Now().UnixNano()
  222. d.proxyMu.Lock()
  223. defer d.proxyMu.Unlock()
  224. if entry, ok := d.proxyClients[normalizedProxy]; ok && entry != nil && entry.client != nil {
  225. entry.lastUsedUnixNano = now
  226. d.proxyHits.Add(1)
  227. return entry.client, nil
  228. }
  229. d.cleanupProxyClientsLocked(now)
  230. transport := &http.Transport{
  231. Proxy: http.ProxyURL(parsedProxyURL),
  232. MaxIdleConns: openAIWSProxyTransportMaxIdleConns,
  233. MaxIdleConnsPerHost: openAIWSProxyTransportMaxIdleConnsPerHost,
  234. IdleConnTimeout: openAIWSProxyTransportIdleConnTimeout,
  235. TLSHandshakeTimeout: 10 * time.Second,
  236. ForceAttemptHTTP2: true,
  237. }
  238. client := &http.Client{Transport: transport}
  239. d.proxyClients[normalizedProxy] = &openAIWSProxyClientEntry{
  240. client: client,
  241. lastUsedUnixNano: now,
  242. }
  243. d.ensureProxyClientCapacityLocked()
  244. d.proxyMisses.Add(1)
  245. return client, nil
  246. }
  247. func (d *coderOpenAIWSClientDialer) cleanupProxyClientsLocked(nowUnixNano int64) {
  248. if d == nil || len(d.proxyClients) == 0 {
  249. return
  250. }
  251. idleTTL := openAIWSProxyClientCacheIdleTTL
  252. if idleTTL <= 0 {
  253. return
  254. }
  255. now := time.Unix(0, nowUnixNano)
  256. for key, entry := range d.proxyClients {
  257. if entry == nil || entry.client == nil {
  258. delete(d.proxyClients, key)
  259. continue
  260. }
  261. lastUsed := time.Unix(0, entry.lastUsedUnixNano)
  262. if now.Sub(lastUsed) > idleTTL {
  263. closeOpenAIWSProxyClient(entry.client)
  264. delete(d.proxyClients, key)
  265. }
  266. }
  267. }
  268. func (d *coderOpenAIWSClientDialer) ensureProxyClientCapacityLocked() {
  269. if d == nil {
  270. return
  271. }
  272. maxEntries := openAIWSProxyClientCacheMaxEntries
  273. if maxEntries <= 0 {
  274. return
  275. }
  276. for len(d.proxyClients) > maxEntries {
  277. var oldestKey string
  278. var oldestLastUsed int64
  279. hasOldest := false
  280. for key, entry := range d.proxyClients {
  281. lastUsed := int64(0)
  282. if entry != nil {
  283. lastUsed = entry.lastUsedUnixNano
  284. }
  285. if !hasOldest || lastUsed < oldestLastUsed {
  286. hasOldest = true
  287. oldestKey = key
  288. oldestLastUsed = lastUsed
  289. }
  290. }
  291. if !hasOldest {
  292. return
  293. }
  294. if entry := d.proxyClients[oldestKey]; entry != nil {
  295. closeOpenAIWSProxyClient(entry.client)
  296. }
  297. delete(d.proxyClients, oldestKey)
  298. }
  299. }
  300. func closeOpenAIWSProxyClient(client *http.Client) {
  301. if client == nil || client.Transport == nil {
  302. return
  303. }
  304. if transport, ok := client.Transport.(*http.Transport); ok && transport != nil {
  305. transport.CloseIdleConnections()
  306. }
  307. }
  308. func (d *coderOpenAIWSClientDialer) SnapshotTransportMetrics() OpenAIWSTransportMetricsSnapshot {
  309. if d == nil {
  310. return OpenAIWSTransportMetricsSnapshot{}
  311. }
  312. hits := d.proxyHits.Load()
  313. misses := d.proxyMisses.Load()
  314. total := hits + misses
  315. reuseRatio := 0.0
  316. if total > 0 {
  317. reuseRatio = float64(hits) / float64(total)
  318. }
  319. return OpenAIWSTransportMetricsSnapshot{
  320. ProxyClientCacheHits: hits,
  321. ProxyClientCacheMisses: misses,
  322. TransportReuseRatio: reuseRatio,
  323. }
  324. }
  325. type coderOpenAIWSClientConn struct {
  326. conn *coderws.Conn
  327. }
  328. var _ openaiwsv2.FrameConn = (*coderOpenAIWSClientConn)(nil)
  329. func (c *coderOpenAIWSClientConn) WriteJSON(ctx context.Context, value any) error {
  330. if c == nil || c.conn == nil {
  331. return errOpenAIWSConnClosed
  332. }
  333. if ctx == nil {
  334. ctx = context.Background()
  335. }
  336. return wsjson.Write(ctx, c.conn, value)
  337. }
  338. func (c *coderOpenAIWSClientConn) ReadMessage(ctx context.Context) ([]byte, error) {
  339. if c == nil || c.conn == nil {
  340. return nil, errOpenAIWSConnClosed
  341. }
  342. if ctx == nil {
  343. ctx = context.Background()
  344. }
  345. msgType, payload, err := c.conn.Read(ctx)
  346. if err != nil {
  347. return nil, err
  348. }
  349. switch msgType {
  350. case coderws.MessageText, coderws.MessageBinary:
  351. return payload, nil
  352. default:
  353. return nil, errOpenAIWSConnClosed
  354. }
  355. }
  356. func (c *coderOpenAIWSClientConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  357. if c == nil || c.conn == nil {
  358. return coderws.MessageText, nil, errOpenAIWSConnClosed
  359. }
  360. if ctx == nil {
  361. ctx = context.Background()
  362. }
  363. msgType, payload, err := c.conn.Read(ctx)
  364. if err != nil {
  365. return coderws.MessageText, nil, err
  366. }
  367. return msgType, payload, nil
  368. }
  369. func (c *coderOpenAIWSClientConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
  370. if c == nil || c.conn == nil {
  371. return errOpenAIWSConnClosed
  372. }
  373. if ctx == nil {
  374. ctx = context.Background()
  375. }
  376. return c.conn.Write(ctx, msgType, payload)
  377. }
  378. func (c *coderOpenAIWSClientConn) Ping(ctx context.Context) error {
  379. if c == nil || c.conn == nil {
  380. return errOpenAIWSConnClosed
  381. }
  382. if ctx == nil {
  383. ctx = context.Background()
  384. }
  385. return c.conn.Ping(ctx)
  386. }
  387. // SupportsIdlePingWithoutReader reports the actual coder/websocket contract.
  388. // Conn.Ping waits for a pong, while control frames are only consumed by Read.
  389. // The pool deliberately has no reader on an idle connection, so using Ping as
  390. // a health probe would deterministically time out a healthy socket.
  391. func (*coderOpenAIWSClientConn) SupportsIdlePingWithoutReader() bool {
  392. return false
  393. }
  394. func (c *coderOpenAIWSClientConn) Close() error {
  395. if c == nil || c.conn == nil {
  396. return nil
  397. }
  398. // Close 为幂等,忽略重复关闭错误。
  399. _ = c.conn.Close(coderws.StatusNormalClosure, "")
  400. _ = c.conn.CloseNow()
  401. return nil
  402. }