vertex_service_account.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "encoding/hex"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "io"
  11. "log/slog"
  12. "net/http"
  13. "net/url"
  14. "regexp"
  15. "strings"
  16. "time"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
  18. "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
  19. "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
  20. "github.com/golang-jwt/jwt/v5"
  21. )
  22. const (
  23. vertexDefaultLocation = "us-central1"
  24. vertexDefaultTokenURL = "https://oauth2.googleapis.com/token"
  25. vertexCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
  26. vertexServiceAccountCacheSkew = 5 * time.Minute
  27. vertexLockWaitTime = 200 * time.Millisecond
  28. vertexAnthropicVersion = "vertex-2023-10-16"
  29. )
  30. var (
  31. vertexLocationPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
  32. vertexAnthropicDatedModelIDPattern = regexp.MustCompile(`^(.+)-([0-9]{8})$`)
  33. vertexAnthropicAlreadyDatedIDPattern = regexp.MustCompile(`^.+@[0-9]{8}$`)
  34. )
  35. type vertexServiceAccountKey struct {
  36. Type string `json:"type"`
  37. ProjectID string `json:"project_id"`
  38. PrivateKeyID string `json:"private_key_id"`
  39. PrivateKey string `json:"private_key"`
  40. ClientEmail string `json:"client_email"`
  41. TokenURI string `json:"token_uri"`
  42. }
  43. type vertexTokenResponse struct {
  44. AccessToken string `json:"access_token"`
  45. TokenType string `json:"token_type"`
  46. ExpiresIn int64 `json:"expires_in"`
  47. Error string `json:"error"`
  48. ErrorDesc string `json:"error_description"`
  49. }
  50. func (a *Account) IsVertexServiceAccount() bool {
  51. return a != nil && a.Type == AccountTypeServiceAccount
  52. }
  53. func (a *Account) VertexProjectID() string {
  54. if a == nil {
  55. return ""
  56. }
  57. if v := strings.TrimSpace(a.GetCredential("project_id")); v != "" {
  58. return v
  59. }
  60. key, err := parseVertexServiceAccountKey(a)
  61. if err == nil {
  62. return strings.TrimSpace(key.ProjectID)
  63. }
  64. return ""
  65. }
  66. func (a *Account) VertexLocation(model string) string {
  67. if a == nil {
  68. return vertexDefaultLocation
  69. }
  70. if model != "" && a.Credentials != nil {
  71. if raw, ok := a.Credentials["vertex_model_locations"].(map[string]any); ok {
  72. if loc, ok := raw[model].(string); ok && strings.TrimSpace(loc) != "" {
  73. return strings.TrimSpace(loc)
  74. }
  75. }
  76. }
  77. if v := strings.TrimSpace(a.GetCredential("location")); v != "" {
  78. return v
  79. }
  80. if v := strings.TrimSpace(a.GetCredential("vertex_location")); v != "" {
  81. return v
  82. }
  83. return vertexDefaultLocation
  84. }
  85. func parseVertexServiceAccountKey(account *Account) (*vertexServiceAccountKey, error) {
  86. if account == nil || account.Credentials == nil {
  87. return nil, errors.New("service account credentials not configured")
  88. }
  89. if raw := strings.TrimSpace(account.GetCredential("service_account_json")); raw != "" {
  90. return parseVertexServiceAccountJSON([]byte(raw))
  91. }
  92. if raw := strings.TrimSpace(account.GetCredential("service_account")); raw != "" {
  93. return parseVertexServiceAccountJSON([]byte(raw))
  94. }
  95. if nested, ok := account.Credentials["service_account_json"].(map[string]any); ok {
  96. b, _ := json.Marshal(nested)
  97. return parseVertexServiceAccountJSON(b)
  98. }
  99. if nested, ok := account.Credentials["service_account"].(map[string]any); ok {
  100. b, _ := json.Marshal(nested)
  101. return parseVertexServiceAccountJSON(b)
  102. }
  103. return nil, errors.New("service_account_json not found in credentials")
  104. }
  105. func parseVertexServiceAccountJSON(raw []byte) (*vertexServiceAccountKey, error) {
  106. var key vertexServiceAccountKey
  107. if err := json.Unmarshal(raw, &key); err != nil {
  108. return nil, fmt.Errorf("invalid service account json: %w", err)
  109. }
  110. if strings.TrimSpace(key.ClientEmail) == "" {
  111. return nil, errors.New("service account json missing client_email")
  112. }
  113. if strings.TrimSpace(key.PrivateKey) == "" {
  114. return nil, errors.New("service account json missing private_key")
  115. }
  116. if strings.TrimSpace(key.ProjectID) == "" {
  117. return nil, errors.New("service account json missing project_id")
  118. }
  119. // Always use the well-known Google token endpoint to prevent SSRF via crafted token_uri.
  120. key.TokenURI = vertexDefaultTokenURL
  121. return &key, nil
  122. }
  123. func vertexServiceAccountCacheKey(account *Account, key *vertexServiceAccountKey) string {
  124. fingerprint := ""
  125. if key != nil {
  126. sum := sha256.Sum256([]byte(key.ClientEmail + "\x00" + key.PrivateKeyID))
  127. fingerprint = hex.EncodeToString(sum[:8])
  128. }
  129. if fingerprint == "" && account != nil {
  130. fingerprint = fmt.Sprintf("account:%d", account.ID)
  131. }
  132. return "vertex:service_account:" + fingerprint
  133. }
  134. // getVertexServiceAccountAccessToken obtains an access token for a Vertex service account,
  135. // using the shared cache and distributed lock to avoid redundant exchanges.
  136. func getVertexServiceAccountAccessToken(ctx context.Context, cache GeminiTokenCache, account *Account) (string, error) {
  137. key, err := parseVertexServiceAccountKey(account)
  138. if err != nil {
  139. return "", err
  140. }
  141. cacheKey := vertexServiceAccountCacheKey(account, key)
  142. if cache != nil {
  143. if token, err := cache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
  144. return token, nil
  145. }
  146. }
  147. locked := false
  148. if cache != nil {
  149. var lockErr error
  150. locked, lockErr = cache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
  151. if lockErr == nil && locked {
  152. defer func() { _ = cache.ReleaseRefreshLock(ctx, cacheKey) }()
  153. } else if lockErr != nil {
  154. slog.Warn("vertex_service_account_token_lock_failed", "account_id", account.ID, "error", lockErr)
  155. } else {
  156. time.Sleep(vertexLockWaitTime)
  157. if token, err := cache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
  158. return token, nil
  159. }
  160. }
  161. }
  162. accessToken, ttl, err := exchangeVertexServiceAccountToken(ctx, key, vertexServiceAccountProxyURL(account))
  163. if err != nil {
  164. return "", err
  165. }
  166. if cache != nil {
  167. _ = cache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
  168. }
  169. return accessToken, nil
  170. }
  171. func vertexServiceAccountProxyURL(account *Account) string {
  172. if account == nil || account.ProxyID == nil || account.Proxy == nil {
  173. return ""
  174. }
  175. return account.Proxy.URL()
  176. }
  177. func newVertexServiceAccountHTTPClient(proxyURL string) (*http.Client, error) {
  178. proxyURL = strings.TrimSpace(proxyURL)
  179. if proxyURL == "" {
  180. return servertiming.InstrumentClient(&http.Client{Timeout: 15 * time.Second}), nil
  181. }
  182. _, parsedProxy, err := proxyurl.Parse(proxyURL)
  183. if err != nil {
  184. return nil, err
  185. }
  186. defaultTransport, ok := http.DefaultTransport.(*http.Transport)
  187. if !ok {
  188. return nil, fmt.Errorf("unexpected default transport type %T", http.DefaultTransport)
  189. }
  190. transport := defaultTransport.Clone()
  191. transport.Proxy = nil
  192. if err := proxyutil.ConfigureTransportProxy(transport, parsedProxy); err != nil {
  193. return nil, err
  194. }
  195. return servertiming.InstrumentClient(&http.Client{Timeout: 15 * time.Second, Transport: transport}), nil
  196. }
  197. func exchangeVertexServiceAccountToken(ctx context.Context, key *vertexServiceAccountKey, proxyURL string) (string, time.Duration, error) {
  198. now := time.Now()
  199. claims := jwt.MapClaims{
  200. "iss": key.ClientEmail,
  201. "scope": vertexCloudPlatformScope,
  202. "aud": key.TokenURI,
  203. "iat": now.Unix(),
  204. "exp": now.Add(time.Hour).Unix(),
  205. }
  206. token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
  207. if strings.TrimSpace(key.PrivateKeyID) != "" {
  208. token.Header["kid"] = key.PrivateKeyID
  209. }
  210. privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(key.PrivateKey))
  211. if err != nil {
  212. return "", 0, fmt.Errorf("parse service account private key: %w", err)
  213. }
  214. assertion, err := token.SignedString(privateKey)
  215. if err != nil {
  216. return "", 0, fmt.Errorf("sign service account assertion: %w", err)
  217. }
  218. values := url.Values{}
  219. values.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
  220. values.Set("assertion", assertion)
  221. req, err := http.NewRequestWithContext(ctx, http.MethodPost, key.TokenURI, strings.NewReader(values.Encode()))
  222. if err != nil {
  223. return "", 0, err
  224. }
  225. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  226. client, err := newVertexServiceAccountHTTPClient(proxyURL)
  227. if err != nil {
  228. return "", 0, fmt.Errorf("configure service account token proxy: %w", err)
  229. }
  230. resp, err := client.Do(req)
  231. if err != nil {
  232. return "", 0, fmt.Errorf("service account token request failed: %w", err)
  233. }
  234. defer func() { _ = resp.Body.Close() }()
  235. body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
  236. var parsed vertexTokenResponse
  237. _ = json.Unmarshal(body, &parsed)
  238. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  239. msg := strings.TrimSpace(parsed.ErrorDesc)
  240. if msg == "" {
  241. msg = strings.TrimSpace(parsed.Error)
  242. }
  243. if msg == "" {
  244. msg = string(bytes.TrimSpace(body))
  245. }
  246. return "", 0, fmt.Errorf("service account token request returned %d: %s", resp.StatusCode, msg)
  247. }
  248. if strings.TrimSpace(parsed.AccessToken) == "" {
  249. return "", 0, errors.New("service account token response missing access_token")
  250. }
  251. ttl := time.Duration(parsed.ExpiresIn) * time.Second
  252. if ttl <= 0 {
  253. ttl = time.Hour
  254. }
  255. if ttl > vertexServiceAccountCacheSkew {
  256. ttl -= vertexServiceAccountCacheSkew
  257. }
  258. return parsed.AccessToken, ttl, nil
  259. }
  260. func buildVertexGeminiURL(projectID, location, model, action string, stream bool) (string, error) {
  261. projectID = strings.TrimSpace(projectID)
  262. location = strings.TrimSpace(location)
  263. model = strings.TrimSpace(model)
  264. action = strings.TrimSpace(action)
  265. if projectID == "" {
  266. return "", errors.New("vertex project_id is required")
  267. }
  268. if location == "" {
  269. location = vertexDefaultLocation
  270. }
  271. if !vertexLocationPattern.MatchString(location) {
  272. return "", fmt.Errorf("invalid vertex location: %s", location)
  273. }
  274. if model == "" {
  275. return "", errors.New("vertex model is required")
  276. }
  277. switch action {
  278. case "generateContent", "streamGenerateContent", "countTokens":
  279. default:
  280. return "", fmt.Errorf("unsupported vertex gemini action: %s", action)
  281. }
  282. host := fmt.Sprintf("%s-aiplatform.googleapis.com", location)
  283. if location == "global" {
  284. host = "aiplatform.googleapis.com"
  285. }
  286. u := fmt.Sprintf(
  287. "https://%s/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
  288. host,
  289. url.PathEscape(projectID),
  290. url.PathEscape(location),
  291. url.PathEscape(model),
  292. action,
  293. )
  294. if stream {
  295. u += "?alt=sse"
  296. }
  297. return u, nil
  298. }
  299. func buildVertexAnthropicURL(projectID, location, model string, stream bool) (string, error) {
  300. projectID = strings.TrimSpace(projectID)
  301. location = strings.TrimSpace(location)
  302. model = strings.TrimSpace(model)
  303. if projectID == "" {
  304. return "", errors.New("vertex project_id is required")
  305. }
  306. if location == "" {
  307. location = vertexDefaultLocation
  308. }
  309. if !vertexLocationPattern.MatchString(location) {
  310. return "", fmt.Errorf("invalid vertex location: %s", location)
  311. }
  312. if model == "" {
  313. return "", errors.New("vertex model is required")
  314. }
  315. action := "rawPredict"
  316. if stream {
  317. action = "streamRawPredict"
  318. }
  319. host := fmt.Sprintf("%s-aiplatform.googleapis.com", location)
  320. if location == "global" {
  321. host = "aiplatform.googleapis.com"
  322. }
  323. escapedModel := strings.ReplaceAll(url.PathEscape(model), "%40", "@")
  324. return fmt.Sprintf(
  325. "https://%s/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
  326. host,
  327. url.PathEscape(projectID),
  328. url.PathEscape(location),
  329. escapedModel,
  330. action,
  331. ), nil
  332. }
  333. func normalizeVertexAnthropicModelID(model string) string {
  334. model = strings.TrimSpace(model)
  335. if model == "" || vertexAnthropicAlreadyDatedIDPattern.MatchString(model) {
  336. return model
  337. }
  338. if m := vertexAnthropicDatedModelIDPattern.FindStringSubmatch(model); len(m) == 3 {
  339. return m[1] + "@" + m[2]
  340. }
  341. return model
  342. }
  343. func buildVertexAnthropicRequestBody(body []byte) ([]byte, error) {
  344. var payload map[string]any
  345. if err := json.Unmarshal(body, &payload); err != nil {
  346. return nil, fmt.Errorf("parse anthropic vertex request body: %w", err)
  347. }
  348. delete(payload, "model")
  349. payload["anthropic_version"] = vertexAnthropicVersion
  350. return json.Marshal(payload)
  351. }