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>
This commit is contained in:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+441
View File
@@ -0,0 +1,441 @@
// Package imagine implements the Imagine.art (vyro.ai) provider client. The
// durable credential is a JSON blob {"token","refreshToken"}: `token` is a ~6h
// access JWT used as Authorization: Bearer for the API, and `refreshToken` is a
// ~7d JWT that mints a fresh pair via /apis/v1/auth/other/refresh/web when the
// access token expires. Both rotate on refresh, so the new pair MUST be saved.
// tls-client gives a Chrome JA3/JA4 so vyro's edge doesn't flag the requests.
package imagine
import (
"context"
"crypto/rand"
"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"
)
const (
apiBase = "https://imagine.vyro.ai"
teamsBase = "https://teams-imagine.vyro.ai"
authBase = "https://auth.vyro.ai"
webOrigin = "https://www.imagine.art"
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("imagine auth failed")
ErrQuotaExhausted = errors.New("imagine quota exhausted")
ErrTemporaryUpstream = errors.New("imagine upstream temporary error")
)
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
// (proactive, not lazy at expiry) — the maintenance sweep keeps tokens fresh so a
// dormant account's rotating refreshToken never lapses.
const refreshLeadSeconds = 600 // 10 minutes
type Client struct {
proxy string
// freshest credential per account (key: user id) + a per-account refresh lock,
// so concurrent callers don't each spend the rotating refresh_token — the first
// refreshes, the rest reuse the cached fresh credential.
mu sync.Mutex
creds map[string]string
locks map[string]*sync.Mutex
}
func NewClient(proxy string) *Client {
return &Client{proxy: strings.TrimSpace(proxy), creds: map[string]string{}, locks: map[string]*sync.Mutex{}}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
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
}
// ---------------------------------------------------------------------------
// Credential helpers
// ---------------------------------------------------------------------------
type credential struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
// Email is the real account email — supplied at import, used for display and
// (pool,email) dedup. It is NOT in the JWT (which only carries userId), so it
// must be carried across refreshes (the refresh response omits it).
Email string `json:"email,omitempty"`
// ParentID is a canvas node the account OWNS, used as the generation's
// parent_id. Imagine rejects any parent the account doesn't own ("user does
// not have access to parent asset") and silently orphans a parent-less
// generation (charged but never produced) — so it's supplied at import and
// carried across refreshes.
ParentID string `json:"parentId,omitempty"`
}
func parseCred(s string) (credential, bool) {
var cr credential
if json.Unmarshal([]byte(strings.TrimSpace(s)), &cr) != nil {
return cr, false
}
if strings.TrimSpace(cr.Token) == "" || strings.TrimSpace(cr.RefreshToken) == "" {
return cr, false
}
return cr, true
}
func buildCred(token, refresh, email, parentID string) string {
b, _ := json.Marshal(credential{
Token: strings.TrimSpace(token),
RefreshToken: strings.TrimSpace(refresh),
Email: strings.TrimSpace(email),
ParentID: strings.TrimSpace(parentID),
})
return string(b)
}
// ParentIDFromCred returns the canvas parent node id supplied at import.
func ParentIDFromCred(cred string) string {
cr, ok := parseCred(cred)
if !ok {
return ""
}
return strings.TrimSpace(cr.ParentID)
}
func looksLikeJWT(s string) bool {
return len(strings.Split(strings.TrimSpace(s), ".")) == 3
}
// IsImagineToken reports whether a pasted credential is an Imagine.art account:
// a JSON object carrying a non-empty token + refreshToken that both look like
// JWTs. Distinguishes it from adobe/leonardo/krea cookies.
func IsImagineToken(value string) bool {
cr, ok := parseCred(value)
if !ok {
return false
}
return looksLikeJWT(cr.Token) && looksLikeJWT(cr.RefreshToken)
}
// jwtClaims base64url-decodes the JWT payload (segment 1) into a claims map.
func jwtClaims(token string) map[string]any {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) < 2 {
return nil
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
// tolerate padded variants
if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
return nil
}
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
return nil
}
return m
}
func userIDFromToken(token string) string {
claims := jwtClaims(token)
if claims == nil {
return ""
}
if v := strings.TrimSpace(stringValue(claims["userId"])); v != "" {
return v
}
return strings.TrimSpace(stringValue(claims["sub"]))
}
func tokenExp(token string) int64 {
claims := jwtClaims(token)
if claims == nil {
return 0
}
return toInt64(claims["exp"])
}
// EmailFromCred returns the real account email supplied at import; if absent it
// falls back to the JWT userId so (pool,email) dedup still has a stable key.
func EmailFromCred(cred string) string {
cr, ok := parseCred(cred)
if !ok {
return ""
}
if e := strings.TrimSpace(cr.Email); e != "" {
return e
}
return userIDFromToken(cr.Token)
}
// UserIDFromCred returns the JWT userId (== org_id used for credit/generation).
func UserIDFromCred(cred string) string {
cr, ok := parseCred(cred)
if !ok {
return ""
}
return userIDFromToken(cr.Token)
}
// ---------------------------------------------------------------------------
// Refresh
// ---------------------------------------------------------------------------
// RefreshIfNeeded returns a credential whose access token is still valid: if the
// stored one is (near) expired it spends the refreshToken to mint a fresh pair
// and rebuilds the credential. Returns (cred, changed, err); changed=true means
// the caller must persist the new credential (both tokens rotate). ErrAuth means
// the refreshToken is dead → the account is gone.
func (c *Client) RefreshIfNeeded(ctx context.Context, cred string) (string, bool, error) {
cr, ok := parseCred(cred)
if !ok {
return cred, false, nil // unparseable — let the downstream call surface the error
}
userID := userIDFromToken(cr.Token)
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.creds[userID]
c.mu.Unlock()
if cc, ok := parseCred(cached); ok && tokenExp(cc.Token)-refreshLeadSeconds > now {
return cached, cached != cred, nil
}
}
if tokenExp(cr.Token)-refreshLeadSeconds > now {
return cred, false, nil // still valid
}
respBody, status, err := c.refreshPost(ctx, cr.RefreshToken)
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 rb struct {
Result struct {
SessionToken string `json:"sessionToken"`
RefreshToken string `json:"refreshToken"`
} `json:"result"`
}
if json.Unmarshal(respBody, &rb) != nil || strings.TrimSpace(rb.Result.SessionToken) == "" {
return "", false, ErrAuth
}
newRefresh := rb.Result.RefreshToken
if strings.TrimSpace(newRefresh) == "" {
newRefresh = cr.RefreshToken // some responses may omit it — keep the old one
}
newCred := buildCred(rb.Result.SessionToken, newRefresh, cr.Email, cr.ParentID)
if userID != "" {
c.mu.Lock()
c.creds[userID] = newCred
c.mu.Unlock()
}
return newCred, 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
}
req, err := http.NewRequest(http.MethodPost, authBase+"/apis/v1/auth/other/refresh/web", nil)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"application/json, text/plain, */*"},
"authorization": {"Bearer " + refreshToken},
"origin": {webOrigin},
"referer": {webOrigin + "/"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "authorization", "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
}
// ---------------------------------------------------------------------------
// Credits
// ---------------------------------------------------------------------------
// FetchCreditsBalance reads the account's credit balance via /v1/credit.
// remaining is the `total` field. 401/403 → ErrAuth (token dead). Returns the
// normalized map shared by all providers.
func (c *Client) FetchCreditsBalance(ctx context.Context, cred string) (map[string]any, error) {
cr, ok := parseCred(cred)
if !ok {
return unknownBalance("bad credential"), nil
}
userID := userIDFromToken(cr.Token)
body, status, err := c.apiGet(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID)
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
}
var cb struct {
Status string `json:"status"`
Total int `json:"total"`
}
if err := json.Unmarshal(body, &cb); err != nil {
return unknownBalance("non-json"), nil
}
return map[string]any{
"remaining": cb.Total,
"used": nil,
"total": nil,
"unknown": false,
"error": nil,
"email": emptyStringNil(EmailFromCred(cred)),
}, nil
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
func (c *Client) apiGet(ctx context.Context, token, url string) ([]byte, int, error) {
client, err := c.newTLSClient()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"application/json, text/plain, */*"},
"authorization": {"Bearer " + token},
"origin": {webOrigin},
"referer": {webOrigin + "/"},
"user-agent": {userAgent},
http.HeaderOrderKey: {
"accept", "authorization", "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
}
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...)
}
// ---------------------------------------------------------------------------
// Small util
// ---------------------------------------------------------------------------
func uuid4() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
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 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 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
}