batch_image_provider_vertex.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/base64"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "net/http"
  11. "net/url"
  12. "sort"
  13. "strings"
  14. "time"
  15. "github.com/Wei-Shaw/sub2api/internal/config"
  16. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  17. )
  18. const (
  19. defaultVertexBatchRequeueAfter = 30 * time.Second
  20. defaultVertexBatchLocation = "global"
  21. defaultVertexManagedGCSPrefix = "batch-image/{env}/{batch_id}"
  22. )
  23. type VertexBatchImageProviderOptions struct {
  24. Enabled bool
  25. ProjectID string
  26. Location string
  27. ManagedGCSBucket string
  28. ManagedGCSPrefix string
  29. Environment string
  30. InputRetentionHours int
  31. OutputRetentionHours int
  32. BatchPredictionBaseURL string
  33. GCSBaseURL string
  34. }
  35. func NewVertexBatchImageProviderOptionsFromConfig(cfg *config.Config) VertexBatchImageProviderOptions {
  36. if cfg == nil {
  37. return VertexBatchImageProviderOptions{}
  38. }
  39. return VertexBatchImageProviderOptions{
  40. Enabled: cfg.BatchImage.VertexEnabled,
  41. ProjectID: cfg.BatchImage.VertexProjectID,
  42. Location: cfg.BatchImage.VertexLocation,
  43. ManagedGCSBucket: cfg.BatchImage.VertexManagedGCSBucket,
  44. ManagedGCSPrefix: cfg.BatchImage.VertexManagedGCSPrefix,
  45. Environment: cfg.Log.Environment,
  46. InputRetentionHours: cfg.BatchImage.VertexInputRetentionHours,
  47. OutputRetentionHours: cfg.BatchImage.VertexOutputRetentionHours,
  48. BatchPredictionBaseURL: cfg.BatchImage.VertexBatchPredictionBaseURL,
  49. GCSBaseURL: cfg.BatchImage.VertexGCSBaseURL,
  50. }
  51. }
  52. type VertexBatchClient interface {
  53. CreateBatchPredictionJob(ctx context.Context, accessToken string, req VertexCreateBatchPredictionJobRequest) (*VertexBatchPredictionJob, error)
  54. GetBatchPredictionJob(ctx context.Context, accessToken string, name string) (*VertexBatchPredictionJob, error)
  55. CancelBatchPredictionJob(ctx context.Context, accessToken string, name string) error
  56. }
  57. type VertexBatchObjectStore interface {
  58. UploadJSONL(ctx context.Context, accessToken string, uri string, r io.Reader) error
  59. ListJSONLObjects(ctx context.Context, accessToken string, prefixURI string) ([]string, error)
  60. OpenObject(ctx context.Context, accessToken string, uri string) (io.ReadCloser, string, error)
  61. DeleteObject(ctx context.Context, accessToken string, uri string) error
  62. DeletePrefix(ctx context.Context, accessToken string, prefixURI string) error
  63. }
  64. type VertexCreateBatchPredictionJobRequest struct {
  65. ProjectID string `json:"-"`
  66. Location string `json:"-"`
  67. DisplayName string `json:"displayName"`
  68. Model string `json:"model"`
  69. InputConfig VertexBatchInputConfig `json:"inputConfig"`
  70. OutputConfig VertexBatchOutputConfig `json:"outputConfig"`
  71. InstanceConfig *VertexBatchInstanceConfig `json:"instanceConfig,omitempty"`
  72. }
  73. type VertexBatchInputConfig struct {
  74. InstancesFormat string `json:"instancesFormat"`
  75. GCSSource VertexBatchGCSSource `json:"gcsSource"`
  76. }
  77. type VertexBatchGCSSource struct {
  78. URIs []string `json:"uris"`
  79. }
  80. type VertexBatchOutputConfig struct {
  81. PredictionsFormat string `json:"predictionsFormat"`
  82. GCSDestination VertexBatchGCSDestination `json:"gcsDestination"`
  83. }
  84. type VertexBatchGCSDestination struct {
  85. OutputURIPrefix string `json:"outputUriPrefix"`
  86. }
  87. type VertexBatchInstanceConfig struct {
  88. KeyField string `json:"keyField"`
  89. }
  90. type VertexBatchPredictionJob struct {
  91. Name string `json:"name"`
  92. DisplayName string `json:"displayName"`
  93. State string `json:"state"`
  94. OutputConfig VertexBatchOutputConfig `json:"outputConfig"`
  95. Error *VertexBatchJobError `json:"error"`
  96. }
  97. type VertexBatchJobError struct {
  98. Code any `json:"code"`
  99. Message string `json:"message"`
  100. Status string `json:"status"`
  101. }
  102. type VertexBatchImageProvider struct {
  103. opts VertexBatchImageProviderOptions
  104. client VertexBatchClient
  105. objectStore VertexBatchObjectStore
  106. tokenCache GeminiTokenCache
  107. }
  108. func NewVertexBatchImageProvider(opts VertexBatchImageProviderOptions, client VertexBatchClient, objectStore VertexBatchObjectStore, tokenCache GeminiTokenCache) *VertexBatchImageProvider {
  109. opts = normalizeVertexBatchImageProviderOptions(opts)
  110. if client == nil {
  111. client = NewVertexBatchHTTPClient(opts.BatchPredictionBaseURL, nil)
  112. }
  113. if objectStore == nil {
  114. objectStore = NewVertexGCSObjectStore(opts.GCSBaseURL, nil)
  115. }
  116. return &VertexBatchImageProvider{
  117. opts: opts,
  118. client: client,
  119. objectStore: objectStore,
  120. tokenCache: tokenCache,
  121. }
  122. }
  123. func NewVertexBatchImageProviderFromConfig(cfg *config.Config, client VertexBatchClient, objectStore VertexBatchObjectStore, tokenCache GeminiTokenCache) *VertexBatchImageProvider {
  124. return NewVertexBatchImageProvider(NewVertexBatchImageProviderOptionsFromConfig(cfg), client, objectStore, tokenCache)
  125. }
  126. func normalizeVertexBatchImageProviderOptions(opts VertexBatchImageProviderOptions) VertexBatchImageProviderOptions {
  127. opts.ProjectID = strings.TrimSpace(opts.ProjectID)
  128. opts.Location = strings.TrimSpace(opts.Location)
  129. if opts.Location == "" {
  130. opts.Location = defaultVertexBatchLocation
  131. }
  132. opts.ManagedGCSBucket = strings.Trim(strings.TrimSpace(opts.ManagedGCSBucket), "/")
  133. opts.ManagedGCSPrefix = strings.Trim(strings.TrimSpace(opts.ManagedGCSPrefix), "/")
  134. if opts.ManagedGCSPrefix == "" {
  135. opts.ManagedGCSPrefix = defaultVertexManagedGCSPrefix
  136. }
  137. opts.Environment = strings.TrimSpace(opts.Environment)
  138. if opts.Environment == "" {
  139. opts.Environment = "default"
  140. }
  141. opts.BatchPredictionBaseURL = strings.TrimRight(strings.TrimSpace(opts.BatchPredictionBaseURL), "/")
  142. opts.GCSBaseURL = strings.TrimRight(strings.TrimSpace(opts.GCSBaseURL), "/")
  143. return opts
  144. }
  145. func (p *VertexBatchImageProvider) Name() string {
  146. return BatchImageProviderVertex
  147. }
  148. func (p *VertexBatchImageProvider) SupportsAccount(account *Account) bool {
  149. if account == nil || account.Platform != PlatformGemini || account.Type != AccountTypeServiceAccount {
  150. return false
  151. }
  152. _, err := parseVertexServiceAccountKey(account)
  153. return err == nil
  154. }
  155. func (p *VertexBatchImageProvider) Submit(ctx context.Context, job *BatchImageJob, account *Account, input BatchImageInput) (*BatchProviderJob, error) {
  156. if err := p.validateAccount(account); err != nil {
  157. return nil, err
  158. }
  159. if strings.TrimSpace(p.opts.ManagedGCSBucket) == "" {
  160. return nil, vertexProviderError("VERTEX_MANAGED_GCS_BUCKET_MISSING", "Vertex managed GCS bucket is not configured", nil)
  161. }
  162. if input.BatchID == "" && job != nil {
  163. input.BatchID = job.BatchID
  164. }
  165. if input.Model == "" && job != nil {
  166. input.Model = job.Model
  167. }
  168. jsonl, err := BuildVertexBatchJSONL(input)
  169. if err != nil {
  170. return nil, err
  171. }
  172. refs, err := p.managedRefs(input.BatchID)
  173. if err != nil {
  174. return nil, err
  175. }
  176. accessToken, err := p.accessToken(ctx, account)
  177. if err != nil {
  178. return nil, mapVertexClientError(err)
  179. }
  180. if err := p.objectStore.UploadJSONL(ctx, accessToken, refs.InputURI, bytes.NewReader(jsonl)); err != nil {
  181. return nil, vertexProviderError("VERTEX_GCS_UPLOAD_FAILED", "Vertex managed GCS upload failed", nil)
  182. }
  183. projectID := strings.TrimSpace(p.opts.ProjectID)
  184. if projectID == "" {
  185. projectID = account.VertexProjectID()
  186. }
  187. if projectID == "" {
  188. return nil, vertexProviderError("VERTEX_PROJECT_ID_MISSING", "Vertex project id is not configured", nil)
  189. }
  190. location := strings.TrimSpace(p.opts.Location)
  191. if location == "" {
  192. location = account.VertexLocation(input.Model)
  193. }
  194. req := VertexCreateBatchPredictionJobRequest{
  195. ProjectID: projectID,
  196. Location: location,
  197. DisplayName: vertexBatchDisplayName(input),
  198. Model: NormalizeVertexBatchModelPath(input.Model),
  199. InputConfig: VertexBatchInputConfig{InstancesFormat: "jsonl", GCSSource: VertexBatchGCSSource{URIs: []string{refs.InputURI}}},
  200. OutputConfig: VertexBatchOutputConfig{PredictionsFormat: "jsonl", GCSDestination: VertexBatchGCSDestination{OutputURIPrefix: refs.OutputPrefixURI}},
  201. InstanceConfig: &VertexBatchInstanceConfig{KeyField: "key"},
  202. }
  203. created, err := p.client.CreateBatchPredictionJob(ctx, accessToken, req)
  204. if err != nil {
  205. return nil, mapVertexClientError(err)
  206. }
  207. if created == nil || strings.TrimSpace(created.Name) == "" {
  208. return nil, vertexProviderError("VERTEX_INVALID_RESPONSE", "Vertex batch response is missing job name", nil)
  209. }
  210. return &BatchProviderJob{
  211. ProviderJobName: created.Name,
  212. ProviderInputRef: refs.InputURI,
  213. ProviderOutputRef: refs.OutputPrefixURI,
  214. RawState: created.State,
  215. }, nil
  216. }
  217. func (p *VertexBatchImageProvider) Get(ctx context.Context, job *BatchImageJob, account *Account) (*BatchProviderStatus, error) {
  218. if err := p.validateAccount(account); err != nil {
  219. return nil, err
  220. }
  221. jobName := batchImageProviderJobName(job)
  222. if jobName == "" {
  223. return nil, ErrBatchImageProviderMissingJobName
  224. }
  225. accessToken, err := p.accessToken(ctx, account)
  226. if err != nil {
  227. return nil, mapVertexClientError(err)
  228. }
  229. vertexJob, err := p.client.GetBatchPredictionJob(ctx, accessToken, jobName)
  230. if err != nil {
  231. return nil, mapVertexClientError(err)
  232. }
  233. if vertexJob == nil {
  234. return nil, vertexProviderError("VERTEX_INVALID_RESPONSE", "Vertex batch response is empty", nil)
  235. }
  236. status := mapVertexBatchState(vertexJob)
  237. outputRef := strings.TrimSpace(vertexJob.OutputConfig.GCSDestination.OutputURIPrefix)
  238. if outputRef == "" {
  239. outputRef = batchImageProviderOutputRef(job)
  240. }
  241. if outputRef == "" && job != nil && job.GCSOutputURI != nil {
  242. outputRef = strings.TrimSpace(*job.GCSOutputURI)
  243. }
  244. status.ProviderOutputRef = outputRef
  245. return status, nil
  246. }
  247. func (p *VertexBatchImageProvider) Cancel(ctx context.Context, job *BatchImageJob, account *Account) error {
  248. if err := p.validateAccount(account); err != nil {
  249. return err
  250. }
  251. jobName := batchImageProviderJobName(job)
  252. if jobName == "" {
  253. return ErrBatchImageProviderMissingJobName
  254. }
  255. accessToken, err := p.accessToken(ctx, account)
  256. if err != nil {
  257. return mapVertexClientError(err)
  258. }
  259. return mapVertexClientError(p.client.CancelBatchPredictionJob(ctx, accessToken, jobName))
  260. }
  261. func (p *VertexBatchImageProvider) OpenResult(ctx context.Context, job *BatchImageJob, account *Account) (io.ReadCloser, string, error) {
  262. if err := p.validateAccount(account); err != nil {
  263. return nil, "", err
  264. }
  265. outputRef := batchImageProviderOutputRef(job)
  266. if outputRef == "" && job != nil && job.GCSOutputURI != nil {
  267. outputRef = strings.TrimSpace(*job.GCSOutputURI)
  268. }
  269. if outputRef == "" {
  270. return nil, "", ErrBatchImageProviderMissingResultRef
  271. }
  272. accessToken, err := p.accessToken(ctx, account)
  273. if err != nil {
  274. return nil, "", mapVertexClientError(err)
  275. }
  276. objects, err := p.objectStore.ListJSONLObjects(ctx, accessToken, outputRef)
  277. if err != nil {
  278. return nil, "", vertexProviderError("VERTEX_GCS_LIST_FAILED", "Vertex managed GCS list failed", nil)
  279. }
  280. sort.Strings(objects)
  281. if len(objects) == 0 {
  282. return nil, "", vertexProviderError("VERTEX_RESULT_OBJECTS_MISSING", "Vertex result objects are missing", nil)
  283. }
  284. return &vertexCombinedJSONLReadCloser{
  285. ctx: ctx,
  286. accessToken: accessToken,
  287. objects: objects,
  288. store: p.objectStore,
  289. }, "application/jsonl", nil
  290. }
  291. func (p *VertexBatchImageProvider) Cleanup(ctx context.Context, job *BatchImageJob, account *Account, target CleanupTarget) error {
  292. if err := p.validateAccount(account); err != nil {
  293. return err
  294. }
  295. accessToken, err := p.accessToken(ctx, account)
  296. if err != nil {
  297. return mapVertexClientError(err)
  298. }
  299. inputRef := batchImageProviderInputRef(job)
  300. outputRef := batchImageProviderOutputRef(job)
  301. if job != nil {
  302. if inputRef == "" && job.GCSInputURI != nil {
  303. inputRef = strings.TrimSpace(*job.GCSInputURI)
  304. }
  305. if outputRef == "" && job.GCSOutputURI != nil {
  306. outputRef = strings.TrimSpace(*job.GCSOutputURI)
  307. }
  308. }
  309. switch target {
  310. case CleanupTargetInput:
  311. return p.deleteManagedInput(ctx, accessToken, job, inputRef)
  312. case CleanupTargetOutput:
  313. return p.deleteManagedOutput(ctx, accessToken, job, outputRef)
  314. case CleanupTargetAll:
  315. if err := p.deleteManagedInput(ctx, accessToken, job, inputRef); err != nil {
  316. return err
  317. }
  318. return p.deleteManagedOutput(ctx, accessToken, job, outputRef)
  319. default:
  320. return ErrUnsupportedCleanupTarget
  321. }
  322. }
  323. func (p *VertexBatchImageProvider) validateAccount(account *Account) error {
  324. if account == nil || account.Platform != PlatformGemini || account.Type != AccountTypeServiceAccount {
  325. return ErrBatchImageProviderUnsupportedAccount
  326. }
  327. if _, err := parseVertexServiceAccountKey(account); err != nil {
  328. return ErrBatchImageProviderMissingServiceAccount
  329. }
  330. return nil
  331. }
  332. func (p *VertexBatchImageProvider) accessToken(ctx context.Context, account *Account) (string, error) {
  333. return getVertexServiceAccountAccessToken(ctx, p.tokenCache, account)
  334. }
  335. func (p *VertexBatchImageProvider) deleteManagedInput(ctx context.Context, accessToken string, job *BatchImageJob, uri string) error {
  336. if strings.TrimSpace(uri) == "" {
  337. return nil
  338. }
  339. if !p.isSafeManagedInput(job, uri) {
  340. return ErrBatchImageProviderUnsafeCleanupPath
  341. }
  342. return mapVertexClientError(p.objectStore.DeleteObject(ctx, accessToken, uri))
  343. }
  344. func (p *VertexBatchImageProvider) deleteManagedOutput(ctx context.Context, accessToken string, job *BatchImageJob, uri string) error {
  345. if strings.TrimSpace(uri) == "" {
  346. return nil
  347. }
  348. if !p.isSafeManagedOutput(job, uri) {
  349. return ErrBatchImageProviderUnsafeCleanupPath
  350. }
  351. return mapVertexClientError(p.objectStore.DeletePrefix(ctx, accessToken, uri))
  352. }
  353. func (p *VertexBatchImageProvider) isSafeManagedInput(job *BatchImageJob, uri string) bool {
  354. if job == nil || strings.TrimSpace(job.BatchID) == "" {
  355. return false
  356. }
  357. refs, err := p.managedRefs(job.BatchID)
  358. return err == nil && strings.TrimSpace(uri) == refs.InputURI
  359. }
  360. func (p *VertexBatchImageProvider) isSafeManagedOutput(job *BatchImageJob, uri string) bool {
  361. if job == nil || strings.TrimSpace(job.BatchID) == "" {
  362. return false
  363. }
  364. refs, err := p.managedRefs(job.BatchID)
  365. return err == nil && strings.HasPrefix(strings.TrimSpace(uri), refs.OutputPrefixURI)
  366. }
  367. type vertexManagedRefs struct {
  368. Prefix string
  369. InputURI string
  370. OutputPrefixURI string
  371. }
  372. func (p *VertexBatchImageProvider) managedRefs(batchID string) (vertexManagedRefs, error) {
  373. batchID = strings.TrimSpace(batchID)
  374. if !IsValidBatchImageID(batchID) {
  375. return vertexManagedRefs{}, batchImageProviderInputError("valid batch_id is required")
  376. }
  377. bucket := strings.Trim(strings.TrimSpace(p.opts.ManagedGCSBucket), "/")
  378. if bucket == "" || strings.Contains(bucket, "://") {
  379. return vertexManagedRefs{}, vertexProviderError("VERTEX_MANAGED_GCS_BUCKET_MISSING", "Vertex managed GCS bucket is not configured", nil)
  380. }
  381. prefix := buildVertexManagedGCSPrefix(p.opts.ManagedGCSPrefix, p.opts.Environment, batchID)
  382. if !strings.Contains(prefix, batchID) {
  383. return vertexManagedRefs{}, batchImageProviderInputError("managed GCS prefix must contain batch_id")
  384. }
  385. base := "gs://" + bucket + "/" + strings.Trim(prefix, "/")
  386. return vertexManagedRefs{
  387. Prefix: strings.Trim(prefix, "/"),
  388. InputURI: base + "/input/requests.jsonl",
  389. OutputPrefixURI: base + "/output/",
  390. }, nil
  391. }
  392. func buildVertexManagedGCSPrefix(template, env, batchID string) string {
  393. template = strings.Trim(strings.TrimSpace(template), "/")
  394. if template == "" {
  395. template = defaultVertexManagedGCSPrefix
  396. }
  397. env = sanitizeVertexGCSPathSegment(env)
  398. batchID = sanitizeVertexGCSPathSegment(batchID)
  399. prefix := strings.ReplaceAll(template, "{env}", env)
  400. prefix = strings.ReplaceAll(prefix, "{batch_id}", batchID)
  401. return strings.Trim(prefix, "/")
  402. }
  403. func sanitizeVertexGCSPathSegment(v string) string {
  404. v = strings.TrimSpace(v)
  405. if v == "" {
  406. return "default"
  407. }
  408. var b strings.Builder
  409. for _, r := range v {
  410. switch {
  411. case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
  412. _, _ = b.WriteRune(r)
  413. case r == '-', r == '_', r == '.':
  414. _, _ = b.WriteRune(r)
  415. default:
  416. _ = b.WriteByte('-')
  417. }
  418. }
  419. return b.String()
  420. }
  421. func vertexBatchDisplayName(input BatchImageInput) string {
  422. if v := strings.TrimSpace(input.DisplayName); v != "" {
  423. return v
  424. }
  425. if v := strings.TrimSpace(input.BatchID); v != "" {
  426. return "sub2api-" + v
  427. }
  428. return "sub2api-image-batch"
  429. }
  430. func BuildVertexBatchJSONL(input BatchImageInput) ([]byte, error) {
  431. if strings.TrimSpace(input.Model) == "" {
  432. return nil, batchImageProviderInputError("model is required")
  433. }
  434. if len(input.Items) == 0 {
  435. return nil, batchImageProviderInputError("at least one item is required")
  436. }
  437. seen := make(map[string]struct{}, len(input.Items))
  438. var buf bytes.Buffer
  439. enc := json.NewEncoder(&buf)
  440. for _, item := range input.Items {
  441. customID := strings.TrimSpace(item.CustomID)
  442. if customID == "" {
  443. return nil, batchImageProviderInputError("custom_id is required")
  444. }
  445. if _, ok := seen[customID]; ok {
  446. return nil, batchImageProviderInputError("duplicate custom_id %q", customID)
  447. }
  448. seen[customID] = struct{}{}
  449. prompt := strings.TrimSpace(item.Prompt)
  450. if prompt == "" {
  451. return nil, batchImageProviderInputError("prompt is required for custom_id %q", customID)
  452. }
  453. parts, err := vertexBatchImageParts(prompt, item.ReferenceImages)
  454. if err != nil {
  455. return nil, err
  456. }
  457. line := map[string]any{
  458. "key": customID,
  459. "request": map[string]any{
  460. "contents": []any{map[string]any{
  461. "role": "user",
  462. "parts": parts,
  463. }},
  464. "generationConfig": map[string]any{
  465. "responseModalities": []string{"TEXT", "IMAGE"},
  466. },
  467. },
  468. }
  469. if err := enc.Encode(line); err != nil {
  470. return nil, err
  471. }
  472. }
  473. return buf.Bytes(), nil
  474. }
  475. func vertexBatchImageParts(prompt string, refs []BatchImageReference) ([]any, error) {
  476. parts := []any{map[string]any{"text": prompt}}
  477. for _, ref := range refs {
  478. mimeType := normalizeBatchImageReferenceMimeType(ref.MimeType)
  479. if mimeType == "" {
  480. return nil, batchImageProviderInputError("reference image mime_type is required")
  481. }
  482. fileURI := strings.TrimSpace(ref.FileURI)
  483. switch {
  484. case len(ref.Data) > 0 && fileURI == "":
  485. parts = append(parts, map[string]any{
  486. "inlineData": map[string]any{
  487. "mimeType": mimeType,
  488. "data": base64.StdEncoding.EncodeToString(ref.Data),
  489. },
  490. })
  491. case len(ref.Data) == 0 && fileURI != "":
  492. parts = append(parts, map[string]any{
  493. "fileData": map[string]any{
  494. "mimeType": mimeType,
  495. "fileUri": fileURI,
  496. },
  497. })
  498. default:
  499. return nil, batchImageProviderInputError("reference image must contain exactly one of data or file_uri")
  500. }
  501. }
  502. return parts, nil
  503. }
  504. func NormalizeVertexBatchModelPath(model string) string {
  505. model = strings.Trim(strings.TrimSpace(model), "/")
  506. if strings.HasPrefix(model, "publishers/") || strings.HasPrefix(model, "projects/") {
  507. return model
  508. }
  509. return "publishers/google/models/" + model
  510. }
  511. func BuildVertexBatchPredictionJobsEndpoint(baseURL, projectID, location string) (string, error) {
  512. projectID = strings.TrimSpace(projectID)
  513. location = strings.TrimSpace(location)
  514. if projectID == "" {
  515. return "", errors.New("vertex project_id is required")
  516. }
  517. if location == "" {
  518. location = defaultVertexBatchLocation
  519. }
  520. if !vertexLocationPattern.MatchString(location) {
  521. return "", fmt.Errorf("invalid vertex location: %s", location)
  522. }
  523. if strings.TrimSpace(baseURL) != "" {
  524. return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/v1/projects/" + url.PathEscape(projectID) + "/locations/" + url.PathEscape(location) + "/batchPredictionJobs", nil
  525. }
  526. host := fmt.Sprintf("%s-aiplatform.googleapis.com", location)
  527. if location == "global" {
  528. host = "aiplatform.googleapis.com"
  529. }
  530. return fmt.Sprintf("https://%s/v1/projects/%s/locations/%s/batchPredictionJobs", host, url.PathEscape(projectID), url.PathEscape(location)), nil
  531. }
  532. func mapVertexBatchState(job *VertexBatchPredictionJob) *BatchProviderStatus {
  533. state := strings.TrimSpace(job.State)
  534. status := &BatchProviderStatus{
  535. RawState: state,
  536. InternalState: BatchProviderStateRunning,
  537. SuggestedRequeueAfter: defaultVertexBatchRequeueAfter,
  538. }
  539. switch strings.ToUpper(state) {
  540. case "JOB_STATE_PENDING", "JOB_STATE_QUEUED":
  541. status.InternalState = BatchProviderStateQueued
  542. case "JOB_STATE_RUNNING", "JOB_STATE_PAUSED":
  543. status.InternalState = BatchProviderStateRunning
  544. case "JOB_STATE_SUCCEEDED":
  545. status.InternalState = BatchProviderStateSucceeded
  546. status.Done = true
  547. status.SuggestedRequeueAfter = 0
  548. case "JOB_STATE_FAILED":
  549. status.InternalState = BatchProviderStateFailed
  550. status.Done = true
  551. status.ErrorCode = "VERTEX_BATCH_FAILED"
  552. status.SuggestedRequeueAfter = 0
  553. case "JOB_STATE_CANCELLED":
  554. status.InternalState = BatchProviderStateCancelled
  555. status.Done = true
  556. status.ErrorCode = "VERTEX_BATCH_CANCELLED"
  557. status.SuggestedRequeueAfter = 0
  558. case "JOB_STATE_EXPIRED":
  559. status.InternalState = BatchProviderStateExpired
  560. status.Done = true
  561. status.ErrorCode = "VERTEX_BATCH_EXPIRED"
  562. status.SuggestedRequeueAfter = 0
  563. default:
  564. if job.Error != nil && strings.TrimSpace(job.Error.Message) != "" {
  565. status.InternalState = BatchProviderStateFailed
  566. status.Done = true
  567. status.ErrorCode = "VERTEX_BATCH_FAILED"
  568. status.SuggestedRequeueAfter = 0
  569. }
  570. }
  571. if job.Error != nil {
  572. if code := strings.TrimSpace(job.Error.Status); code != "" {
  573. status.ErrorCode = code
  574. }
  575. status.ErrorMessage = strings.TrimSpace(job.Error.Message)
  576. }
  577. return status
  578. }
  579. func vertexProviderError(reason, message string, cause error) error {
  580. err := infraerrors.New(http.StatusBadGateway, reason, message)
  581. if cause != nil {
  582. return err.WithCause(cause)
  583. }
  584. return err
  585. }
  586. func mapVertexClientError(err error) error {
  587. if err == nil {
  588. return nil
  589. }
  590. if errors.Is(err, ErrBatchImageProviderMissingServiceAccount) ||
  591. errors.Is(err, ErrBatchImageProviderMissingJobName) ||
  592. errors.Is(err, ErrBatchImageProviderMissingResultRef) ||
  593. errors.Is(err, ErrBatchImageProviderUnsafeCleanupPath) ||
  594. errors.Is(err, ErrUnsupportedCleanupTarget) {
  595. return err
  596. }
  597. var apiErr *VertexAPIError
  598. if errors.As(err, &apiErr) {
  599. switch apiErr.StatusCode {
  600. case http.StatusUnauthorized:
  601. return vertexProviderError("VERTEX_AUTH_FAILED", "Vertex authentication failed", nil)
  602. case http.StatusForbidden:
  603. return vertexProviderError("VERTEX_PERMISSION_DENIED", "Vertex permission denied", nil)
  604. case http.StatusTooManyRequests:
  605. return vertexProviderError("VERTEX_RATE_LIMITED", "Vertex rate limit exceeded", nil)
  606. case http.StatusNotFound:
  607. return vertexProviderError("VERTEX_BATCH_NOT_FOUND", "Vertex batch resource was not found", nil)
  608. default:
  609. return vertexProviderError("VERTEX_INVALID_RESPONSE", "Vertex API request failed", nil)
  610. }
  611. }
  612. return vertexProviderError("VERTEX_INVALID_RESPONSE", "Vertex API request failed", err)
  613. }
  614. type vertexCombinedJSONLReadCloser struct {
  615. ctx context.Context
  616. accessToken string
  617. objects []string
  618. store VertexBatchObjectStore
  619. index int
  620. current io.ReadCloser
  621. needBoundary bool
  622. closed bool
  623. }
  624. func (r *vertexCombinedJSONLReadCloser) Read(p []byte) (int, error) {
  625. if r.closed {
  626. return 0, io.ErrClosedPipe
  627. }
  628. if r.needBoundary {
  629. if len(p) == 0 {
  630. return 0, nil
  631. }
  632. p[0] = '\n'
  633. r.needBoundary = false
  634. return 1, nil
  635. }
  636. for {
  637. if r.current == nil {
  638. if r.index >= len(r.objects) {
  639. return 0, io.EOF
  640. }
  641. obj := r.objects[r.index]
  642. r.index++
  643. rc, _, err := r.store.OpenObject(r.ctx, r.accessToken, obj)
  644. if err != nil {
  645. return 0, err
  646. }
  647. r.current = rc
  648. }
  649. n, err := r.current.Read(p)
  650. if err == io.EOF {
  651. _ = r.current.Close()
  652. r.current = nil
  653. if r.index < len(r.objects) {
  654. if n > 0 {
  655. r.needBoundary = true
  656. return n, nil
  657. }
  658. if len(p) == 0 {
  659. return 0, nil
  660. }
  661. p[0] = '\n'
  662. return 1, nil
  663. }
  664. if n > 0 {
  665. return n, nil
  666. }
  667. continue
  668. }
  669. return n, err
  670. }
  671. }
  672. func (r *vertexCombinedJSONLReadCloser) Close() error {
  673. r.closed = true
  674. if r.current != nil {
  675. return r.current.Close()
  676. }
  677. return nil
  678. }
  679. type VertexBatchHTTPClient struct {
  680. baseURL string
  681. client *http.Client
  682. }
  683. func NewVertexBatchHTTPClient(baseURL string, client *http.Client) *VertexBatchHTTPClient {
  684. if client == nil {
  685. client = batchImageDefaultHTTPClient()
  686. }
  687. return &VertexBatchHTTPClient{baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), client: client}
  688. }
  689. func (c *VertexBatchHTTPClient) CreateBatchPredictionJob(ctx context.Context, accessToken string, req VertexCreateBatchPredictionJobRequest) (*VertexBatchPredictionJob, error) {
  690. endpoint, err := BuildVertexBatchPredictionJobsEndpoint(c.baseURL, req.ProjectID, req.Location)
  691. if err != nil {
  692. return nil, err
  693. }
  694. payload, err := json.Marshal(req)
  695. if err != nil {
  696. return nil, err
  697. }
  698. httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
  699. if err != nil {
  700. return nil, err
  701. }
  702. httpReq.Header.Set("Content-Type", "application/json")
  703. httpReq.Header.Set("Authorization", "Bearer "+accessToken)
  704. return doVertexJSON[VertexBatchPredictionJob](c.client, httpReq)
  705. }
  706. func (c *VertexBatchHTTPClient) GetBatchPredictionJob(ctx context.Context, accessToken string, name string) (*VertexBatchPredictionJob, error) {
  707. endpoint := c.vertexResourceURL(name)
  708. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
  709. if err != nil {
  710. return nil, err
  711. }
  712. req.Header.Set("Authorization", "Bearer "+accessToken)
  713. return doVertexJSON[VertexBatchPredictionJob](c.client, req)
  714. }
  715. func (c *VertexBatchHTTPClient) CancelBatchPredictionJob(ctx context.Context, accessToken string, name string) error {
  716. endpoint := c.vertexResourceURL(name) + ":cancel"
  717. req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
  718. if err != nil {
  719. return err
  720. }
  721. req.Header.Set("Authorization", "Bearer "+accessToken)
  722. return doVertexNoBody(c.client, req)
  723. }
  724. func (c *VertexBatchHTTPClient) vertexResourceURL(name string) string {
  725. name = strings.TrimLeft(strings.TrimSpace(name), "/")
  726. if c.baseURL != "" {
  727. return c.baseURL + "/v1/" + name
  728. }
  729. return "https://aiplatform.googleapis.com/v1/" + name
  730. }
  731. type VertexGCSObjectStore struct {
  732. baseURL string
  733. client *http.Client
  734. }
  735. func NewVertexGCSObjectStore(baseURL string, client *http.Client) *VertexGCSObjectStore {
  736. if client == nil {
  737. client = batchImageDefaultHTTPClient()
  738. }
  739. baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
  740. if baseURL == "" {
  741. baseURL = "https://storage.googleapis.com"
  742. }
  743. return &VertexGCSObjectStore{baseURL: baseURL, client: client}
  744. }
  745. func (s *VertexGCSObjectStore) UploadJSONL(ctx context.Context, accessToken string, uri string, r io.Reader) error {
  746. bucket, object, err := parseGCSURI(uri)
  747. if err != nil {
  748. return err
  749. }
  750. endpoint := fmt.Sprintf("%s/upload/storage/v1/b/%s/o?uploadType=media&name=%s", s.baseURL, url.PathEscape(bucket), url.QueryEscape(object))
  751. req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, r)
  752. if err != nil {
  753. return err
  754. }
  755. req.Header.Set("Authorization", "Bearer "+accessToken)
  756. req.Header.Set("Content-Type", "application/jsonl")
  757. return doVertexNoBody(s.client, req)
  758. }
  759. func (s *VertexGCSObjectStore) ListJSONLObjects(ctx context.Context, accessToken string, prefixURI string) ([]string, error) {
  760. return s.listObjects(ctx, accessToken, prefixURI, true)
  761. }
  762. func (s *VertexGCSObjectStore) listObjects(ctx context.Context, accessToken string, prefixURI string, jsonlOnly bool) ([]string, error) {
  763. bucket, prefix, err := parseGCSURI(prefixURI)
  764. if err != nil {
  765. return nil, err
  766. }
  767. var objects []string
  768. pageToken := ""
  769. for {
  770. endpoint := fmt.Sprintf("%s/storage/v1/b/%s/o?prefix=%s", s.baseURL, url.PathEscape(bucket), url.QueryEscape(prefix))
  771. if pageToken != "" {
  772. endpoint += "&pageToken=" + url.QueryEscape(pageToken)
  773. }
  774. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
  775. if err != nil {
  776. return nil, err
  777. }
  778. req.Header.Set("Authorization", "Bearer "+accessToken)
  779. var page struct {
  780. Items []struct {
  781. Name string `json:"name"`
  782. } `json:"items"`
  783. NextPageToken string `json:"nextPageToken"`
  784. }
  785. if err := doVertexDecodeJSON(s.client, req, &page); err != nil {
  786. return nil, err
  787. }
  788. for _, item := range page.Items {
  789. if !jsonlOnly || strings.HasSuffix(item.Name, ".jsonl") {
  790. objects = append(objects, "gs://"+bucket+"/"+item.Name)
  791. }
  792. }
  793. if page.NextPageToken == "" {
  794. return objects, nil
  795. }
  796. pageToken = page.NextPageToken
  797. }
  798. }
  799. func (s *VertexGCSObjectStore) OpenObject(ctx context.Context, accessToken string, uri string) (io.ReadCloser, string, error) {
  800. bucket, object, err := parseGCSURI(uri)
  801. if err != nil {
  802. return nil, "", err
  803. }
  804. endpoint := fmt.Sprintf("%s/storage/v1/b/%s/o/%s?alt=media", s.baseURL, url.PathEscape(bucket), url.PathEscape(object))
  805. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
  806. if err != nil {
  807. return nil, "", err
  808. }
  809. req.Header.Set("Authorization", "Bearer "+accessToken)
  810. resp, err := s.client.Do(req)
  811. if err != nil {
  812. return nil, "", err
  813. }
  814. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  815. defer func() { _ = resp.Body.Close() }()
  816. return nil, "", readVertexAPIError(resp)
  817. }
  818. contentType := resp.Header.Get("Content-Type")
  819. if contentType == "" {
  820. contentType = "application/jsonl"
  821. }
  822. return resp.Body, contentType, nil
  823. }
  824. func (s *VertexGCSObjectStore) DeleteObject(ctx context.Context, accessToken string, uri string) error {
  825. bucket, object, err := parseGCSURI(uri)
  826. if err != nil {
  827. return err
  828. }
  829. endpoint := fmt.Sprintf("%s/storage/v1/b/%s/o/%s", s.baseURL, url.PathEscape(bucket), url.PathEscape(object))
  830. req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
  831. if err != nil {
  832. return err
  833. }
  834. req.Header.Set("Authorization", "Bearer "+accessToken)
  835. return doVertexNoBody(s.client, req)
  836. }
  837. func (s *VertexGCSObjectStore) DeletePrefix(ctx context.Context, accessToken string, prefixURI string) error {
  838. objects, err := s.listObjects(ctx, accessToken, prefixURI, false)
  839. if err != nil {
  840. return err
  841. }
  842. for _, object := range objects {
  843. if err := s.DeleteObject(ctx, accessToken, object); err != nil {
  844. return err
  845. }
  846. }
  847. return nil
  848. }
  849. func parseGCSURI(uri string) (bucket, object string, err error) {
  850. uri = strings.TrimSpace(uri)
  851. if !strings.HasPrefix(uri, "gs://") {
  852. return "", "", fmt.Errorf("invalid gcs uri")
  853. }
  854. rest := strings.TrimPrefix(uri, "gs://")
  855. parts := strings.SplitN(rest, "/", 2)
  856. if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
  857. return "", "", fmt.Errorf("invalid gcs uri")
  858. }
  859. return parts[0], parts[1], nil
  860. }
  861. type VertexAPIError struct {
  862. StatusCode int
  863. Code string
  864. Message string
  865. }
  866. func (e *VertexAPIError) Error() string {
  867. if e == nil {
  868. return "<nil>"
  869. }
  870. if e.Code != "" {
  871. return fmt.Sprintf("vertex api error: status=%d code=%s message=%s", e.StatusCode, e.Code, e.Message)
  872. }
  873. return fmt.Sprintf("vertex api error: status=%d message=%s", e.StatusCode, e.Message)
  874. }
  875. func doVertexJSON[T any](client *http.Client, req *http.Request) (*T, error) {
  876. var out T
  877. if err := doVertexDecodeJSON(client, req, &out); err != nil {
  878. return nil, err
  879. }
  880. return &out, nil
  881. }
  882. func doVertexDecodeJSON(client *http.Client, req *http.Request, out any) error {
  883. resp, err := client.Do(req)
  884. if err != nil {
  885. return err
  886. }
  887. defer func() { _ = resp.Body.Close() }()
  888. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  889. return readVertexAPIError(resp)
  890. }
  891. return json.NewDecoder(resp.Body).Decode(out)
  892. }
  893. func doVertexNoBody(client *http.Client, req *http.Request) error {
  894. resp, err := client.Do(req)
  895. if err != nil {
  896. return err
  897. }
  898. defer func() { _ = resp.Body.Close() }()
  899. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  900. return readVertexAPIError(resp)
  901. }
  902. return nil
  903. }
  904. func readVertexAPIError(resp *http.Response) error {
  905. body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
  906. message := string(body)
  907. code := ""
  908. var parsed struct {
  909. Error struct {
  910. Code any `json:"code"`
  911. Message string `json:"message"`
  912. Status string `json:"status"`
  913. } `json:"error"`
  914. }
  915. if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error.Message != "" {
  916. message = parsed.Error.Message
  917. code = parsed.Error.Status
  918. }
  919. return &VertexAPIError{StatusCode: resp.StatusCode, Code: code, Message: message}
  920. }
  921. var _ BatchImageProvider = (*VertexBatchImageProvider)(nil)
  922. var _ VertexBatchClient = (*VertexBatchHTTPClient)(nil)
  923. var _ VertexBatchObjectStore = (*VertexGCSObjectStore)(nil)