| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- package service
- import (
- "context"
- "crypto/sha256"
- "crypto/x509"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "sync"
- "sync/atomic"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
- openaiwsv2 "github.com/Wei-Shaw/sub2api/internal/service/openai_ws_v2"
- coderws "github.com/coder/websocket"
- "github.com/coder/websocket/wsjson"
- )
- const openAIWSMessageReadLimitBytes int64 = 16 * 1024 * 1024
- const (
- openAIWSProxyTransportMaxIdleConns = 128
- openAIWSProxyTransportMaxIdleConnsPerHost = 64
- openAIWSProxyTransportIdleConnTimeout = 90 * time.Second
- openAIWSProxyClientCacheMaxEntries = 256
- openAIWSProxyClientCacheIdleTTL = 15 * time.Minute
- )
- type OpenAIWSTransportMetricsSnapshot struct {
- ProxyClientCacheHits int64 `json:"proxy_client_cache_hits"`
- ProxyClientCacheMisses int64 `json:"proxy_client_cache_misses"`
- TransportReuseRatio float64 `json:"transport_reuse_ratio"`
- }
- // openAIWSClientConn 抽象 WS 客户端连接,便于替换底层实现。
- type openAIWSClientConn interface {
- WriteJSON(ctx context.Context, value any) error
- ReadMessage(ctx context.Context) ([]byte, error)
- Ping(ctx context.Context) error
- Close() error
- }
- // openAIWSIdlePingCapable is intentionally separate from openAIWSClientConn.
- // A pool probe happens while no goroutine is reading an idle connection, which
- // is not safe for every WebSocket implementation.
- type openAIWSIdlePingCapable interface {
- SupportsIdlePingWithoutReader() bool
- }
- // openAIWSClientDialer 抽象 WS 建连器。
- type openAIWSClientDialer interface {
- Dial(ctx context.Context, wsURL string, headers http.Header, proxyURL string) (openAIWSClientConn, int, http.Header, error)
- }
- type openAIWSTransportMetricsDialer interface {
- SnapshotTransportMetrics() OpenAIWSTransportMetricsSnapshot
- }
- func newDefaultOpenAIWSClientDialer() openAIWSClientDialer {
- return &coderOpenAIWSClientDialer{
- proxyClients: make(map[string]*openAIWSProxyClientEntry),
- }
- }
- type coderOpenAIWSClientDialer struct {
- proxyMu sync.Mutex
- proxyClients map[string]*openAIWSProxyClientEntry
- proxyHits atomic.Int64
- proxyMisses atomic.Int64
- // System roots are used in production. Local handshake tests inject their CA.
- tlsRootCAs *x509.CertPool
- tlsProxyRootCAs *x509.CertPool
- }
- type openAIWSTLSContextKey struct{}
- type openAIWSTLSConfig struct {
- accountID int64
- profile *tlsfingerprint.Profile
- }
- func withOpenAIWSTLSProfile(ctx context.Context, accountID int64, profile *tlsfingerprint.Profile) context.Context {
- if ctx == nil {
- ctx = context.Background()
- }
- if profile == nil {
- return ctx
- }
- return context.WithValue(ctx, openAIWSTLSContextKey{}, openAIWSTLSConfig{accountID: accountID, profile: profile.Clone()})
- }
- // Include the full target and proxy in the digest without keeping credentials in
- // cache keys or logs. Profile contents, rather than its display name, define TLS.
- func openAIWSTransportKey(accountID int64, target, proxy string, profile *tlsfingerprint.Profile) string {
- if profile == nil {
- return ""
- }
- value := fmt.Sprintf("%d\x00%s\x00%s\x00%s", accountID, strings.TrimSpace(target), strings.TrimSpace(proxy), profile.CacheKey())
- return fmt.Sprintf("mode1:%x", sha256.Sum256([]byte(value)))
- }
- // openAIWSHandshakeError keeps a bounded, non-logged HTTP error body so the
- // Agent Identity recovery path can distinguish an invalid task from other
- // 401 handshake failures.
- type openAIWSHandshakeError struct {
- Body []byte
- Err error
- }
- func (e *openAIWSHandshakeError) Error() string {
- if e == nil || e.Err == nil {
- return "openai ws handshake failed"
- }
- return e.Err.Error()
- }
- func (e *openAIWSHandshakeError) Unwrap() error {
- if e == nil {
- return nil
- }
- return e.Err
- }
- type openAIWSProxyClientEntry struct {
- client *http.Client
- lastUsedUnixNano int64
- }
- func (d *coderOpenAIWSClientDialer) Dial(
- ctx context.Context,
- wsURL string,
- headers http.Header,
- proxyURL string,
- ) (openAIWSClientConn, int, http.Header, error) {
- targetURL := strings.TrimSpace(wsURL)
- if targetURL == "" {
- return nil, 0, nil, errors.New("ws url is empty")
- }
- opts := &coderws.DialOptions{
- HTTPHeader: cloneHeader(headers),
- CompressionMode: coderws.CompressionContextTakeover,
- }
- tlsConfig, protected := ctx.Value(openAIWSTLSContextKey{}).(openAIWSTLSConfig)
- if protected {
- parsedTarget, err := url.Parse(targetURL)
- if err != nil || parsedTarget.Scheme != "wss" || parsedTarget.Host == "" {
- return nil, 0, nil, errors.New("mode1 websocket requires a valid wss target")
- }
- client, err := d.fingerprintHTTPClient(tlsConfig, targetURL, proxyURL)
- if err != nil {
- return nil, 0, nil, err
- }
- opts.HTTPClient = client
- } else if proxy := strings.TrimSpace(proxyURL); proxy != "" {
- proxyClient, err := d.proxyHTTPClient(proxy)
- if err != nil {
- return nil, 0, nil, err
- }
- opts.HTTPClient = proxyClient
- }
- conn, resp, err := coderws.Dial(ctx, targetURL, opts)
- if err != nil {
- status := 0
- respHeaders := http.Header(nil)
- if resp != nil {
- status = resp.StatusCode
- respHeaders = cloneHeader(resp.Header)
- }
- var body []byte
- if resp != nil && resp.Body != nil {
- body, _ = io.ReadAll(io.LimitReader(resp.Body, 8<<10))
- _ = resp.Body.Close()
- }
- return nil, status, respHeaders, &openAIWSHandshakeError{Body: body, Err: err}
- }
- // coder/websocket 默认单消息读取上限为 32KB,Codex WS 事件(如 rate_limits/大 delta)
- // 可能超过该阈值,需显式提高上限,避免本地 read_fail(message too big)。
- conn.SetReadLimit(openAIWSMessageReadLimitBytes)
- respHeaders := http.Header(nil)
- if resp != nil {
- respHeaders = cloneHeader(resp.Header)
- }
- return &coderOpenAIWSClientConn{conn: conn}, 0, respHeaders, nil
- }
- func (d *coderOpenAIWSClientDialer) fingerprintHTTPClient(cfg openAIWSTLSConfig, target, proxy string) (*http.Client, error) {
- if d == nil || cfg.accountID <= 0 || cfg.profile == nil {
- return nil, errors.New("invalid mode1 websocket TLS configuration")
- }
- var proxyURL *url.URL
- if strings.TrimSpace(proxy) != "" {
- var err error
- proxyURL, err = url.Parse(strings.TrimSpace(proxy))
- if err != nil {
- return nil, errors.New("invalid mode1 websocket proxy URL")
- }
- }
- key := openAIWSTransportKey(cfg.accountID, target, proxy, cfg.profile)
- now := time.Now().UnixNano()
- d.proxyMu.Lock()
- defer d.proxyMu.Unlock()
- if entry := d.proxyClients[key]; entry != nil && entry.client != nil {
- entry.lastUsedUnixNano = now
- d.proxyHits.Add(1)
- return entry.client, nil
- }
- d.cleanupProxyClientsLocked(now)
- transport, err := tlsfingerprint.NewHTTPTransport(cfg.profile, proxyURL, tlsfingerprint.TransportOptions{
- RootCAs: d.tlsRootCAs, ProxyRootCAs: d.tlsProxyRootCAs, HandshakeTimeout: 10 * time.Second,
- })
- if err != nil {
- return nil, fmt.Errorf("mode1 websocket TLS transport: %w", err)
- }
- transport.MaxIdleConns = openAIWSProxyTransportMaxIdleConns
- transport.MaxIdleConnsPerHost = openAIWSProxyTransportMaxIdleConnsPerHost
- transport.IdleConnTimeout = openAIWSProxyTransportIdleConnTimeout
- client := &http.Client{Transport: transport}
- if d.proxyClients == nil {
- d.proxyClients = make(map[string]*openAIWSProxyClientEntry)
- }
- d.proxyClients[key] = &openAIWSProxyClientEntry{client: client, lastUsedUnixNano: now}
- d.ensureProxyClientCapacityLocked()
- d.proxyMisses.Add(1)
- return client, nil
- }
- func (d *coderOpenAIWSClientDialer) proxyHTTPClient(proxy string) (*http.Client, error) {
- if d == nil {
- return nil, errors.New("openai ws dialer is nil")
- }
- normalizedProxy := strings.TrimSpace(proxy)
- if normalizedProxy == "" {
- return nil, errors.New("proxy url is empty")
- }
- parsedProxyURL, err := url.Parse(normalizedProxy)
- if err != nil {
- return nil, fmt.Errorf("invalid proxy url: %w", err)
- }
- now := time.Now().UnixNano()
- d.proxyMu.Lock()
- defer d.proxyMu.Unlock()
- if entry, ok := d.proxyClients[normalizedProxy]; ok && entry != nil && entry.client != nil {
- entry.lastUsedUnixNano = now
- d.proxyHits.Add(1)
- return entry.client, nil
- }
- d.cleanupProxyClientsLocked(now)
- transport := &http.Transport{
- Proxy: http.ProxyURL(parsedProxyURL),
- MaxIdleConns: openAIWSProxyTransportMaxIdleConns,
- MaxIdleConnsPerHost: openAIWSProxyTransportMaxIdleConnsPerHost,
- IdleConnTimeout: openAIWSProxyTransportIdleConnTimeout,
- TLSHandshakeTimeout: 10 * time.Second,
- ForceAttemptHTTP2: true,
- }
- client := &http.Client{Transport: transport}
- d.proxyClients[normalizedProxy] = &openAIWSProxyClientEntry{
- client: client,
- lastUsedUnixNano: now,
- }
- d.ensureProxyClientCapacityLocked()
- d.proxyMisses.Add(1)
- return client, nil
- }
- func (d *coderOpenAIWSClientDialer) cleanupProxyClientsLocked(nowUnixNano int64) {
- if d == nil || len(d.proxyClients) == 0 {
- return
- }
- idleTTL := openAIWSProxyClientCacheIdleTTL
- if idleTTL <= 0 {
- return
- }
- now := time.Unix(0, nowUnixNano)
- for key, entry := range d.proxyClients {
- if entry == nil || entry.client == nil {
- delete(d.proxyClients, key)
- continue
- }
- lastUsed := time.Unix(0, entry.lastUsedUnixNano)
- if now.Sub(lastUsed) > idleTTL {
- closeOpenAIWSProxyClient(entry.client)
- delete(d.proxyClients, key)
- }
- }
- }
- func (d *coderOpenAIWSClientDialer) ensureProxyClientCapacityLocked() {
- if d == nil {
- return
- }
- maxEntries := openAIWSProxyClientCacheMaxEntries
- if maxEntries <= 0 {
- return
- }
- for len(d.proxyClients) > maxEntries {
- var oldestKey string
- var oldestLastUsed int64
- hasOldest := false
- for key, entry := range d.proxyClients {
- lastUsed := int64(0)
- if entry != nil {
- lastUsed = entry.lastUsedUnixNano
- }
- if !hasOldest || lastUsed < oldestLastUsed {
- hasOldest = true
- oldestKey = key
- oldestLastUsed = lastUsed
- }
- }
- if !hasOldest {
- return
- }
- if entry := d.proxyClients[oldestKey]; entry != nil {
- closeOpenAIWSProxyClient(entry.client)
- }
- delete(d.proxyClients, oldestKey)
- }
- }
- func closeOpenAIWSProxyClient(client *http.Client) {
- if client == nil || client.Transport == nil {
- return
- }
- if transport, ok := client.Transport.(*http.Transport); ok && transport != nil {
- transport.CloseIdleConnections()
- }
- }
- func (d *coderOpenAIWSClientDialer) SnapshotTransportMetrics() OpenAIWSTransportMetricsSnapshot {
- if d == nil {
- return OpenAIWSTransportMetricsSnapshot{}
- }
- hits := d.proxyHits.Load()
- misses := d.proxyMisses.Load()
- total := hits + misses
- reuseRatio := 0.0
- if total > 0 {
- reuseRatio = float64(hits) / float64(total)
- }
- return OpenAIWSTransportMetricsSnapshot{
- ProxyClientCacheHits: hits,
- ProxyClientCacheMisses: misses,
- TransportReuseRatio: reuseRatio,
- }
- }
- type coderOpenAIWSClientConn struct {
- conn *coderws.Conn
- }
- var _ openaiwsv2.FrameConn = (*coderOpenAIWSClientConn)(nil)
- func (c *coderOpenAIWSClientConn) WriteJSON(ctx context.Context, value any) error {
- if c == nil || c.conn == nil {
- return errOpenAIWSConnClosed
- }
- if ctx == nil {
- ctx = context.Background()
- }
- return wsjson.Write(ctx, c.conn, value)
- }
- func (c *coderOpenAIWSClientConn) ReadMessage(ctx context.Context) ([]byte, error) {
- if c == nil || c.conn == nil {
- return nil, errOpenAIWSConnClosed
- }
- if ctx == nil {
- ctx = context.Background()
- }
- msgType, payload, err := c.conn.Read(ctx)
- if err != nil {
- return nil, err
- }
- switch msgType {
- case coderws.MessageText, coderws.MessageBinary:
- return payload, nil
- default:
- return nil, errOpenAIWSConnClosed
- }
- }
- func (c *coderOpenAIWSClientConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
- if c == nil || c.conn == nil {
- return coderws.MessageText, nil, errOpenAIWSConnClosed
- }
- if ctx == nil {
- ctx = context.Background()
- }
- msgType, payload, err := c.conn.Read(ctx)
- if err != nil {
- return coderws.MessageText, nil, err
- }
- return msgType, payload, nil
- }
- func (c *coderOpenAIWSClientConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
- if c == nil || c.conn == nil {
- return errOpenAIWSConnClosed
- }
- if ctx == nil {
- ctx = context.Background()
- }
- return c.conn.Write(ctx, msgType, payload)
- }
- func (c *coderOpenAIWSClientConn) Ping(ctx context.Context) error {
- if c == nil || c.conn == nil {
- return errOpenAIWSConnClosed
- }
- if ctx == nil {
- ctx = context.Background()
- }
- return c.conn.Ping(ctx)
- }
- // SupportsIdlePingWithoutReader reports the actual coder/websocket contract.
- // Conn.Ping waits for a pong, while control frames are only consumed by Read.
- // The pool deliberately has no reader on an idle connection, so using Ping as
- // a health probe would deterministically time out a healthy socket.
- func (*coderOpenAIWSClientConn) SupportsIdlePingWithoutReader() bool {
- return false
- }
- func (c *coderOpenAIWSClientConn) Close() error {
- if c == nil || c.conn == nil {
- return nil
- }
- // Close 为幂等,忽略重复关闭错误。
- _ = c.conn.Close(coderws.StatusNormalClosure, "")
- _ = c.conn.CloseNow()
- return nil
- }
|