Files
image2api/backend/internal/provider/krea/client.go
T
chiyiandClaude Opus 4.8 606caaf047 Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:59:04 +08:00

489 lines
15 KiB
Go

// Package krea implements the Krea.ai (krea.ai) provider client. The durable
// credential is the browser cookie (Supabase "sb-superb-auth-token"); Krea's own
// Next.js backend reads it directly, so quota and generation just forward the
// cookie — there's no separate token-exchange step. tls-client gives a Chrome
// JA3/JA4 so Krea's Cloudflare edge doesn't flag the requests.
package krea
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
// kreaAnonKey is Krea's public Supabase anon key (fixed, embedded in their
// frontend) — required as the apikey/bearer when refreshing a session token.
const kreaAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1Mjc4ODU3LCJleHAiOjE5MzI5NTg4NTd9.NUiqEOd__QsCCMjo3D1zrCAda5dLV2F5p6Kf584sZKc"
const (
apiBase = "https://www.krea.ai"
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
var (
ErrAuth = errors.New("krea auth failed")
ErrQuotaExhausted = errors.New("krea quota exhausted")
ErrTemporaryUpstream = errors.New("krea upstream temporary error")
)
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
// (not lazily at expiry). The maintenance sweep refreshes proactively, so a
// dormant account's token never lapses — once the rotating refresh_token is gone
// (expired/consumed) the account can't recover, so we keep it perpetually fresh.
const refreshLeadSeconds = 600 // 10 minutes
type Client struct {
proxy string
// freshest cookie per account (key: user id) + a per-account refresh lock, so
// concurrent callers don't each spend the single-use (rotating) refresh_token —
// the first refreshes, the rest reuse the cached fresh cookie.
mu sync.Mutex
cookies map[string]string
locks map[string]*sync.Mutex
// actAt = last /app activation time per account (key: user id); actLocks gives
// a per-account lock so concurrent generations wait for the first to finish the
// (once-per-daily-reset) activation instead of each loading /app.
actAt map[string]int64
actLocks map[string]*sync.Mutex
}
func NewClient(proxy string) *Client {
return &Client{
proxy: strings.TrimSpace(proxy),
cookies: map[string]string{},
locks: map[string]*sync.Mutex{},
actAt: map[string]int64{},
actLocks: map[string]*sync.Mutex{},
}
}
func (c *Client) userLock(userID string) *sync.Mutex {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.locks[userID]
if !ok {
m = &sync.Mutex{}
c.locks[userID] = m
}
return m
}
// RefreshIfNeeded returns a cookie whose access_token is still valid: if the
// stored one is (near) expired it spends the refresh_token to mint a new session
// and rebuilds the cookie. Returns (cookie, changed, err); changed=true means the
// caller must persist the new cookie (the refresh_token rotated). ErrAuth means
// the refresh_token is dead → the account is gone.
func (c *Client) RefreshIfNeeded(ctx context.Context, cookie string) (string, bool, error) {
authVal := authCookieValue(cookie)
sess, ok := decodeSession(authVal)
if !ok {
return cookie, false, nil // unparseable — let the downstream call surface the error
}
userID := nestedStr(sess, "user", "id")
now := time.Now().Unix()
lk := c.userLock(userID)
lk.Lock()
defer lk.Unlock()
// A concurrent caller may already have refreshed this account.
if userID != "" {
c.mu.Lock()
cached := c.cookies[userID]
c.mu.Unlock()
if cs, ok := decodeSession(authCookieValue(cached)); ok && toInt64(cs["expires_at"])-refreshLeadSeconds > now {
return cached, cached != cookie, nil
}
}
if toInt64(sess["expires_at"])-refreshLeadSeconds > now {
return cookie, false, nil // still valid
}
refreshTok := strings.TrimSpace(stringValue(sess["refresh_token"]))
if refreshTok == "" {
return "", false, ErrAuth
}
respBody, status, err := c.refreshPost(ctx, refreshTok)
if err != nil {
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
}
if status == 400 || status == 401 || status == 403 {
return "", false, ErrAuth
}
if status != 200 {
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
}
var ns map[string]any
if json.Unmarshal(respBody, &ns) != nil || strings.TrimSpace(stringValue(ns["access_token"])) == "" {
return "", false, ErrAuth
}
newCookie := replaceAuthCookie(cookie, "base64-"+base64.StdEncoding.EncodeToString(respBody))
if userID != "" {
c.mu.Lock()
c.cookies[userID] = newCookie
c.mu.Unlock()
}
return newCookie, true, nil
}
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
client, err := c.newTLSClient()
if err != nil {
return nil, 0, err
}
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
req, err := http.NewRequest(http.MethodPost, apiBase+"/auth/v1/token?grant_type=refresh_token", bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"content-type": {"application/json;charset=UTF-8"},
"apikey": {kreaAnonKey},
"authorization": {"Bearer " + kreaAnonKey},
"x-client-info": {"supabase-ssr/0.6.1 createBrowserClient"},
"x-supabase-api-version": {"2024-01-01"},
"origin": {apiBase},
"referer": {apiBase + "/"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "content-type", "apikey", "authorization", "x-client-info",
"x-supabase-api-version", "origin", "referer", "user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
// chunkSize is supabase-ssr's per-cookie chunk limit; larger sessions (e.g.
// Google-OAuth accounts) are split into sb-superb-auth-token.0/.1/...
const chunkSize = 3600
func cookieVal(cookie, name string) string {
for _, p := range strings.Split(cookie, ";") {
p = strings.TrimSpace(p)
if v, ok := strings.CutPrefix(p, name+"="); ok {
return v
}
}
return ""
}
// authCookieValue returns the full auth value, transparently reassembling a
// chunked cookie (sb-superb-auth-token.0 + .1 + ...) or returning the single one.
func authCookieValue(cookie string) string {
if v := cookieVal(cookie, "sb-superb-auth-token"); v != "" {
return v
}
var b strings.Builder
for i := 0; ; i++ {
v := cookieVal(cookie, fmt.Sprintf("sb-superb-auth-token.%d", i))
if v == "" {
break
}
b.WriteString(v)
}
return b.String()
}
// replaceAuthCookie drops every sb-superb-auth-token[.N] cookie and re-adds the
// new value (chunked the same way supabase-ssr would if it's large), preserving
// all other cookies (krea-workspace-id, etc.).
func replaceAuthCookie(cookie, newValue string) string {
var out []string
for _, p := range strings.Split(cookie, ";") {
t := strings.TrimSpace(p)
if t == "" || strings.HasPrefix(t, "sb-superb-auth-token=") || strings.HasPrefix(t, "sb-superb-auth-token.") {
continue
}
out = append(out, t)
}
if len(newValue) <= chunkSize {
out = append(out, "sb-superb-auth-token="+newValue)
} else {
for i, off := 0, 0; off < len(newValue); i++ {
end := off + chunkSize
if end > len(newValue) {
end = len(newValue)
}
out = append(out, fmt.Sprintf("sb-superb-auth-token.%d=%s", i, newValue[off:end]))
off = end
}
}
return strings.Join(out, "; ")
}
// decodeSession base64-decodes the auth cookie value into the session JSON map.
func decodeSession(authValue string) (map[string]any, bool) {
v := strings.TrimPrefix(strings.TrimSpace(authValue), "base64-")
if v == "" {
return nil, false
}
raw, err := base64.StdEncoding.DecodeString(v)
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(v)
if err != nil {
return nil, false
}
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
return nil, false
}
return m, true
}
func nestedStr(m map[string]any, k1, k2 string) string {
if sub, ok := m[k1].(map[string]any); ok {
return strings.TrimSpace(stringValue(sub[k2]))
}
return ""
}
func toInt64(v any) int64 {
switch x := v.(type) {
case float64:
return int64(x)
case int64:
return x
case int:
return int64(x)
case json.Number:
n, _ := x.Int64()
return n
case string:
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
return n
default:
return 0
}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
// IsKreaCookie reports whether a pasted credential is a Krea cookie: it carries
// the Supabase auth cookie. Distinguishes it from adobe/leonardo cookies.
func IsKreaCookie(value string) bool {
return strings.Contains(value, "sb-superb-auth-token")
}
// EmailFromCookie decodes the account email straight out of the cookie's embedded
// Supabase session (no network), handling chunked cookies too.
func EmailFromCookie(cookie string) string {
sess, ok := decodeSession(authCookieValue(cookie))
if !ok {
return ""
}
return nestedStr(sess, "user", "email")
}
// FetchCreditsBalance reads the account's free-credit balance via /api/billing-data.
// remaining is the integer floor of balance.free (per spec: 17.94 → 17). 401 →
// ErrAuth (cookie dead). Returns the normalized map shared by all providers.
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
// Detach from the request ctx so a page refresh can't cancel the probe
// mid-flight (which left accounts stuck at "—").
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
// NOTE: no /app here — the heavy SSR activation is done separately (on recovery
// and at generation via Activate). This probe just reads the current balance.
body, status, err := c.apiGet(probeCtx, cookie, "/api/billing-data")
if err != nil {
return unknownBalance("network: " + err.Error()), nil
}
if status == 401 || status == 403 {
return nil, ErrAuth
}
if status != 200 {
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
}
// 余额在 balance.free(真实剩余,小数,随用量递减)。krea 的这个字段一直都在,
// 只是排在很长的 entitlements 之后 —— 只读它,不用套餐配额兜底。
var bd struct {
Balance struct {
Free float64 `json:"free"`
Total float64 `json:"total"`
} `json:"balance"`
}
if err := json.Unmarshal(body, &bd); err != nil {
return unknownBalance("non-json"), nil
}
remaining := int(bd.Balance.Free) // floor
return map[string]any{
"remaining": remaining,
"used": nil,
"total": int(bd.Balance.Total),
"unknown": false,
"error": nil,
"email": emptyStringNil(EmailFromCookie(cookie)),
}, nil
}
// Activate loads the authenticated SSR app page (/app), which is what makes krea
// grant the account's DAILY free balance — a cold API-only call (billing-data /
// generate) otherwise sees balance.free=0 and 402s. Done at most ONCE per account
// per daily reset, under a per-account lock: the first caller loads /app while
// concurrent callers wait, then everyone proceeds (no redundant /app). Called
// before each generation and by the daily activation sweep. Best-effort.
func (c *Client) Activate(ctx context.Context, cookie string) {
key := accountKey(cookie)
lastReset := (time.Now().Unix() / 86400) * 86400
c.mu.Lock()
doneToday := key != "" && c.actAt[key] >= lastReset
c.mu.Unlock()
if doneToday {
return
}
lk := c.actLock(key)
lk.Lock()
defer lk.Unlock()
// Re-check after acquiring the lock — another caller may have just activated.
c.mu.Lock()
doneToday = key != "" && c.actAt[key] >= lastReset
c.mu.Unlock()
if doneToday {
return
}
actCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
defer cancel()
_, _, _ = c.apiGet(actCtx, cookie, "/app")
c.mu.Lock()
c.actAt[key] = time.Now().Unix()
c.mu.Unlock()
}
func (c *Client) actLock(key string) *sync.Mutex {
c.mu.Lock()
defer c.mu.Unlock()
m, ok := c.actLocks[key]
if !ok {
m = &sync.Mutex{}
c.actLocks[key] = m
}
return m
}
// accountKey is the stable per-account id (the Supabase user id from the session
// cookie) used to key activation state — survives cookie rotation.
func accountKey(cookie string) string {
if sess, ok := decodeSession(authCookieValue(cookie)); ok {
return nestedStr(sess, "user", "id")
}
return ""
}
// apiGet issues a GET to a krea.ai API path carrying the account cookie.
func (c *Client) apiGet(ctx context.Context, cookie, path string) ([]byte, int, error) {
client, err := c.newTLSClient()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodGet, apiBase+path, nil)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"accept-language": {"en-US,en;q=0.9"},
"cookie": {cookie},
"referer": {apiBase + "/"},
"user-agent": {userAgent},
"sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-origin"},
http.HeaderOrderKey: {
"accept", "accept-language", "cookie", "referer", "user-agent",
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
options := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(60),
tlsclient.WithClientProfile(profiles.Chrome_120),
}
if c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
func unknownBalance(reason string) map[string]any {
return map[string]any{
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
}
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, _ := json.Marshal(x)
return strings.TrimSpace(string(b))
}
}
func intValue(v any) int {
switch x := v.(type) {
case int:
return x
case float64:
return int(x)
case json.Number:
n, _ := x.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(x))
return n
default:
return 0
}
}
func emptyStringNil(v string) any {
if strings.TrimSpace(v) == "" {
return nil
}
return v
}
func clip(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return s[:n]
}
return s
}