Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b9e570c2 | ||
|
|
76510d34c9 | ||
|
|
1753e03898 | ||
|
|
d37ca69f77 | ||
|
|
0373aa2d03 | ||
|
|
e8e5527e63 | ||
|
|
58ee41a4f8 | ||
|
|
c60ba294e1 | ||
|
|
edd5253529 | ||
|
|
a9640ff059 | ||
|
|
e509584a77 | ||
|
|
ab9b63f5cb | ||
|
|
aeb94dd11c | ||
|
|
b700294ad3 | ||
|
|
df6ef5ad22 | ||
|
|
682960de26 | ||
|
|
71c0371c31 | ||
|
|
c78b01d988 | ||
|
|
6c6e5add03 | ||
|
|
971d47c661 | ||
|
|
c49125b38f | ||
|
|
af7e09cf21 | ||
|
|
0fe1706ef9 | ||
|
|
3d5be149e3 | ||
|
|
98d1124f66 | ||
|
|
d49207b8f2 | ||
|
|
539c251f9f | ||
|
|
cce126cd52 | ||
|
|
6372b7cb8b | ||
|
|
3a18406b23 | ||
|
|
f65ce5c765 | ||
|
|
5e42db52b9 | ||
|
|
f6e916c985 | ||
|
|
509b0d41c6 | ||
|
|
4d31219368 | ||
|
|
8b85d5623a | ||
|
|
2fa0324a66 | ||
|
|
5ddce37ee6 | ||
|
|
c5cc2e9086 | ||
|
|
a1f61d548d | ||
|
|
89a9d76b3e | ||
|
|
8fd49db8c2 | ||
|
|
466507b388 | ||
|
|
4632b1ab04 | ||
|
|
72d4eec203 | ||
|
|
5226e11cfc | ||
|
|
b76e6ab717 | ||
|
|
abd7b5c23b | ||
|
|
b4efe6b192 | ||
|
|
ecdbc0b281 | ||
|
|
b17cc3e9c8 | ||
|
|
effe76d83f | ||
|
|
e7a4613bac | ||
|
|
1fee3e5461 | ||
|
|
0732dfddef | ||
|
|
ab8ddc8354 | ||
|
|
e69455fbfc | ||
|
|
1be1b00511 | ||
|
|
babfc71f13 | ||
|
|
ae49b95e0f | ||
|
|
33b39006c0 | ||
|
|
62365568e9 | ||
|
|
1f329bfbe1 | ||
|
|
c96f7ac3ea | ||
|
|
39dcfc96c8 | ||
|
|
d8a3003b69 | ||
|
|
a880fc3161 | ||
|
|
efe2153446 | ||
|
|
daf4605475 | ||
|
|
6fa8352455 | ||
|
|
7ff87aa5d9 | ||
|
|
e5f5b82dc6 | ||
|
|
e49fc1fc89 | ||
|
|
3d8c809393 | ||
|
|
6b4c90f876 | ||
|
|
fe78a55fe4 | ||
|
|
db5c77851d | ||
|
|
00109f83a2 | ||
|
|
bb715e2288 | ||
|
|
5c5249cb70 | ||
|
|
cc9929c716 | ||
|
|
d4adc0b6aa | ||
|
|
5dc334062e | ||
|
|
655531eaca | ||
|
|
d934dbe419 | ||
|
|
af5bf84582 | ||
|
|
90a34276ef | ||
|
|
cbdde40172 | ||
|
|
2137f9565b | ||
|
|
23bef52290 | ||
|
|
ec5dd9adf7 | ||
|
|
256eeb3234 | ||
|
|
c559e712b6 | ||
|
|
c429209e50 | ||
|
|
5a0cbeb429 | ||
|
|
2e5a1e2e72 | ||
|
|
9e69dbfc70 | ||
|
|
0631849c36 | ||
|
|
6354e629ad | ||
|
|
067ee50fb7 | ||
|
|
91e6881623 | ||
|
|
27d58cfb5d | ||
|
|
d0506b0d2e | ||
|
|
602664c122 | ||
|
|
b39173643d | ||
|
|
313d54a5ec |
@@ -64,3 +64,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# fast-deploy build artifact
|
# fast-deploy build artifact
|
||||||
/frontend/dist.tar.gz
|
/frontend/dist.tar.gz
|
||||||
|
|
||||||
|
# ad-hoc probe/test scratch files (cookies, tokens, live probes)
|
||||||
|
_tmp_*
|
||||||
|
_arp_*.txt
|
||||||
|
zz_live_probe_test.go
|
||||||
|
|||||||
@@ -1,305 +1,170 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" />
|
<img src="frontend/public/favicon.svg" width="84" alt="image2api" />
|
||||||
|
|
||||||
<h1>image2api</h1>
|
<h1>image2api</h1>
|
||||||
|
|
||||||
**Multi-provider AI image / video generation gateway — one OpenAI-compatible API, seven platforms aggregated, a ready-to-run operations system**
|
**vivid-protocol AI image / video generation gateway — channel-based upstream routing, a ready-to-run operations system**
|
||||||
|
|
||||||
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
|
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
|
||||||
|
|
||||||
[简体中文](README.md) | **English**
|
[简体中文](README.md) | **English**
|
||||||
|
|
||||||
[](https://vividai.run)
|
[](https://vividai.run)
|
||||||
|
|
||||||
[](https://go.dev)
|
[](https://go.dev)
|
||||||
[](https://vuejs.org)
|
[](https://vuejs.org)
|
||||||
[](#-deployment)
|
[](#-deployment)
|
||||||
[](#-openai-compatible-api)
|
|
||||||
[](#-deployment)
|
|
||||||
[](#-supported-models--providers)
|
|
||||||
[](#-deployment)
|
[](#-deployment)
|
||||||
[](#-license)
|
[](#-license)
|
||||||
|
|
||||||
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-openai-compatible-api) · [Community](#-community--contact)
|
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-vivid-protocol-api)
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<img src="docs/screenshots/playground.png" alt="image2api — playground" width="860" />
|
<img src="docs/screenshots/home.png" alt="image2api — landing" width="860" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Table of Contents
|
|
||||||
|
|
||||||
- [Overview](#-overview)
|
|
||||||
- [Screenshots](#-screenshots)
|
|
||||||
- [Features](#-features)
|
|
||||||
- [Supported Models / Providers](#-supported-models--providers)
|
|
||||||
- [OpenAI-Compatible API](#-openai-compatible-api)
|
|
||||||
- [Deployment](#-deployment)
|
|
||||||
- [Tech Stack](#-tech-stack)
|
|
||||||
- [Repository Layout](#-repository-layout)
|
|
||||||
- [Roadmap](#-roadmap)
|
|
||||||
- [Community / Contact](#-community--contact)
|
|
||||||
- [License](#-license)
|
|
||||||
|
|
||||||
## ✨ Overview
|
|
||||||
|
|
||||||
**image2api** wraps the image / video capabilities of Adobe Firefly, OpenAI, Runway, Grok, Leonardo, Krea and Imagine into **a single OpenAI-compatible API**. Behind it, multi-account pools are scheduled automatically — out of quota → switch account, auth expired → refresh or kill, transient errors → retry, tokens proactively renewed before they expire — to deliver a stable service.
|
|
||||||
|
|
||||||
It's more than an API proxy: it ships with **credit billing, CDK top-ups, referral rewards, a user system, an admin console, and a modern generation frontend**, so a single command turns it into a fully operational AI generation site — the author's live instance **[Vivid AI · vividai.run](https://vividai.run)** (brand) is built on this project.
|
|
||||||
|
|
||||||
> 💡 Both frontend and backend are **fully open-source** (MIT) — Go + Vue 3, free to fork and self-host.
|
|
||||||
|
|
||||||
**At a glance** 🔌 OpenAI-compatible · 🤖 7 platforms, 10+ models · 🔁 auto failover / token keep-alive · 💳 credits + agent pricing · 🎨 generation frontend + admin console · 🐳 one-command deploy (bring your own TLS proxy)
|
|
||||||
|
|
||||||
## 🖼️ Screenshots
|
## 🖼️ Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<sub>🎨 Modern generation frontend · light / dark admin console · data-driven ops dashboard</sub>
|
<sub>✨ Vivid-style light UI · channel routing · data-driven ops dashboard</sub>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="Dashboard" /></td>
|
<td width="33%"><img src="docs/screenshots/playground.png" alt="Studio" /></td>
|
||||||
<td width="50%"><img src="docs/screenshots/models.png" alt="Models" /></td>
|
<td width="33%"><img src="docs/screenshots/dashboard.png" alt="Dashboard" /></td>
|
||||||
|
<td width="33%"><img src="docs/screenshots/models.png" alt="Models" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>📊 Dashboard</b><br/><sub>Users / volume / provider health / 24h trend</sub></td>
|
<td align="center"><b>🎨 Studio</b><br/><sub>image + video · reference uploads · live pricing</sub></td>
|
||||||
<td align="center"><b>🧩 Model management</b><br/><sub>Per-model capabilities, pricing & weight</sub></td>
|
<td align="center"><b>📊 Dashboard</b><br/><sub>users / volume / channel health / 24h trend</sub></td>
|
||||||
|
<td align="center"><b>🧩 Models</b><br/><sub>channel bindings + weights · standard + reseller pricing</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%"><img src="docs/screenshots/accounts.png" alt="Accounts" /></td>
|
<td width="33%"><img src="docs/screenshots/channels.png" alt="Channels" /></td>
|
||||||
<td width="50%"><img src="docs/screenshots/logs.png" alt="Logs" /></td>
|
<td width="33%"><img src="docs/screenshots/users.png" alt="Users" /></td>
|
||||||
|
<td width="33%"><img src="docs/screenshots/logs.png" alt="Logs" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>🔑 Account pools</b><br/><sub>Multi-account pools · weight / concurrency · CRUD</sub></td>
|
<td align="center"><b>🔌 Channels</b><br/><sub>save-to-sync upstream models · live balance · failover</sub></td>
|
||||||
<td align="center"><b>📜 Call logs</b><br/><sub>Success / failure / in-progress · prompts & latency</sub></td>
|
<td align="center"><b>👥 Users</b><br/><sub>roles / concurrency groups / credits · batch ops</sub></td>
|
||||||
|
<td align="center"><b>📜 Logs</b><br/><sub>success / failed / running · prompts and latency kept</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
## ✨ Introduction
|
||||||
|
|
||||||
|
**image2api** is a homogeneous gateway. Upstream, it connects any number of **vivid-protocol (vividai) instances** as "channels" and routes jobs across them. Downstream, it exposes the same **vivid-protocol API** to callers: `POST /v1/generate` (NDJSON stream) · `POST /v1/uploads` (presigned upload) · `GET /v1/models` · `GET /v1/balance`. No built-in provider code, no account pools — an upstream is just another vividai instance, so gateways can be layered arbitrarily.
|
||||||
|
|
||||||
|
It is more than a gateway: built-in **credit billing, CDK top-ups, online payment (epay), invite rewards, reseller pricing, user accounts, an admin console and a modern generation UI** — one command turns it into a production AI generation site. The author's live instance **[Vivid AI · vividai.run](https://vividai.run)** runs on this project.
|
||||||
|
|
||||||
|
> 💡 Frontend + backend fully open source (MIT). Go + Vue 3.
|
||||||
|
|
||||||
|
**One-liner** 🔁 vivid-protocol channel mesh · 📦 save-a-channel syncs all models · 💳 credits + payment + reseller pricing · 🧩 concurrency groups · 🎨 generation UI + admin console · 🐳 one-command deploy
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
#### 🎨 Generation
|
#### 🎨 Generation
|
||||||
- Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference)
|
- Image + video in one place, with multi-modal references (images / first-last frames / video / audio) per model capability
|
||||||
- Multiple resolutions (images 1K / 2K / 4K · videos 720p / 1080p), aspect ratios and video durations — configured and priced per model
|
- Multiple resolutions (1K/2K/4K images · 720p/1080p/768p video), aspect ratios, discrete or per-second video durations — all per-model configurable and priced
|
||||||
- 7 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes
|
- **Priced = listed**: no enable switch — a model goes public once every tier is priced and an enabled channel serves it
|
||||||
- **Model aliases**: one model can expose multiple public ids — API calls with any alias resolve to it
|
- **Model aliases**: one upstream model can map to several public ids
|
||||||
- **De-AI fingerprint** (optional): one-click toggle on the playground — generated images get anti-AI-detection post-processing (subtle detail jitter + metadata stripping), charged as a per-tier surcharge (defaults 1K+1 / 2K+2 / 4K+3 credits, admin-configurable, can be disabled globally); processed works carry a "de-AI" badge across the playground, gallery, logs and admin image manager
|
- Optional **de-AI** post-processing on generated images (per-tier surcharge)
|
||||||
|
|
||||||
#### 🔌 OpenAI Compatible
|
#### 🔁 Channel routing
|
||||||
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` · balance `/v1/user/balance` (remaining / cumulative used)
|
- **Save = full sync**: storing a channel pulls the upstream `/v1/models`, binds everything, unbinds what disappeared and deletes orphans
|
||||||
- **Strict OpenAI params**: `size` drives **both aspect ratio + resolution tier** (images by long edge → 1K/2K/4K, videos by short edge → 720p/1080p) — just swap `base_url` + `api_key` into an existing OpenAI SDK
|
- **Weight + concurrency-aware** dispatch with failover; upstream 4xx rejections pass through in Chinese, transient errors move to the next channel
|
||||||
- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table
|
- Channel keys stored AES-GCM encrypted (`CHANNEL_SECRET`); upstream balance probed live
|
||||||
|
|
||||||
#### 🔁 Account Pools + Smart Failover
|
#### 🔌 vivid-protocol API
|
||||||
- Round-robin scheduling across the pool; one bad account doesn't break the whole
|
- `POST /v1/generate` — NDJSON stream: first frame returns `jobId`, ~25s blank-line heartbeats, terminal `succeeded`/`failed`
|
||||||
- **Out of quota → switch** · **auth expired → refresh & retry / kill** · **transient → retry same account ×3** · **bad params → fail fast**
|
- `POST /v1/uploads` — presigned PUT direct-to-storage for reference files
|
||||||
- **Pre-deducted credits**: atomic debit before generation, auto-refunded on failure, no over-spend under concurrency
|
- `GET /v1/models` — bare array with capability specs · `GET /v1/balance` — balance
|
||||||
|
- **jobId reconnect**: resubmit with only `jobId` to resume a dropped stream — no duplicate job or charge; pending jobs resume after a server restart too
|
||||||
|
- Unified errors `{"error":{"code","message"}}` (4001 rejected / 4002 insufficient / 4011 unauthorized / 4293 concurrency / 5001 server); API keys are `vk_` + 48 hex
|
||||||
|
|
||||||
#### 🔐 Automatic Token Keep-alive
|
#### 💳 Billing & operations
|
||||||
- Single-use rotating tokens (Krea / Imagine) are **renewed proactively 10 minutes before expiry**; new tokens persisted automatically
|
- Credits (pre-charge + auto-refund), per model/tier/duration pricing; de-AI surcharge per tier
|
||||||
- Adobe cookies exchanged for fresh tokens on a schedule; bare JWTs killed on expiry
|
- **Reseller pricing**: reseller role + per-model reseller prices (API calls included)
|
||||||
- Daily quota recovered at each provider's reset time, then re-probed for the real balance
|
- **Online payment (epay)**: WeChat/Alipay QR, fixed + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callbacks
|
||||||
|
- **CDK codes** · invite rewards · email register / verification / password reset
|
||||||
|
- **Concurrency groups**: per-group in-flight caps across UI + API (`0` = unlimited)
|
||||||
|
- Roles: user / reseller / admin
|
||||||
|
|
||||||
#### 💳 Billing & Operations
|
#### 🛠️ Admin console
|
||||||
- Credit-based (**pre-deduct + refund on failure**), priced per model / resolution / duration; de-AI fingerprint adds a per-tier surcharge
|
- Dashboard (trends / DAU / top failures / top spenders)
|
||||||
- **Agent pricing**: a user can be set as an "agent" role and models can carry agent prices; agent users (including their API key calls) are billed at the agent price, falling back to the normal price when unset
|
- **Channels** (save-to-sync, upstream balance, enable, concurrency, weight) · models (standard + reseller pricing, aliases, read-only listable state) · concurrency groups · orders · global logs · users · CDK · media manager · showcase · announcements · site config
|
||||||
- **Online top-up (易支付 / epay)**: WeChat / Alipay QR, preset + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callback auto-credits; cumulative top-up tracked
|
- **Banned words**: prompt hits rejected, per-word / per-user trigger stats
|
||||||
- **CDK redeem codes** · **referral rewards** · email sign-up / verification code / password reset
|
|
||||||
- **Concurrency groups**: cap a user's simultaneous generations (playground + API key combined, `0` = unlimited), self-healing Redis counters, new users auto-join the default group
|
|
||||||
- Three roles: regular user / agent / admin (single)
|
|
||||||
|
|
||||||
#### 🖥️ User Frontend (Vue 3)
|
**🧰 Engineering**: S3/RustFS media behind an authenticated proxy + retention cleanup · presigned reference uploads · self-healing maintenance (stale-job resume/refund, expired file pruning) · one-command Docker deploy (TLS is your reverse proxy's job).
|
||||||
- Playground · creations gallery · generation logs (with failure reasons / source tags)
|
|
||||||
- Gallery **multi-select batch ops**: select page / bulk delete (videos take their frame stills along) / bulk download (multiple files auto-packed into a zip, fetched concurrently)
|
|
||||||
- Lightbox preview with built-in **copy original / download / close** buttons; one-click copy of the original image from cards
|
|
||||||
- **Top-up · Orders** (recharge history / resume unpaid) · API docs · API key management · referral · about, light / dark theme
|
|
||||||
- **In-app announcements**: a Markdown notice pops up after login and re-shows whenever its content changes
|
|
||||||
|
|
||||||
#### 🛠️ Admin Console
|
## 🔌 Channel architecture
|
||||||
- Overview dashboard (trends / DAU / top failures / top spenders)
|
|
||||||
- Model management (normal + agent price + aliases) · account management (bulk import / dedup / quota) · **concurrency groups** · **order management** (filter / search / paginate) · site-wide logs · user management (set as agent / assign concurrency group / view cumulative top-up / banned-word hits) · CDK · image management (multi-select bulk delete / zip download) · showcase · **announcements** · site config (incl. epay, de-AI fingerprint toggle & surcharge pricing)
|
|
||||||
- **Banned words**: add / remove words in the console (paginated + multi-select bulk delete); prompts containing a banned word are rejected outright (playground + API, case-insensitive), with per-word / per-user hit counters
|
|
||||||
|
|
||||||
**🧰 Engineering highlights**: tls-client (Chrome JA3/JA4 fingerprint) reliably passes Cloudflare · media stored in S3/RustFS, served through an authenticated proxy with retention cleanup · self-healing maintenance loop (quota recovery / credential refresh / orphan-job cleanup with refunds) · one-command Docker deploy (TLS via your own reverse proxy).
|
```
|
||||||
|
caller ──vivid protocol──▶ image2api ──vivid protocol──▶ upstream vividai A (weight w, conc c)
|
||||||
## 🤖 Supported Models / Providers
|
(this host) ├─ upstream vividai B
|
||||||
|
└─ …any number
|
||||||
| Provider | Models (examples) | Type |
|
|
||||||
|---|---|---|
|
|
||||||
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
|
|
||||||
| **OpenAI** | gpt-image-2 | Image |
|
|
||||||
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | Video / Image |
|
|
||||||
| **Grok (console.x.ai)** | grok-video (imagine text/image-to-video), grok-image / grok-image-quality (imagine text/image-to-image) | Video / Image |
|
|
||||||
| **Leonardo.ai** | seedream-4.5 | Image |
|
|
||||||
| **Krea.ai** | flux-klein-2 | Image |
|
|
||||||
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image |
|
|
||||||
|
|
||||||
> Models are enabled and priced dynamically from the admin console — add or remove anytime.
|
|
||||||
|
|
||||||
## 🔌 OpenAI-Compatible API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Text-to-image — pure OpenAI params: size drives both aspect ratio + tier (long edge <1800→1K / <3500→2K / ≥3500→4K)
|
|
||||||
curl https://your-domain/v1/images/generations \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-image-2",
|
|
||||||
"prompt": "a cute cat on a desk, studio lighting",
|
|
||||||
"size": "2048x2048"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Image-to-image — multipart reference upload (multiple via image[])
|
|
||||||
curl https://your-domain/v1/images/edits \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-F model="seedream-4.5" -F prompt="make it cyberpunk" -F image=@input.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Images return OpenAI-style `{ "created": ..., "data": [{ "b64_json": "..." }] }` (raw base64, no `data:` prefix, nothing stored server-side). **Video** is async: `POST /v1/videos` → poll `GET /v1/videos/{id}` until `completed` → `GET /v1/videos/{id}/content` for the mp4. Full parameters are documented on the in-app **/docs** page.
|
- A channel = a homogeneous upstream: `base_url` + `vk_` key + enable switch + concurrency cap + routing weight
|
||||||
|
- Models come from the upstream `/v1/models` sync; locally they only get aliases and pricing; **one model can fail over across channels**
|
||||||
|
- This service speaks the same protocol externally — it can itself be mounted as someone's channel, infinitely nestable
|
||||||
|
- **No OpenAI-protocol upstreams**: a channel must be a vivid-protocol instance
|
||||||
|
|
||||||
|
## 📡 vivid-protocol API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# generate — NDJSON stream; first frame returns jobId
|
||||||
|
curl -N https://your-domain/v1/generate \
|
||||||
|
-H "Authorization: Bearer vk_xxxxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
|
||||||
|
# → {"jobId":"…","status":"running"} …heartbeats… {"status":"succeeded","outputs":[{"url":"…"}]}
|
||||||
|
|
||||||
|
# reconnect — resubmit with only jobId, no duplicate job
|
||||||
|
curl -N https://your-domain/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<jobId>"}'
|
||||||
|
|
||||||
|
# reference files — presigned upload session, then PUT the bytes
|
||||||
|
curl https://your-domain/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
|
||||||
|
|
||||||
|
curl https://your-domain/v1/models -H "Authorization: Bearer vk_xxxx"
|
||||||
|
curl https://your-domain/v1/balance -H "Authorization: Bearer vk_xxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
Full parameters, the error-code table and Python examples live on the in-app **/docs** page.
|
||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
> Domain + HTTPS are handled by your own reverse proxy (this project issues no certificates).
|
**Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx listens on container **HTTP :2000**); point your reverse proxy at `http://<host>:2000`.
|
||||||
|
|
||||||
**Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx serving **HTTP on container port 2000**); point your reverse proxy at `http://<host>:2000` (port via `WEB_PORT`; edit the values (passwords / keys / `CORS_ORIGINS`, and `COOKIE_SECURE=true` when your proxy serves HTTPS) directly in `docker-compose.yml`).
|
> ⚠️ Change the secrets in `docker-compose.yml` before deploying — especially **`CHANNEL_SECRET`** (encrypts stored channel keys; rotating it makes saved keys undecryptable).
|
||||||
|
|
||||||
Or **build from source** — bring your own **PostgreSQL · Redis · RustFS (or any S3) · reverse proxy**:
|
Manual build needs PostgreSQL · Redis · RustFS (or any S3) · a reverse proxy — see `backend/.env.example` for all env vars.
|
||||||
|
|
||||||
```bash
|
After deploy: admin → Channels → add channel (upstream URL + `vk_` key) → models auto-sync → price them in 模型管理 → done.
|
||||||
# 1. Create an empty database (the backend auto-migrates on start)
|
|
||||||
createdb vivid_ai
|
|
||||||
|
|
||||||
# 2. Configure and build the backend from source
|
## 🧱 Stack
|
||||||
cat > backend/.env <<'EOF'
|
|
||||||
APP_ENV=production
|
|
||||||
HTTP_ADDR=127.0.0.1:6666
|
|
||||||
POSTGRES_DSN=host=127.0.0.1 user=postgres password=YOUR_PASSWORD dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
|
|
||||||
REDIS_ADDR=127.0.0.1:6379
|
|
||||||
RUSTFS_ENDPOINT=http://127.0.0.1:9000
|
|
||||||
RUSTFS_BUCKET=vivid-ai
|
|
||||||
RUSTFS_ACCESS_KEY=YOUR_AK
|
|
||||||
RUSTFS_SECRET_KEY=YOUR_SK
|
|
||||||
CORS_ORIGINS=https://your-domain
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
EOF
|
|
||||||
cd backend && go build -o bin/api ./cmd/api && ./bin/api # listens on 127.0.0.1:6666
|
|
||||||
|
|
||||||
# 3. Build the frontend (output in frontend/dist)
|
| Layer | Tech |
|
||||||
cd frontend && npm install && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Nginx reverse proxy (issue the certificate yourself with certbot / acme.sh):
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name your-domain;
|
|
||||||
ssl_certificate /path/fullchain.pem;
|
|
||||||
ssl_certificate_key /path/privkey.pem;
|
|
||||||
root /path/to/frontend/dist;
|
|
||||||
index index.html;
|
|
||||||
client_max_body_size 50m;
|
|
||||||
proxy_read_timeout 600s; # video generation can take a while
|
|
||||||
|
|
||||||
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
|
||||||
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
|
|
||||||
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
|
|
||||||
location ^~ /images/ { proxy_pass http://127.0.0.1:6666; }
|
|
||||||
location = /health { proxy_pass http://127.0.0.1:6666; }
|
|
||||||
location ^~ /v1/ { proxy_pass http://127.0.0.1:6666; add_header Cache-Control "no-store" always; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> See `backend/.env.example` for the full set of environment variables.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 🧱 Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | Go · gin · gorm (PostgreSQL) · go-redis · tls-client (Chrome fingerprint) |
|
| Backend | Go · gin · gorm (PostgreSQL) · go-redis |
|
||||||
| Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
| Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
||||||
| Infrastructure | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
|
| Infra | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
|
||||||
|
|
||||||
## 📦 Repository Layout
|
## 💬 Contact
|
||||||
|
|
||||||
```
|
|
||||||
backend/ Backend source (Go)
|
|
||||||
├── cmd/
|
|
||||||
│ ├── api/ Service entry point (main)
|
|
||||||
│ └── marklabel/ Ops helper (mark accounts on demand)
|
|
||||||
├── internal/
|
|
||||||
│ ├── bootstrap/ App wiring, scheduled maintenance startup
|
|
||||||
│ ├── config/ Env-var configuration loading
|
|
||||||
│ ├── http/
|
|
||||||
│ │ ├── handler/ HTTP handlers (v1-compatible API, admin, auth…)
|
|
||||||
│ │ ├── middleware/ Auth / request-id middleware
|
|
||||||
│ │ └── router/ Route registration
|
|
||||||
│ ├── model/ GORM data models
|
|
||||||
│ ├── provider/ Upstream provider clients
|
|
||||||
│ │ ├── adobe/ Adobe Firefly (tls-client fingerprint)
|
|
||||||
│ │ ├── chatgpt/ OpenAI (incl. PoW / turnstile)
|
|
||||||
│ │ ├── runway/ Runway video + Nano Banana image
|
|
||||||
│ │ ├── grok/ Grok (console.x.ai DPoP, video / image; account state via grok.com)
|
|
||||||
│ │ ├── leonardo/ Leonardo
|
|
||||||
│ │ ├── krea/ Krea
|
|
||||||
│ │ ├── imagine/ Imagine.art
|
|
||||||
│ │ ├── custom/ Custom upstream (OpenAI-compatible v1, routed by id)
|
|
||||||
│ │ └── epay/ 易支付 / epay (mapi order + MD5-verified callback, top-ups)
|
|
||||||
│ ├── repo/ Data-access layer (users / models / accounts / logs / CDK / orders / concurrency groups…)
|
|
||||||
│ ├── service/ Business logic (scheduling, billing, account pools, keep-alive, maintenance)
|
|
||||||
│ └── storage/ RustFS / S3 media storage
|
|
||||||
├── Dockerfile Multi-stage build (compile source → slim runtime image)
|
|
||||||
└── .env.example Backend env-var template
|
|
||||||
|
|
||||||
frontend/ Frontend source (Vue 3 + Vite)
|
|
||||||
├── src/
|
|
||||||
│ ├── views/ Pages (playground / accounts / models / users / concurrency / orders / logs / overview / top-up / settings…)
|
|
||||||
│ ├── components/ Reusable components (modals / selectors / lightbox…)
|
|
||||||
│ ├── layouts/ Public / admin layouts
|
|
||||||
│ ├── utils/ Utility functions
|
|
||||||
│ └── api.js · auth.js … API client, auth, theme, credits, etc.
|
|
||||||
├── Dockerfile Nginx static hosting (HTTP :2000) + API proxy
|
|
||||||
└── default.conf.template Nginx site template (reverse proxy + caching)
|
|
||||||
|
|
||||||
docker-compose.yml Docker orchestration (Postgres / Redis / RustFS / backend / frontend)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
|
||||||
|
|
||||||
- [ ] More upstream providers
|
|
||||||
- [ ] Usage analytics / export
|
|
||||||
- [ ] Multi-language UI (i18n)
|
|
||||||
- [ ] Webhook / async callbacks
|
|
||||||
|
|
||||||
## 💬 Community / Contact
|
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 🌐 Website | **[vividai.run](https://vividai.run)** |
|
| 🌐 Site | **[vividai.run](https://vividai.run)** |
|
||||||
| 👥 QQ Group | **1106849765** · [Join](https://qm.qq.com/q/976LeMFoHu) |
|
| 👥 QQ group | **1106849765** · [join](https://qm.qq.com/q/976LeMFoHu) |
|
||||||
| 🐧 QQ | **1114639355** · [Add](https://qm.qq.com/q/ItgCcNA7ac) |
|
| 🐧 QQ | **1114639355** · [add](https://qm.qq.com/q/ItgCcNA7ac) |
|
||||||
| 🛒 Shop | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
|
|
||||||
| ✉️ Email | vividairun@gmail.com |
|
| ✉️ Email | vividairun@gmail.com |
|
||||||
|
|
||||||
## ⭐ Star History
|
|
||||||
|
|
||||||
<!-- After creating the GitHub repo, uncomment the line below and replace OWNER with your username to show the chart: -->
|
|
||||||
<!-- [](https://star-history.com/#OWNER/image2api&Date) -->
|
|
||||||
|
|
||||||
If you find this useful, give it a ⭐ — uncomment the line above after creating the repo to show the Star History chart.
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
This project (frontend + backend) is open-source under the [MIT](LICENSE) license — free to use, modify, commercialize and redistribute.
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
If this project helps you, a ⭐ Star is much appreciated!
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" />
|
<img src="frontend/public/favicon.svg" width="84" alt="image2api" />
|
||||||
|
|
||||||
<h1>image2api</h1>
|
<h1>image2api</h1>
|
||||||
|
|
||||||
**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合七大平台,开箱即用的运营系统**
|
**vivid 协议 AI 生图 / 生视频网关 —— 渠道化接入同构上游,开箱即用的运营系统**
|
||||||
|
|
||||||
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
|
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
|
||||||
|
|
||||||
@@ -15,17 +15,14 @@
|
|||||||
[](https://go.dev)
|
[](https://go.dev)
|
||||||
[](https://vuejs.org)
|
[](https://vuejs.org)
|
||||||
[](#-部署)
|
[](#-部署)
|
||||||
[](#-openai-兼容-api)
|
|
||||||
[](#-部署)
|
|
||||||
[](#-支持的模型--供应商)
|
|
||||||
[](#-部署)
|
[](#-部署)
|
||||||
[](#-license)
|
[](#-license)
|
||||||
|
|
||||||
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-openai-兼容-api) · [交流群](#-交流--联系)
|
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-vivid-协议-api) · [交流群](#-交流--联系)
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<img src="docs/screenshots/playground.png" alt="image2api — 画图台" width="860" />
|
<img src="docs/screenshots/home.png" alt="image2api — 首页" width="860" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,46 +33,49 @@
|
|||||||
- [简介](#-简介)
|
- [简介](#-简介)
|
||||||
- [界面预览](#-界面预览)
|
- [界面预览](#-界面预览)
|
||||||
- [核心功能](#-核心功能)
|
- [核心功能](#-核心功能)
|
||||||
- [支持的模型 / 供应商](#-支持的模型--供应商)
|
- [渠道架构](#-渠道架构)
|
||||||
- [OpenAI 兼容 API](#-openai-兼容-api)
|
- [vivid 协议 API](#-vivid-协议-api)
|
||||||
- [部署](#-部署)
|
- [部署](#-部署)
|
||||||
- [技术栈](#-技术栈)
|
- [技术栈](#-技术栈)
|
||||||
- [仓库结构](#-仓库结构)
|
- [仓库结构](#-仓库结构)
|
||||||
- [Roadmap](#-roadmap)
|
|
||||||
- [交流 / 联系](#-交流--联系)
|
- [交流 / 联系](#-交流--联系)
|
||||||
- [License](#-license)
|
- [License](#-license)
|
||||||
|
|
||||||
## ✨ 简介
|
## ✨ 简介
|
||||||
|
|
||||||
**image2api** 把 Adobe Firefly、OpenAI、Runway、Grok、Leonardo、Krea、Imagine 等平台,以及**任意 OpenAI 兼容上游**的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 权重优先 + 并发感知、额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。
|
**image2api** 是一个同构网关:对上,把任意多个 **vivid 协议(vividai)实例**接成「渠道」统一调度;对下,对外暴露同一套 **vivid 协议 API**(`POST /v1/generate` NDJSON 流 · `POST /v1/uploads` 预签名直传 · `GET /v1/models` · `GET /v1/balance`)。没有内置供应商代码、没有账号池 —— 上游就是另一个 vividai,套娃式组网任意层叠。
|
||||||
|
|
||||||
它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。
|
它不只是 API 网关:自带**积分计费、CDK 充值、在线充值(易支付)、邀请奖励、代理价体系、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)** 即基于本项目搭建。
|
||||||
|
|
||||||
> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。
|
> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。
|
||||||
|
|
||||||
**一句话亮点** 🔌 OpenAI 兼容 · 🤖 7 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 在线充值(易支付)+ 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
|
**一句话亮点** 🔁 vivid 协议渠道组网 · 📦 保存渠道即全量同步模型 · 💳 积分 + 充值 + 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
|
||||||
|
|
||||||
## 🖼️ 界面预览
|
## 🖼️ 界面预览
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<sub>🎨 现代化画图前端 · 深 / 浅双主题管理后台 · 数据驱动的运营看板</sub>
|
<sub>✨ Vivid 风格浅色界面 · 渠道化调度 · 数据驱动的运营看板</sub>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
|
<td width="33%"><img src="docs/screenshots/playground.png" alt="画图台" /></td>
|
||||||
<td width="50%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
|
<td width="33%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
|
||||||
|
<td width="33%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / Provider 健康 / 24h 趋势</sub></td>
|
<td align="center"><b>🎨 画图台</b><br/><sub>生图 / 生视频一站式 · 参考图上传 · 实时计价</sub></td>
|
||||||
<td align="center"><b>🧩 模型管理</b><br/><sub>按模型独立配置能力、定价与权重</sub></td>
|
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / 渠道健康 / 24h 趋势</sub></td>
|
||||||
|
<td align="center"><b>🧩 模型管理</b><br/><sub>渠道绑定与权重 · 普通价 + 代理价 · 配价即上架</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%"><img src="docs/screenshots/accounts.png" alt="账号池管理" /></td>
|
<td width="33%"><img src="docs/screenshots/channels.png" alt="渠道管理" /></td>
|
||||||
<td width="50%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
|
<td width="33%"><img src="docs/screenshots/users.png" alt="用户管理" /></td>
|
||||||
|
<td width="33%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>🔑 账号池管理</b><br/><sub>多账号池 · 权重 / 并发 · 一键增删改</sub></td>
|
<td align="center"><b>🔌 渠道管理</b><br/><sub>保存即全量同步上游模型 · 余额实时探测 · 故障转移</sub></td>
|
||||||
|
<td align="center"><b>👥 用户管理</b><br/><sub>角色 / 并发组 / 积分 · 批量选中操作</sub></td>
|
||||||
<td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td>
|
<td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -83,117 +83,98 @@
|
|||||||
## 🚀 核心功能
|
## 🚀 核心功能
|
||||||
|
|
||||||
#### 🎨 生成能力
|
#### 🎨 生成能力
|
||||||
- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考)
|
- 生图 + 生视频一站式,支持**参考图 / 首末帧 / 视频 / 音频**等多模态参考(按模型能力声明下发)
|
||||||
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p)、多宽高比、视频多时长,按模型独立配置与定价
|
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p / 768p)、多宽高比、视频多时长(离散档或区间档按秒计价),按模型独立配置与定价
|
||||||
- 7 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码
|
- **配价即上架**:模型无启停开关,全部档位有价 + 绑定了启用渠道即对外可见;不定价 = 隐藏
|
||||||
- **模型别名**:同一模型可配多个对外 id,API 调用任意别名均可命中
|
- **模型别名**:同一上游模型可配多个对外 id,API 调用任意别名均可命中
|
||||||
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识
|
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理,按画质档位加收积分(后台可改价、可整体关闭)
|
||||||
|
|
||||||
#### 🔌 OpenAI 兼容
|
#### 🔁 渠道调度
|
||||||
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` · 余额 `/v1/user/balance`(剩余/累计已用)
|
- **保存渠道即全量同步**:填上游 `base_url` + `vk_` key 保存,自动拉取上游 `/v1/models` 全部入库绑定;上游消失的模型自动解绑,无其他渠道的孤儿模型自动清理
|
||||||
- **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK
|
- **权重 + 并发感知调度**:渠道可配权重(加权随机)与并发上限,逐渠道尝试、失败自动故障转移
|
||||||
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么
|
- **错误分类**:上游 4xx(参数/额度)直接透传中文拒绝理由、不重试;网络/5xx 换渠道重试
|
||||||
|
- 渠道 API Key 以 AES-GCM 加密落库(`CHANNEL_SECRET`),上游余额实时探测
|
||||||
|
|
||||||
#### 🔁 多账号池 + 智能故障转移
|
#### 🔌 vivid 协议 API
|
||||||
- 账号池调度,单账号出错不影响整体
|
- `POST /v1/generate` —— NDJSON 流式生成:首行回执 `jobId`,约 25s 空行心跳,终帧 `succeeded`/`failed`
|
||||||
- **权重优先 + 并发感知**:按账号权重从高到低调度,某账号并发满了才轮到下一个;同权重组内 round-robin 均摊。每账号并发数可配(上游账号),其余系统固定
|
- `POST /v1/uploads` —— 预签名 PUT 直传参考文件到对象存储
|
||||||
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错**
|
- `GET /v1/models` —— 裸数组,含能力规格(清晰度档位 / 比例 / 时长 / 参考文件上限)
|
||||||
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
|
- `GET /v1/balance` —— 余额查询
|
||||||
|
- **jobId 断流重连**:断流后仅带 `jobId` 重发同一请求即可续等,不重复下单、不重复扣费;进程重启后带 `jobId` 的任务也会自动续等
|
||||||
#### 🔗 自定义上游聚合(OpenAI 兼容)
|
- 统一错误包 `{"error":{"code","message"}}`(4001 参数拒绝 / 4002 积分不足 / 4011 未授权 / 4293 并发上限 / 5001 服务异常);API Key 为 `vk_` + 48 hex
|
||||||
- 把任意 **OpenAI 兼容的 v1 端点**当成一个账号接入(填 `base_url` + `key`),无需写代码
|
|
||||||
- **按 model id 自动路由**:上游声明支持哪些 id,生成该 id 时即走对应上游(可覆盖内置 provider);id 留空 = 全部
|
|
||||||
- 模型管理里自由新建自定义模型(id / 类型 / 比例 / 分辨率·价 / 时长·价 / 参考图),按本地价计费
|
|
||||||
- 调用**直连不走代理**;上游可配权重与并发,与内置池统一调度
|
|
||||||
|
|
||||||
#### 🔐 Token 自动保活
|
|
||||||
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
|
|
||||||
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
|
|
||||||
- 每日额度按平台重置时间自动恢复 + 重新探测真实余额
|
|
||||||
|
|
||||||
#### 💳 计费与运营
|
#### 💳 计费与运营
|
||||||
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分
|
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分
|
||||||
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
|
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
|
||||||
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账;累计充值可查
|
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账
|
||||||
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
|
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
|
||||||
- **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组
|
- **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组
|
||||||
- 三级角色:普通用户 / 代理 / 管理员(唯一)
|
- 三级角色:普通用户 / 代理 / 管理员
|
||||||
|
|
||||||
#### 🖥️ 用户前台(Vue 3)
|
#### 🖥️ 用户前台(Vue 3)
|
||||||
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
|
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
|
||||||
- 作品画廊**多选批量操作**:全选本页 / 批量删除(视频连带首尾帧图) / 批量下载(多个自动打包 zip,并发拉取)
|
- 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip)
|
||||||
- 预览大图内置**复制原图 / 下载 / 关闭**按钮,卡片一键复制原图
|
- **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 无限画布
|
||||||
- **充值 · 订单**(充值记录 / 未付可继续支付) · API 文档 · API Key 管理 · 邀请 · 关于,亮 / 暗主题
|
- **站内公告**:登录后自动弹出 Markdown 公告
|
||||||
- **站内公告**:登录后自动弹出 Markdown 公告,内容更新即重推
|
|
||||||
|
|
||||||
#### 🛠️ 管理后台
|
#### 🛠️ 管理后台
|
||||||
- 概览看板(趋势 / DAU / 失败 Top / 消费榜)
|
- 概览看板(趋势 / DAU / 失败 Top / 消费榜)
|
||||||
- 模型管理(普通价 + 代理价 + 别名) · 账号管理(批量导入 / 去重 / 额度) · **并发分组** · **订单管理**(筛选 / 搜索 / 分页) · 全站日志 · 用户管理(设为代理 / 分配并发组 / 看累计充值 / 违禁触发次数) · CDK · 图片管理(多选批量删除 / zip 打包下载) · 展示位 · **站点公告** · 站点配置(含易支付、去AI特征开关与附加价格)
|
- **渠道管理**(保存即同步模型 / 上游余额 / 启用开关 / 并发 / 权重)· 模型管理(普通价 + 代理价 + 别名,上架状态只读)· **并发分组** · **订单管理** · 全站日志 · 用户管理(设为代理 / 分配并发组)· CDK · 图片管理 · 展示位 · **站点公告** · 站点配置(易支付、去AI特征等)
|
||||||
- **违禁词管理**:后台增删违禁词(分页 + 多选批量删除),提示词命中即拦截(画图台 + API,不区分大小写),按词 / 按用户统计触发次数
|
- **违禁词管理**:提示词命中即拦截,按词 / 按用户统计触发次数
|
||||||
|
|
||||||
**🧰 工程亮点**:tls-client(Chrome JA3/JA4 指纹)稳定穿透 Cloudflare · 媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 自愈式维护轮询(恢复额度 / 刷新凭据 / 清理僵死任务并退款) · 一条命令 Docker 部署(TLS 交给你的反代)。
|
**🧰 工程亮点**:媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 预签名直传参考文件 · 自愈式维护轮询(僵死任务续等 / 退款、过期文件清理)· 一条命令 Docker 部署(TLS 交给你的反代)。
|
||||||
|
|
||||||
## 🤖 支持的模型 / 供应商
|
## 🔌 渠道架构
|
||||||
|
|
||||||
| 供应商 | 模型(示例) | 类型 |
|
```
|
||||||
|---|---|---|
|
调用方 ──vivid 协议──▶ image2api ──vivid 协议──▶ 上游 vividai 实例 A (渠道, 权重 w, 并发 c)
|
||||||
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
|
(本服务) ├─ 上游 vividai 实例 B
|
||||||
| **OpenAI** | gpt-image-2 | 图像 |
|
└─ …任意多个
|
||||||
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | 视频 / 图像 |
|
|
||||||
| **Grok(console.x.ai)** | grok-video(imagine 文生 / 图生视频)、grok-image / grok-image-quality(imagine 文生图 / 图生图) | 视频 / 图像 |
|
|
||||||
| **Leonardo.ai** | seedream-4.5 | 图像 |
|
|
||||||
| **Krea.ai** | flux-klein-2 | 图像 |
|
|
||||||
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
|
|
||||||
| **自定义上游** | 任意 OpenAI 兼容 v1 端点(按 id 路由) | 图像 / 视频 |
|
|
||||||
|
|
||||||
> 模型由管理后台动态启用并定价,可随时增删。自定义上游支持把任何 OpenAI 兼容服务接成账号,按 model id 路由调用。
|
|
||||||
|
|
||||||
## 🔌 OpenAI 兼容 API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 文生图 —— 纯 OpenAI 参数:size 同时决定比例 + 分辨率档(长边 <1800→1K / <3500→2K / ≥3500→4K)
|
|
||||||
curl https://你的域名/v1/images/generations \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-image-2",
|
|
||||||
"prompt": "a cute cat on a desk, studio lighting",
|
|
||||||
"size": "2048x2048"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 图生图 —— multipart 上传参考图(可多张 image[])
|
|
||||||
curl https://你的域名/v1/images/edits \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-F model="seedream-4.5" -F prompt="改成赛博朋克风格" -F image=@input.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
图片返回 OpenAI 风格 `{ "created": ..., "data": [{ "b64_json": "..." }] }`(原始 base64,无 `data:` 前缀,服务端不留存)。**视频**走异步:`POST /v1/videos` 建任务 → 轮询 `GET /v1/videos/{id}` 至 `completed` → `GET /v1/videos/{id}/content` 取 mp4。完整参数见站内 **/docs** 文档页。
|
- 渠道 = 一个同构上游:`base_url` + `vk_` API Key + 启用开关 + 并发上限 + 调度权重
|
||||||
|
- 模型来自渠道同步(上游 `/v1/models`),本地只做别名映射与定价;**模型可在多个渠道间故障转移**
|
||||||
|
- 本服务对外同样说 vivid 协议 —— 所以它自己也能被接成别人的渠道,无限层叠组网
|
||||||
|
- **不支持接 OpenAI 协议上游**:上游必须是 vivid 协议实例
|
||||||
|
|
||||||
|
## 📡 vivid 协议 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 文生图 / 生视频 —— NDJSON 流,首行回执 jobId
|
||||||
|
curl -N https://你的域名/v1/generate \
|
||||||
|
-H "Authorization: Bearer vk_xxxxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
|
||||||
|
# → {"jobId":"…","status":"running"} …空行心跳… {"status":"succeeded","outputs":[{"url":"…"}]}
|
||||||
|
|
||||||
|
# 断流重连:只带 jobId 重发,不重复下单
|
||||||
|
curl -N https://你的域名/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<上文 jobId>"}'
|
||||||
|
|
||||||
|
# 参考文件:先拿预签名上传会话,PUT 直传后拿 ref key
|
||||||
|
curl https://你的域名/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
|
||||||
|
|
||||||
|
# 模型清单(裸数组)与余额
|
||||||
|
curl https://你的域名/v1/models -H "Authorization: Bearer vk_xxxx"
|
||||||
|
curl https://你的域名/v1/balance -H "Authorization: Bearer vk_xxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
完整参数、错误码表与 Python 示例见站内 **/docs** 文档页。
|
||||||
|
|
||||||
## 🚀 部署
|
## 🚀 部署
|
||||||
|
|
||||||
> 域名 + HTTPS 由你自己的反向代理处理(本项目不内置证书签发)。
|
> 域名 + HTTPS 由你自己的反向代理处理(本项目不内置证书签发)。
|
||||||
|
|
||||||
**Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`(端口用 `WEB_PORT` 改;要改密码 / 密钥 / `CORS_ORIGINS`(反代走 HTTPS 时把 `COOKIE_SECURE` 设为 `true`),直接改 `docker-compose.yml` 里对应值即可)。
|
**Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`。
|
||||||
|
|
||||||
也可**从源码手动构建**,自备 **PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理**:
|
> ⚠️ 部署前把 `docker-compose.yml` 里的密码 / 密钥改掉 —— 特别是 **`CHANNEL_SECRET`**(渠道上游 Key 的加密密钥,变更后已存渠道密钥无法解密)。
|
||||||
|
|
||||||
|
也可**从源码手动构建**,自备 PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 创建空库(后端启动自动建表)
|
# 1. 创建空库(后端启动自动建表)
|
||||||
createdb vivid_ai
|
createdb vivid_ai
|
||||||
|
|
||||||
# 2. 配置并从源码构建后端
|
# 2. 配置并从源码构建后端 —— 完整环境变量见 backend/.env.example
|
||||||
cat > backend/.env <<'EOF'
|
|
||||||
APP_ENV=production
|
|
||||||
HTTP_ADDR=127.0.0.1:6666
|
|
||||||
POSTGRES_DSN=host=127.0.0.1 user=postgres password=你的密码 dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
|
|
||||||
REDIS_ADDR=127.0.0.1:6379
|
|
||||||
RUSTFS_ENDPOINT=http://127.0.0.1:9000
|
|
||||||
RUSTFS_BUCKET=vivid-ai
|
|
||||||
RUSTFS_ACCESS_KEY=你的AK
|
|
||||||
RUSTFS_SECRET_KEY=你的SK
|
|
||||||
CORS_ORIGINS=https://你的域名
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
EOF
|
|
||||||
cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666
|
cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666
|
||||||
|
|
||||||
# 3. 构建前端(产物 frontend/dist)
|
# 3. 构建前端(产物 frontend/dist)
|
||||||
@@ -212,7 +193,6 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
client_max_body_size 50m;
|
client_max_body_size 50m;
|
||||||
proxy_read_timeout 600s; # 视频生成耗时长
|
proxy_read_timeout 600s; # 视频生成耗时长
|
||||||
|
|
||||||
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
||||||
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
|
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
|
||||||
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
|
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
|
||||||
@@ -222,13 +202,13 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 完整环境变量见 `backend/.env.example`。
|
部署后:后台「渠道」页新建渠道(上游 URL + `vk_` key)→ 保存即自动同步全部上游模型 → 模型管理里配价 → 画图台 / 对外 API 立即可用。
|
||||||
|
|
||||||
## 🧱 技术栈
|
## 🧱 技术栈
|
||||||
|
|
||||||
| 层 | 技术 |
|
| 层 | 技术 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 后端 | Go · gin · gorm(PostgreSQL)· go-redis · tls-client(Chrome 指纹) |
|
| 后端 | Go · gin · gorm(PostgreSQL)· go-redis |
|
||||||
| 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
| 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
||||||
| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx |
|
| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx |
|
||||||
|
|
||||||
@@ -236,54 +216,34 @@ server {
|
|||||||
|
|
||||||
```
|
```
|
||||||
backend/ 后端源码(Go)
|
backend/ 后端源码(Go)
|
||||||
├── cmd/
|
├── cmd/api/ 服务入口(main)
|
||||||
│ ├── api/ 服务入口(main)
|
|
||||||
│ └── marklabel/ 运维小工具(按需标记账号)
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bootstrap/ 应用装配、定时维护任务启动
|
│ ├── bootstrap/ 应用装配、定时维护任务启动
|
||||||
│ ├── config/ 环境变量配置加载
|
│ ├── config/ 环境变量配置加载
|
||||||
│ ├── http/
|
│ ├── http/
|
||||||
│ │ ├── handler/ HTTP 处理器(v1 兼容接口、后台、鉴权…)
|
│ │ ├── handler/ HTTP 处理器(/v1 vivid 协议、渠道管理、鉴权…)
|
||||||
│ │ ├── middleware/ 鉴权 / 请求 ID 等中间件
|
│ │ ├── middleware/ 鉴权 / 请求 ID 等中间件
|
||||||
│ │ └── router/ 路由注册
|
│ │ └── router/ 路由注册
|
||||||
│ ├── model/ GORM 数据模型
|
│ ├── model/ GORM 数据模型(渠道 / 模型 / 用户 / 事件…)
|
||||||
│ ├── provider/ 各上游供应商客户端
|
│ ├── upstream/ vivid 协议上游客户端(NDJSON / 上传 / 模型 / 余额)
|
||||||
│ │ ├── adobe/ Adobe Firefly(tls-client 指纹)
|
│ ├── repo/ 数据访问层
|
||||||
│ │ ├── chatgpt/ OpenAI(含 PoW / turnstile)
|
│ ├── service/ 业务逻辑(渠道调度、计费、维护、v1 job 生命周期)
|
||||||
│ │ ├── runway/ Runway 视频 + Nano Banana 图像
|
│ └── storage/ RustFS / S3 媒体存储(预签名 PUT/GET)
|
||||||
│ │ ├── grok/ Grok(console.x.ai DPoP,视频 / 图像;账号态走 grok.com)
|
├── Dockerfile 多阶段构建
|
||||||
│ │ ├── leonardo/ Leonardo
|
|
||||||
│ │ ├── krea/ Krea
|
|
||||||
│ │ ├── imagine/ Imagine.art
|
|
||||||
│ │ ├── custom/ 自定义上游(OpenAI 兼容 v1,按 id 路由,直连不走代理)
|
|
||||||
│ │ └── epay/ 易支付(mapi 下单 + 回调 MD5 验签,积分充值)
|
|
||||||
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK / 订单 / 并发组…)
|
|
||||||
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
|
|
||||||
│ └── storage/ RustFS / S3 媒体存储
|
|
||||||
├── Dockerfile 多阶段构建(源码编译 → 精简运行镜像)
|
|
||||||
└── .env.example 后端环境变量模板
|
└── .env.example 后端环境变量模板
|
||||||
|
|
||||||
frontend/ 前端源码(Vue 3 + Vite)
|
frontend/ 前端源码(Vue 3 + Vite)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── views/ 页面(画图台 / 账号 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
|
│ ├── views/ 页面(画图台 / 渠道 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
|
||||||
│ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…)
|
│ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…)
|
||||||
│ ├── layouts/ 公共 / 后台布局
|
│ ├── layouts/ 公共 / 后台布局
|
||||||
│ ├── utils/ 工具函数
|
|
||||||
│ └── api.js · auth.js … 接口封装、鉴权、主题、积分等
|
│ └── api.js · auth.js … 接口封装、鉴权、主题、积分等
|
||||||
├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代
|
├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代
|
||||||
└── default.conf.template Nginx 站点模板(反代 + 缓存策略)
|
└── default.conf.template Nginx 站点模板(反代 + 缓存策略)
|
||||||
|
|
||||||
docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端 / 前端)
|
docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端 / 前端)
|
||||||
.env.example → backend/.env 后端环境变量模板
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
|
||||||
|
|
||||||
- [ ] 更多上游供应商接入
|
|
||||||
- [ ] 用量统计 / 导出
|
|
||||||
- [ ] 多语言界面(i18n)
|
|
||||||
- [ ] Webhook / 异步回调
|
|
||||||
|
|
||||||
## 💬 交流 / 联系
|
## 💬 交流 / 联系
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -294,13 +254,6 @@ docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端
|
|||||||
| 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
|
| 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
|
||||||
| ✉️ 邮箱 | vividairun@gmail.com |
|
| ✉️ 邮箱 | vividairun@gmail.com |
|
||||||
|
|
||||||
## ⭐ Star History
|
|
||||||
|
|
||||||
<!-- 建好 GitHub 仓库后,取消下面这行注释并把 OWNER 换成你的用户名即可显示趋势图: -->
|
|
||||||
<!-- [](https://star-history.com/#OWNER/image2api&Date) -->
|
|
||||||
|
|
||||||
觉得有用就点个 ⭐ 吧 —— 建好仓库后取消上方注释即可展示 Star 趋势图。
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
|
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Vivid AI backend config template. Copy to .env and fill in real values.
|
# image2api backend config template. Copy to .env and fill in real values.
|
||||||
# Real environment variables override .env.
|
# Real environment variables override .env.
|
||||||
|
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
APP_TITLE=Vivid AI
|
APP_TITLE=image2api
|
||||||
HTTP_ADDR=:6666
|
HTTP_ADDR=:6666
|
||||||
|
|
||||||
# PostgreSQL (database must already exist; tables auto-migrate on boot)
|
# PostgreSQL (database must already exist; tables auto-migrate on boot)
|
||||||
@@ -13,8 +13,22 @@ REDIS_ADDR=127.0.0.1:6379
|
|||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# RustFS / S3-compatible object storage (generated media + reference uploads)
|
||||||
|
RUSTFS_ENDPOINT=http://127.0.0.1:9000
|
||||||
|
RUSTFS_BUCKET=vivid-ai
|
||||||
|
RUSTFS_ACCESS_KEY=YOUR_AK
|
||||||
|
RUSTFS_SECRET_KEY=YOUR_SK
|
||||||
|
|
||||||
|
# Channel upstream API keys are stored AES-GCM encrypted with this secret.
|
||||||
|
# Set a random value in production and keep it stable — changing it makes
|
||||||
|
# previously saved channel keys undecryptable.
|
||||||
|
CHANNEL_SECRET=change-me-channel-secret
|
||||||
|
|
||||||
# Frontend dev origins allowed for CORS
|
# Frontend dev origins allowed for CORS
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|
||||||
# Optional: override generated media root (defaults to ../../ai-gateway/data/generated)
|
# Set to true when serving the site over HTTPS (session cookie Secure flag)
|
||||||
# GENERATED_ROOT=
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# SESSION_COOKIE_NAME=vivid_session
|
||||||
|
# GENERATED_ROOT= # optional: override generated media root
|
||||||
|
|||||||
@@ -1,55 +1,27 @@
|
|||||||
# Vivid AI Backend
|
# image2api backend
|
||||||
|
|
||||||
Go backend for `vivid-ai`, using:
|
Go backend for `image2api` — a vivid-protocol gateway that routes generation
|
||||||
|
jobs across same-protocol upstream channels. Stack: Gin · GORM (PostgreSQL) ·
|
||||||
|
go-redis.
|
||||||
|
|
||||||
- Gin
|
## Architecture sketch
|
||||||
- GORM
|
|
||||||
- PostgreSQL
|
|
||||||
- Redis
|
|
||||||
|
|
||||||
## Current scope
|
- `internal/upstream` — vivid-protocol client (NDJSON generate + jobId resume,
|
||||||
|
`/v1/uploads`, `/v1/models`, `/v1/balance`, error classification)
|
||||||
This is an in-progress rewrite. The current skeleton already includes:
|
- `internal/service/channel.go` — channel CRUD, upstream model sync (save =
|
||||||
|
full sync), AES-GCM key storage
|
||||||
- app bootstrap
|
- `internal/service/v1.go` — channel-weighted dispatch, billing, job lifecycle,
|
||||||
- PostgreSQL and Redis initialization
|
stale-job resume
|
||||||
- GORM auto-migrations
|
- `internal/http/handler/v1.go` — public vivid endpoints
|
||||||
- session storage in Redis
|
(`/v1/generate` `/v1/uploads` `/v1/models` `/v1/balance`)
|
||||||
- image access control for `/images/:user/:name`
|
|
||||||
- public site endpoint: `/admin/api/site`
|
|
||||||
- public showcase endpoint: `/admin/api/showcase`
|
|
||||||
- session-based auth endpoint: `/admin/api/auth/me`
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Set these before running:
|
See `.env.example`. Required: `POSTGRES_DSN`, `REDIS_ADDR`, `RUSTFS_*`.
|
||||||
|
Important: `CHANNEL_SECRET` encrypts stored channel API keys (AES-GCM) — set a
|
||||||
|
random value and keep it stable; rotating it makes saved keys undecryptable.
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
$env:POSTGRES_DSN="host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
cp .env.example .env # fill in values
|
||||||
$env:REDIS_ADDR="127.0.0.1:6379"
|
go build -o bin/api ./cmd/api && ./bin/api
|
||||||
$env:HTTP_ADDR=":6061"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:APP_ENV="development"
|
|
||||||
$env:APP_TITLE="Vivid AI"
|
|
||||||
$env:SESSION_COOKIE_NAME="vivid_session"
|
|
||||||
$env:CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
go run ./cmd/api
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Generated media defaults to `../../ai-gateway/data/generated` relative to the backend working directory.
|
|
||||||
- Private images require either:
|
|
||||||
- session cookie
|
|
||||||
- bearer session token
|
|
||||||
- bearer API key
|
|
||||||
- Showcase images are public.
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type row struct {
|
|
||||||
ID string
|
|
||||||
Pool string
|
|
||||||
Status string
|
|
||||||
AccountEmail string
|
|
||||||
ImageLimited bool
|
|
||||||
VideoLimited bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
dsn := os.Getenv("POSTGRES_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
fmt.Println("POSTGRES_DSN env is required, e.g. host=127.0.0.1 user=postgres password=... dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("open err:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pick row
|
|
||||||
db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE pool='adobe' ORDER BY id LIMIT 1`).Scan(&pick)
|
|
||||||
fmt.Printf("picked: id=%s email=%s status=%s image_limited=%v video_limited=%v\n", pick.ID, pick.AccountEmail, pick.Status, pick.ImageLimited, pick.VideoLimited)
|
|
||||||
|
|
||||||
if err := db.Exec(`UPDATE token_accounts SET video_limited=true, updated_at=now() WHERE id=?`, pick.ID).Error; err != nil {
|
|
||||||
fmt.Println("update err:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("-> set video_limited=true")
|
|
||||||
|
|
||||||
var after row
|
|
||||||
db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE id=?`, pick.ID).Scan(&after)
|
|
||||||
fmt.Printf("after: id=%s status=%s image_limited=%v video_limited=%v\n", after.ID, after.Status, after.ImageLimited, after.VideoLimited)
|
|
||||||
}
|
|
||||||
@@ -5,25 +5,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/config"
|
"backend/internal/config"
|
||||||
"backend/internal/http/handler"
|
"backend/internal/http/handler"
|
||||||
"backend/internal/http/router"
|
"backend/internal/http/router"
|
||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
"backend/internal/provider/adobe"
|
|
||||||
"backend/internal/provider/chatgpt"
|
|
||||||
"backend/internal/provider/custom"
|
|
||||||
"backend/internal/provider/creativefabrica"
|
|
||||||
"backend/internal/provider/grok"
|
|
||||||
"backend/internal/provider/imagine"
|
|
||||||
"backend/internal/provider/krea"
|
|
||||||
"backend/internal/provider/leonardo"
|
|
||||||
"backend/internal/provider/runway"
|
|
||||||
"backend/internal/repo"
|
"backend/internal/repo"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"backend/internal/storage"
|
"backend/internal/storage"
|
||||||
|
"backend/internal/upstream"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -51,9 +42,9 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
return nil, fmt.Errorf("create generated root %s: %w", cfg.GeneratedRoot, err)
|
return nil, fmt.Errorf("create generated root %s: %w", cfg.GeneratedRoot, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成 gorm.ErrDuplicatedKey,
|
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成
|
||||||
// 否则各 import-*(krea/adobe/leonardo/runway)里的 errors.Is(err, gorm.ErrDuplicatedKey)
|
// gorm.ErrDuplicatedKey,否则 errors.Is(err, gorm.ErrDuplicatedKey) 兜底命不中,
|
||||||
// 兜底命不中,重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
// 重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
||||||
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
|
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open postgres: %w", err)
|
return nil, fmt.Errorf("open postgres: %w", err)
|
||||||
@@ -77,6 +68,14 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
|
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
|
||||||
return nil, fmt.Errorf("cdk marketing index: %w", err)
|
return nil, fmt.Errorf("cdk marketing index: %w", err)
|
||||||
}
|
}
|
||||||
|
// 配价即上架(vivid 同款):models 不再有启停开关,删遗留列。
|
||||||
|
if err := db.WithContext(ctx).Exec(`ALTER TABLE model_configs DROP COLUMN IF EXISTS enabled`).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("drop model_configs.enabled: %w", err)
|
||||||
|
}
|
||||||
|
// 首页展示位不再支持 bento,清掉遗留记录。
|
||||||
|
if err := db.WithContext(ctx).Exec(`DELETE FROM showcase_items WHERE kind = 'bento'`).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("purge bento showcase: %w", err)
|
||||||
|
}
|
||||||
if err := seedDefaults(ctx, db); err != nil {
|
if err := seedDefaults(ctx, db); err != nil {
|
||||||
return nil, fmt.Errorf("seed defaults: %w", err)
|
return nil, fmt.Errorf("seed defaults: %w", err)
|
||||||
}
|
}
|
||||||
@@ -97,8 +96,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
eventRepo := repo.NewEventRepository(db)
|
eventRepo := repo.NewEventRepository(db)
|
||||||
cdkRepo := repo.NewCDKRepository(db)
|
cdkRepo := repo.NewCDKRepository(db)
|
||||||
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
||||||
tokenRepo := repo.NewTokenRepository(db)
|
channelRepo := repo.NewChannelRepository(db)
|
||||||
refreshRepo := repo.NewRefreshProfileRepository(db)
|
|
||||||
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
||||||
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
||||||
if err := cgroupRepo.EnsureDefault(ctx); err != nil {
|
if err := cgroupRepo.EnsureDefault(ctx); err != nil {
|
||||||
@@ -117,32 +115,28 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc, cgroupRepo)
|
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc, cgroupRepo)
|
||||||
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
||||||
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
||||||
adobeClient := adobe.NewClient("projectx_webapp", "")
|
upClient := upstream.NewClient()
|
||||||
chatGPTClient := chatgpt.NewClient("")
|
channelSecret := cfg.ChannelSecret
|
||||||
runwayClient := runway.NewClient("")
|
if channelSecret == "" {
|
||||||
leonardoClient := leonardo.NewClient("")
|
// Stable fallback so a restarted process can still decrypt existing
|
||||||
kreaClient := krea.NewClient("")
|
// rows when CHANNEL_SECRET isn't set.
|
||||||
imagineClient := imagine.NewClient("")
|
channelSecret = cfg.PostgresDSN
|
||||||
grokClient := grok.NewClient("")
|
}
|
||||||
// Keep grok's x-statsig-id anti-bot recipe current by reading grok's own
|
channelSvc := service.NewChannelService(channelRepo, modelRepo, eventRepo, concSvc, upClient, channelSecret)
|
||||||
// headless-browser signer output. Event-driven: seed from the persisted
|
// Sweep provider-era model rows: anything not bound to a channel can never
|
||||||
// recipe, capture once at startup, then re-capture only on an anti-bot 403
|
// be routed under the channel scheduler.
|
||||||
// (a reship made the recipe stale). No polling.
|
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
|
||||||
startGrokStatsigRefresh(siteRepo)
|
log.Printf("purge orphan models: %v", err)
|
||||||
customClient := custom.NewClient()
|
} else if n > 0 {
|
||||||
cfClient := creativefabrica.NewClient("")
|
log.Printf("purged %d unbound model(s) left over from the provider era", n)
|
||||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, cfClient, rustfsClient)
|
}
|
||||||
|
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
||||||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
|
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, channelRepo, cdkRepo, rustfsClient, showcaseRepo)
|
||||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo)
|
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, channelRepo, orderRepo)
|
||||||
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
|
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
|
||||||
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
||||||
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, cfClient)
|
|
||||||
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
|
|
||||||
// Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token
|
|
||||||
// from the cookie). Wired post-construction to avoid a ctor init cycle.
|
|
||||||
v1Svc.SetRefresh(refreshSvc)
|
|
||||||
bannedWordRepo := repo.NewBannedWordRepository(db)
|
bannedWordRepo := repo.NewBannedWordRepository(db)
|
||||||
v1Svc.SetBannedWords(bannedWordRepo)
|
v1Svc.SetBannedWords(bannedWordRepo)
|
||||||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||||||
@@ -163,7 +157,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
CDK: handler.NewCDKHandler(cdkSvc),
|
CDK: handler.NewCDKHandler(cdkSvc),
|
||||||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||||||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||||||
ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc),
|
ChannelAdmin: handler.NewChannelAdminHandler(channelSvc),
|
||||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||||
@@ -171,9 +165,9 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
Canvas: handler.NewCanvasHandler(canvasSvc),
|
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
||||||
// cleanup, log retention) — the Go equivalent of the Python daemon thread.
|
// log/media retention).
|
||||||
maintenanceSvc := service.NewMaintenanceService(tokenRepo, tokenSvc, eventRepo, userRepo, refreshSvc, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc, showcaseRepo, orderRepo)
|
||||||
loopCtx, loopCancel := context.WithCancel(context.Background())
|
loopCtx, loopCancel := context.WithCancel(context.Background())
|
||||||
go maintenanceSvc.Run(loopCtx)
|
go maintenanceSvc.Run(loopCtx)
|
||||||
|
|
||||||
@@ -186,33 +180,6 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startGrokStatsigRefresh wires grok's headless x-statsig-id refresher to the
|
|
||||||
// site-settings store (persisted across restarts) with an app-lifetime context.
|
|
||||||
func startGrokStatsigRefresh(siteRepo *repo.SiteSettingRepository) {
|
|
||||||
const kHeader, kSuffix, kTrailer = "grok.statsig.header", "grok.statsig.suffix", "grok.statsig.trailer"
|
|
||||||
grok.StartStatsigAutoRefresh(context.Background(), 0,
|
|
||||||
func(ctx context.Context) (string, string, int, bool) {
|
|
||||||
h, _ := siteRepo.GetValue(ctx, kHeader)
|
|
||||||
s, _ := siteRepo.GetValue(ctx, kSuffix)
|
|
||||||
tv, _ := siteRepo.GetValue(ctx, kTrailer)
|
|
||||||
if h == "" || s == "" {
|
|
||||||
return "", "", 0, false
|
|
||||||
}
|
|
||||||
t, _ := strconv.Atoi(tv)
|
|
||||||
return h, s, t, true
|
|
||||||
},
|
|
||||||
func(ctx context.Context, h, s string, t int) {
|
|
||||||
if err := siteRepo.UpsertValues(ctx, map[string]string{
|
|
||||||
kHeader: h,
|
|
||||||
kSuffix: s,
|
|
||||||
kTrailer: strconv.Itoa(t),
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("grok statsig: persist recipe failed: %v", err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
if a.maintenanceCancel != nil {
|
if a.maintenanceCancel != nil {
|
||||||
a.maintenanceCancel()
|
a.maintenanceCancel()
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type Config struct {
|
|||||||
RustFSBucket string
|
RustFSBucket string
|
||||||
RustFSAccessKey string
|
RustFSAccessKey string
|
||||||
RustFSSecretKey string
|
RustFSSecretKey string
|
||||||
|
// ChannelSecret encrypts channel upstream API keys at rest (AES-GCM).
|
||||||
|
// Optional — when empty a stable key is derived from the Postgres DSN.
|
||||||
|
ChannelSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -60,6 +63,7 @@ func Load() (*Config, error) {
|
|||||||
RustFSBucket: envString("RUSTFS_BUCKET", ""),
|
RustFSBucket: envString("RUSTFS_BUCKET", ""),
|
||||||
RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""),
|
RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""),
|
||||||
RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""),
|
RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""),
|
||||||
|
ChannelSecret: envString("CHANNEL_SECRET", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
|
"backend/internal/repo"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -99,7 +100,21 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
|
|||||||
userIDs = ids
|
userIDs = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", userIDs, strings.TrimSpace(c.Query("q")), "", c.Query("source"), false, false, false)
|
// ?kw= — 单搜索框:命中 提示词/模型/错误,或命中 用户(名称/邮箱/ID) 的记录都保留。
|
||||||
|
var items []model.EventLog
|
||||||
|
var total int64
|
||||||
|
var stats *repo.EventStats
|
||||||
|
var err error
|
||||||
|
if kw := strings.TrimSpace(c.Query("kw")); kw != "" {
|
||||||
|
kwIDs, uerr := h.admin.MatchUserIDs(c.Request.Context(), kw)
|
||||||
|
if uerr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, total, stats, err = h.admin.LogsKeyword(c.Request.Context(), limit, offset, kind, status, since, kw, kwIDs, c.Query("source"))
|
||||||
|
} else {
|
||||||
|
items, total, stats, err = h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", userIDs, strings.TrimSpace(c.Query("q")), "", c.Query("source"), false, false, false)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"backend/internal/service"
|
||||||
|
"backend/internal/upstream"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelAdminHandler exposes channel CRUD + upstream model import under
|
||||||
|
// /admin/api/channels*. Replaces the old token/account admin surface.
|
||||||
|
type ChannelAdminHandler struct {
|
||||||
|
channels *service.ChannelService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChannelAdminHandler(channels *service.ChannelService) *ChannelAdminHandler {
|
||||||
|
return &ChannelAdminHandler{channels: channels}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChannelAdminHandler) List(c *gin.Context) {
|
||||||
|
data, err := h.channels.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load channels"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
||||||
|
var body service.SaveChannelDto
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
||||||
|
ch, warning, err := h.channels.Save(c.Request.Context(), body)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID, "warning": warning})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.channels.Delete(c.Request.Context(), body.ID); err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchModels proxies GET /v1/models on a channel (stored creds or ad-hoc
|
||||||
|
// base_url+api_key for a not-yet-saved channel form).
|
||||||
|
func (h *ChannelAdminHandler) FetchModels(c *gin.Context) {
|
||||||
|
var body service.FetchModelsDto
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.channels.FetchModels(c.Request.Context(), body)
|
||||||
|
if err != nil {
|
||||||
|
writeUpstreamErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []upstream.RemoteModel{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncModels manually re-runs the full upstream model sync on a saved channel
|
||||||
|
// (new models bound, disappeared ones unbound, orphans deleted). Fetch
|
||||||
|
// failures are errors here — unlike the save path which degrades to a warning.
|
||||||
|
func (h *ChannelAdminHandler) SyncModels(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, err := h.channels.SyncModels(c.Request.Context(), body.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeUpstreamErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportModels binds the ticked upstream models to the channel (creating local
|
||||||
|
// model rows on first import).
|
||||||
|
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Models []service.ChannelModelSpec `json:"models"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.channels.ImportModels(c.Request.Context(), body.ID, body.Models); err != nil {
|
||||||
|
writeUpstreamErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUpstreamErr translates upstream-protocol errors into sensible admin
|
||||||
|
// responses (auth → 400 with the upstream's message, temporary → 502).
|
||||||
|
func writeUpstreamErr(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, upstream.ErrAuth):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "上游鉴权失败:" + err.Error()})
|
||||||
|
case errors.Is(err, upstream.ErrInvalid):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"detail": "上游请求失败:" + err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"backend/internal/config"
|
"backend/internal/config"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"backend/internal/storage"
|
"backend/internal/storage"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ func (h *ImageHandler) Serve(c *gin.Context) {
|
|||||||
if !public {
|
if !public {
|
||||||
authorized, err := h.imageAccess.IsAuthorized(
|
authorized, err := h.imageAccess.IsAuthorized(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
|
c.GetHeader("Authorization"),
|
||||||
readCookie(c, h.cfg.SessionCookieName),
|
readCookie(c, h.cfg.SessionCookieName),
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,534 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"backend/internal/service"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProviderAdminHandler struct {
|
|
||||||
tokens *service.TokenService
|
|
||||||
refresh *service.RefreshProfileService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewProviderAdminHandler(tokens *service.TokenService, refresh *service.RefreshProfileService) *ProviderAdminHandler {
|
|
||||||
return &ProviderAdminHandler{
|
|
||||||
tokens: tokens,
|
|
||||||
refresh: refresh,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) TokensList(c *gin.Context) {
|
|
||||||
data, err := h.tokens.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load tokens"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) TokensCreate(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Pool string `json:"pool"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.tokens.Add(c.Request.Context(), body.Pool, body.Value, body.ID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportChatGPTToken(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := body.AccessToken
|
|
||||||
if token == "" {
|
|
||||||
token = body.Value
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportChatGPTToken(c.Request.Context(), token, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportRunwayToken(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := body.AccessToken
|
|
||||||
if token == "" {
|
|
||||||
token = body.Value
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportRunwayToken(c.Request.Context(), token, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportGrokToken(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
SSO string `json:"sso"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := body.AccessToken
|
|
||||||
for _, v := range []string{body.Value, body.SSO} {
|
|
||||||
if token == "" {
|
|
||||||
token = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportGrokToken(c.Request.Context(), token, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportCustomAccount(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
Models string `json:"models"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Weight int `json:"weight"`
|
|
||||||
Concurrency int `json:"concurrency"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
baseURL := body.BaseURL
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = body.URL
|
|
||||||
}
|
|
||||||
key := body.Key
|
|
||||||
if key == "" {
|
|
||||||
key = body.APIKey
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportCustomAccount(c.Request.Context(), baseURL, key, body.Models, body.Name, body.Weight, body.Concurrency, body.ID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Cookie string `json:"cookie"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cookie := body.Cookie
|
|
||||||
if cookie == "" {
|
|
||||||
cookie = body.Value
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportKreaCookie(c.Request.Context(), cookie, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportImagineToken(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Cookie string `json:"cookie"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cred := body.Cookie
|
|
||||||
if cred == "" {
|
|
||||||
cred = body.Value
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportImagineToken(c.Request.Context(), cred, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportLeonardoCookie(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Cookie string `json:"cookie"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cookie := body.Cookie
|
|
||||||
if cookie == "" {
|
|
||||||
cookie = body.Value
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportLeonardoCookie(c.Request.Context(), cookie, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportAdobeCookie(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Cookie string `json:"cookie"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cookie := body.Cookie
|
|
||||||
if cookie == "" {
|
|
||||||
cookie = body.Value
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, profile, err := h.tokens.ImportAdobeCookie(c.Request.Context(), cookie, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"ok": true,
|
|
||||||
"profile_id": profile.ID,
|
|
||||||
"id": item.ID,
|
|
||||||
"status": item.Status,
|
|
||||||
"pending": item.Status == "pending",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) ImportCreativeFabricaCookie(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Cookie string `json:"cookie"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cookie := body.Cookie
|
|
||||||
if cookie == "" {
|
|
||||||
cookie = body.Value
|
|
||||||
}
|
|
||||||
name := body.Name
|
|
||||||
if name == "" {
|
|
||||||
name = body.ID
|
|
||||||
}
|
|
||||||
item, err := h.tokens.ImportCreativeFabricaCookie(c.Request.Context(), cookie, name)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"ok": true,
|
|
||||||
"id": item.ID,
|
|
||||||
"status": item.Status,
|
|
||||||
"pending": item.Status == "pending",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) TokenUpdate(c *gin.Context) {
|
|
||||||
var body map[string]any
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.tokens.Update(c.Request.Context(), c.Param("pool"), c.Param("id"), body)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) TokenDelete(c *gin.Context) {
|
|
||||||
if err := h.tokens.Delete(c.Request.Context(), c.Param("pool"), c.Param("id")); err != nil {
|
|
||||||
if errors.Is(err, service.ErrNotFound) {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "token not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenDeleteBulk removes multiple accounts in one call (account multi-select).
|
|
||||||
func (h *ProviderAdminHandler) TokenDeleteBulk(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
IDs []string `json:"ids"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(body.IDs) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何账号"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
n, err := h.tokens.DeleteBulk(c.Request.Context(), body.IDs)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
|
|
||||||
data, err := h.tokens.Accounts(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// KPI stats are computed over the FULL set (每个类型的 成功/失败/限额), independent
|
|
||||||
// of the current filter/page — mirrors the old client-side `stats` computed.
|
|
||||||
stats := accountsStats(data)
|
|
||||||
|
|
||||||
// Server-side filtering (类型 / 状态 / 搜索 邮箱·ID·类型 / dead) so pagination is
|
|
||||||
// correct across pages. ?dead=1 returns only 异常(已失效) accounts — used by
|
|
||||||
// 「删除异常账号」to collect every dead id regardless of the current page.
|
|
||||||
typeFilter := strings.TrimSpace(c.Query("type"))
|
|
||||||
statusFilter := strings.TrimSpace(c.Query("status"))
|
|
||||||
planFilter := strings.TrimSpace(c.Query("plan"))
|
|
||||||
deadOnly := c.Query("dead") == "1"
|
|
||||||
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
|
|
||||||
filtered := make([]map[string]any, 0, len(data))
|
|
||||||
for _, row := range data {
|
|
||||||
if deadOnly && !rowBool(row, "dead") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if typeFilter != "" && rowStr(row, "type") != typeFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if statusFilter != "" && rowStr(row, "status") != statusFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
|
|
||||||
if planFilter != "" {
|
|
||||||
plan := strings.ToLower(rowStr(row, "plan"))
|
|
||||||
isSub := rowBool(row, "sub_account")
|
|
||||||
isFree := plan == "" || plan == "free"
|
|
||||||
switch planFilter {
|
|
||||||
case "vip":
|
|
||||||
if isFree { continue }
|
|
||||||
case "free":
|
|
||||||
if !isFree { continue }
|
|
||||||
case "sub":
|
|
||||||
if !isSub { continue }
|
|
||||||
case "master":
|
|
||||||
if isFree || isSub { continue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if q != "" {
|
|
||||||
email := strings.ToLower(rowStr(row, "email"))
|
|
||||||
id := strings.ToLower(rowStr(row, "id"))
|
|
||||||
typ := strings.ToLower(rowStr(row, "type"))
|
|
||||||
if !strings.Contains(email, q) && !strings.Contains(id, q) && !strings.Contains(typ, q) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filtered = append(filtered, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
total := len(filtered)
|
|
||||||
limit, offset := pageParams(c, 20)
|
|
||||||
page := pageSlice(filtered, limit, offset)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": page, "total": total, "limit": limit, "offset": offset, "stats": stats})
|
|
||||||
}
|
|
||||||
|
|
||||||
// accountsStats reproduces the 账号 KPI strip: per-type 正常/失效/限额 counts plus a
|
|
||||||
// grand total and total dead count (drives 「删除异常账号 (N)」).
|
|
||||||
func accountsStats(rows []map[string]any) gin.H {
|
|
||||||
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok", "creativefabrica"}
|
|
||||||
by := map[string]*struct{ N, Ok, Dead, Quota int }{}
|
|
||||||
for _, t := range types {
|
|
||||||
by[t] = &struct{ N, Ok, Dead, Quota int }{}
|
|
||||||
}
|
|
||||||
deadTotal := 0
|
|
||||||
for _, row := range rows {
|
|
||||||
dead := rowBool(row, "dead")
|
|
||||||
if dead {
|
|
||||||
deadTotal++
|
|
||||||
}
|
|
||||||
g, ok := by[rowStr(row, "type")]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
g.N++
|
|
||||||
status := rowStr(row, "status")
|
|
||||||
switch {
|
|
||||||
case status == "active":
|
|
||||||
g.Ok++
|
|
||||||
case dead || status == "disabled":
|
|
||||||
g.Dead++
|
|
||||||
case status == "quota":
|
|
||||||
g.Quota++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := gin.H{"total": len(rows), "dead_total": deadTotal}
|
|
||||||
for _, t := range types {
|
|
||||||
g := by[t]
|
|
||||||
out[t] = gin.H{"n": g.N, "ok": g.Ok, "dead": g.Dead, "quota": g.Quota}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func rowStr(m map[string]any, key string) string {
|
|
||||||
s, _ := m[key].(string)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func rowBool(m map[string]any, key string) bool {
|
|
||||||
b, _ := m[key].(bool)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
|
|
||||||
data, err := h.tokens.Quota(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) AccountEmail(c *gin.Context) {
|
|
||||||
data, err := h.tokens.Email(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) RefreshProfiles(c *gin.Context) {
|
|
||||||
items, err := h.refresh.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load refresh profiles"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) RefreshNow(c *gin.Context) {
|
|
||||||
if err := h.refresh.RefreshNow(c.Request.Context(), c.Param("profile_id")); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) RefreshUpdate(c *gin.Context) {
|
|
||||||
var body map[string]any
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.refresh.Update(c.Request.Context(), c.Param("profile_id"), body)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProviderAdminHandler) RefreshDelete(c *gin.Context) {
|
|
||||||
if err := h.refresh.Delete(c.Request.Context(), c.Param("profile_id")); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
@@ -46,16 +46,16 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
// AdminList is the paginated flat view for the admin 首页内容 page — the public
|
// AdminList is the paginated flat view for the admin 首页内容 page — the public
|
||||||
// List keeps its grouped shape for the home page, this one adds kind filter +
|
// List keeps its grouped shape for the home page, this one adds kind filter +
|
||||||
// limit/offset/total. Order mirrors the old client flattening: hero → bento → work.
|
// limit/offset/total. Order mirrors the old client flattening: hero → work.
|
||||||
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
|
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
|
||||||
grouped, err := h.showcase.Grouped(c.Request.Context())
|
grouped, err := h.showcase.Grouped(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kindFilter := c.Query("kind") // "" | hero | bento | work
|
kindFilter := c.Query("kind") // "" | hero | work
|
||||||
flat := make([]gin.H, 0, 16)
|
flat := make([]gin.H, 0, 16)
|
||||||
for _, kind := range []string{"hero", "bento", "work"} {
|
for _, kind := range []string{"hero", "work"} {
|
||||||
if kindFilter != "" && kind != kindFilter {
|
if kindFilter != "" && kind != kindFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/internal/repo"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserGenerationHandler struct {
|
type UserGenerationHandler struct {
|
||||||
@@ -91,6 +101,45 @@ func (h *UserGenerationHandler) MyImages(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadMyImage stores one user-supplied image (multipart field "file") under
|
||||||
|
// the caller's owner directory, so it can be placed on the infinite canvas and
|
||||||
|
// shows up in the 我的图片 picker alongside generated outputs. Returns the
|
||||||
|
// storage key ("owner/name") the frontend feeds to generatedUrl/thumbUrl.
|
||||||
|
func (h *UserGenerationHandler) UploadMyImage(c *gin.Context) {
|
||||||
|
user := currentUser(c)
|
||||||
|
if user == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 20<<20)
|
||||||
|
f, _, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "请选择图片文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(f, 15<<20+1))
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "读取文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) > 15<<20 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "图片不能超过 15MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "不是有效的图片文件(支持 png/jpg/webp/gif)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := h.admin.SaveOwnedImage(c.Request.Context(), service.OwnerDir(user), format, data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"name": key})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteMyFile removes ONE of the caller's own generated files (plus its
|
// DeleteMyFile removes ONE of the caller's own generated files (plus its
|
||||||
// thumbnail) and blanks the log rows referencing it, so the 画图台 grid and
|
// thumbnail) and blanks the log rows referencing it, so the 画图台 grid and
|
||||||
// 创作记录 gallery stop showing it. ?file= is the storage key (owner/name).
|
// 创作记录 gallery stop showing it. ?file= is the storage key (owner/name).
|
||||||
@@ -202,7 +251,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
|||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
ReferenceImages []string `json:"reference_images"`
|
ReferenceImages []string `json:"reference_images"`
|
||||||
AccountID string `json:"account_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||||
@@ -216,7 +265,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
|||||||
Resolution: body.Resolution,
|
Resolution: body.Resolution,
|
||||||
Duration: body.Duration,
|
Duration: body.Duration,
|
||||||
ReferenceImages: body.ReferenceImages,
|
ReferenceImages: body.ReferenceImages,
|
||||||
AccountID: body.AccountID,
|
ChannelID: body.ChannelID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
@@ -405,161 +454,19 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
"data": []gin.H{
|
if err != nil {
|
||||||
{
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load video presets"})
|
||||||
"key": "gemini-veo31",
|
return
|
||||||
"label": "Veo31 Fast",
|
}
|
||||||
"type": "video",
|
out := make([]gin.H, 0, len(models))
|
||||||
"provider": "adobe",
|
for _, m := range models {
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
if m.Type != "video" {
|
||||||
"ratios": []string{"16:9", "9:16"},
|
continue
|
||||||
"resolutions": []string{"720p", "1080p"},
|
}
|
||||||
"max_reference_images": 9,
|
out = append(out, modelPreset(m))
|
||||||
"reference_mode": "frame",
|
}
|
||||||
},
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||||
{
|
|
||||||
"key": "gemini-veo3.1-fast",
|
|
||||||
"label": "Veo 3.1 Fast",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "adobe",
|
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "gemini-veo3.1",
|
|
||||||
"label": "Veo 3.1",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "adobe",
|
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "adobe-seedance-2.0-fast",
|
|
||||||
"label": "Seedance 2.0 Fast",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "adobe",
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "style",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "adobe-seedance-2.0",
|
|
||||||
"label": "Seedance 2.0",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "adobe",
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "style",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "seedance-2.0-fast",
|
|
||||||
"label": "Seedance 2.0 Fast (Creative Fabrica)",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "creativefabrica",
|
|
||||||
"durations": []string{"14s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
|
||||||
// 没有首尾帧,也不收视频/音频参考。
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"max_videos": 0,
|
|
||||||
"max_audios": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "seedance-2.0",
|
|
||||||
"label": "Seedance 2.0 (Creative Fabrica)",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "creativefabrica",
|
|
||||||
"durations": []string{"10s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
|
||||||
// 没有首尾帧,也不收视频/音频参考。
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"max_videos": 0,
|
|
||||||
"max_audios": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "seedance-2.0-不卡人脸",
|
|
||||||
"label": "Seedance 2.0 (Leonardo 私有)",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"max_reference_images": 8,
|
|
||||||
"max_images": 4,
|
|
||||||
"max_videos": 3,
|
|
||||||
"max_audios": 1,
|
|
||||||
"reference_mode": "style",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "seedance-2.0-fast-不卡人脸",
|
|
||||||
"label": "Seedance 2.0 Fast (Leonardo 私有)",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"max_reference_images": 8,
|
|
||||||
"max_images": 4,
|
|
||||||
"max_videos": 3,
|
|
||||||
"max_audios": 1,
|
|
||||||
"reference_mode": "style",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "minimax-h3",
|
|
||||||
"label": "MiniMax H3 (Leonardo 私有)",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"1440p"},
|
|
||||||
"max_reference_images": 8,
|
|
||||||
"max_images": 5,
|
|
||||||
"max_videos": 0,
|
|
||||||
"max_audios": 3,
|
|
||||||
"max_audio_seconds": 15,
|
|
||||||
"reference_mode": "style",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "firefly-video",
|
|
||||||
"label": "Firefly Video",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "adobe",
|
|
||||||
"durations": []string{"5s"},
|
|
||||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
|
||||||
"resolutions": []string{"540p", "720p", "1080p"},
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "runway-gen4-turbo",
|
|
||||||
"label": "Runway Gen-4 Turbo",
|
|
||||||
"type": "video",
|
|
||||||
"provider": "runway",
|
|
||||||
"durations": []string{"5s", "10s"},
|
|
||||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
|
||||||
"resolutions": []string{"2K"},
|
|
||||||
"max_reference_images": 1,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
// Runway is strictly image-to-video — a first-frame image is required
|
|
||||||
// (no text2video), so the UI must block submit without one.
|
|
||||||
"requires_reference": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||||
@@ -574,7 +481,7 @@ func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) Models(c *gin.Context) {
|
func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||||
items, err := h.publicModels()
|
items, err := h.publicModels(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||||
return
|
return
|
||||||
@@ -582,537 +489,86 @@ func (h *UserGenerationHandler) Models(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
|
// modelPreset projects one model_configs row into the preset shape the studio
|
||||||
items := []gin.H{
|
// (画图台) / model pickers consume. Database-driven now: providers are gone and
|
||||||
{
|
// every model arrives via a channel import.
|
||||||
"id": "gpt-image-2",
|
func modelPreset(m model.ModelConfig) gin.H {
|
||||||
"provider": "chatgpt",
|
durations := repo.JSONStrings(m.Durations)
|
||||||
"type": "image",
|
if len(durations) == 0 && m.DurationMin > 0 && m.DurationMax >= m.DurationMin {
|
||||||
// ChatGPT web backend only reliably produces 1K and honors a limited
|
// Range spec → expand integer seconds (capped so absurd ranges can't
|
||||||
// ratio set; size params are advisory prompt hints. Mirrors the Python
|
// flood a dropdown).
|
||||||
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
|
for d := m.DurationMin; d <= m.DurationMax && len(durations) < 61; d++ {
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
durations = append(durations, fmt.Sprintf("%ds", d))
|
||||||
"resolutions": []string{"1K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 6,
|
|
||||||
"description": "ChatGPT image generation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "firefly-gpt-image-2",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 6,
|
|
||||||
"description": "Adobe Firefly GPT Image",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "firefly-image-5",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
|
||||||
"resolutions": []string{"1K", "2K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"description": "Adobe Firefly Image 5",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runway-nano-banana-2",
|
|
||||||
"provider": "runway",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 6,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"description": "Runway Nano Banana 2 (图/参考图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runway-nano-banana-pro",
|
|
||||||
"provider": "runway",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 6,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"description": "Runway Nano Banana Pro (图/参考图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nano-banana-2",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 6,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"description": "Nano Banana 2 (图/参考图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nano-banana-pro",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 6,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"description": "Nano Banana Pro (图/参考图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-veo3.1-fast",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
|
||||||
"max_reference_images": 3,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
"description": "Veo 3.1 Fast",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-veo3.1",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"durations": []string{"4s", "6s", "8s"},
|
|
||||||
"max_reference_images": 3,
|
|
||||||
"reference_mode": "style",
|
|
||||||
"description": "Veo 3.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-veo3.1",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"durations": []string{"5s", "10s"},
|
|
||||||
"max_reference_images": 2,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
"description": "Luma Ray video",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "firefly-video",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"durations": []string{"5s"},
|
|
||||||
"max_reference_images": 2,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
"description": "Adobe Firefly Video",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "adobe-seedance-2.0-fast",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"per_second": true,
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "style",
|
|
||||||
"description": "Seedance 2.0 Fast",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "adobe-seedance-2.0",
|
|
||||||
"provider": "adobe",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"per_second": true,
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "style",
|
|
||||||
"description": "Seedance 2.0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0-fast",
|
|
||||||
"provider": "creativefabrica",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
// 一次性账号:积分刚好够一次生成,时长固定 14 秒。
|
|
||||||
"durations": []string{"14s"},
|
|
||||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0",
|
|
||||||
"provider": "creativefabrica",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
// 一次性账号:积分刚好够一次生成,时长固定 10 秒。
|
|
||||||
"durations": []string{"10s"},
|
|
||||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
|
||||||
"max_reference_images": 9,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runway-gen4-turbo",
|
|
||||||
"provider": "runway",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"durations": []string{"5s", "10s"},
|
|
||||||
"max_reference_images": 1,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
"description": "Runway Gen-4 Turbo video (图生视频)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grok-video",
|
|
||||||
"provider": "grok",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
// Console 视频吃 1–15 秒的整数时长,所以按秒计价(同 seedance);
|
|
||||||
// 参考图只有 1 张首帧(上游只有 image 字段,没有尾帧)。
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"per_second": true,
|
|
||||||
"max_reference_images": 1,
|
|
||||||
"reference_mode": "frame",
|
|
||||||
"description": "Grok Imagine video (文/图生视频)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grok-image",
|
|
||||||
"provider": "grok",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
|
||||||
"resolutions": []string{"1K", "2K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 3,
|
|
||||||
"reference_mode": "asset",
|
|
||||||
"description": "Grok Imagine image (文生图 / 图生图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedream-4.5",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
|
||||||
"resolutions": []string{"2K", "4K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 6,
|
|
||||||
"description": "Leonardo Seedream 4.5 (生图 / 图生图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0-不卡人脸",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"per_second": true,
|
|
||||||
// 参考资产总上限 8 = 4 图 + 1 音频 + 3 视频(分类上限在服务端校验)。
|
|
||||||
"max_reference_images": 8,
|
|
||||||
"reference_mode": "style",
|
|
||||||
"description": "Leonardo Seedance 2.0 (私有生成 / 图音视频参考)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0-fast-不卡人脸",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"per_second": true,
|
|
||||||
"max_reference_images": 8,
|
|
||||||
"reference_mode": "style",
|
|
||||||
"description": "Leonardo Seedance 2.0 Fast (私有生成 / 图音视频参考)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "minimax-h3",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"type": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"1440p"},
|
|
||||||
// 上游吃 5–15 秒整数时长,所以按秒计价(后台只填一个 /s 价)。
|
|
||||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
|
||||||
"per_second": true,
|
|
||||||
// 参考资产总上限 8 = 5 图 + 3 音频(分类上限在服务端校验)。
|
|
||||||
"max_reference_images": 8,
|
|
||||||
"reference_mode": "style",
|
|
||||||
"description": "Leonardo MiniMax H3 / hailuo-03 (1440p / 图音参考)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "flux-klein-2",
|
|
||||||
"provider": "krea",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
|
||||||
"resolutions": []string{"1K", "2K"},
|
|
||||||
"image_to_image": true,
|
|
||||||
"max_reference_images": 4,
|
|
||||||
"description": "Krea Flux Klein (生图 / 图生图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "imagine-1.5",
|
|
||||||
"provider": "imagine",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
|
||||||
"resolutions": []string{"2K"},
|
|
||||||
"max_reference_images": 0,
|
|
||||||
"description": "Imagine 1.5 (文生图)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "imagine-1.5pro",
|
|
||||||
"provider": "imagine",
|
|
||||||
"type": "image",
|
|
||||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
|
||||||
"resolutions": []string{"4K"},
|
|
||||||
"max_reference_images": 0,
|
|
||||||
"description": "Imagine 1.5 Pro (文生图)",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
existing := map[string]bool{}
|
}
|
||||||
if h.admin != nil {
|
_, perSecond := m.DurationPrices["per_second"]
|
||||||
|
return gin.H{
|
||||||
|
"key": m.EffectiveName(),
|
||||||
|
"id": m.ID,
|
||||||
|
"label": m.EffectiveName(),
|
||||||
|
"type": m.Type,
|
||||||
|
"provider": m.Provider,
|
||||||
|
"ratios": repo.JSONStrings(m.Ratios),
|
||||||
|
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||||
|
"durations": durations,
|
||||||
|
"duration_min": m.DurationMin,
|
||||||
|
"duration_max": m.DurationMax,
|
||||||
|
"per_second": perSecond,
|
||||||
|
"max_reference_images": m.MaxReferenceImages,
|
||||||
|
"max_images": m.MaxReferenceImages,
|
||||||
|
"max_videos": m.MaxReferenceVideos,
|
||||||
|
"max_audios": m.MaxReferenceAudios,
|
||||||
|
"max_reference_videos": m.MaxReferenceVideos,
|
||||||
|
"max_reference_audios": m.MaxReferenceAudios,
|
||||||
|
"max_video_seconds": m.MaxRefVideoSeconds,
|
||||||
|
"max_audio_seconds": m.MaxRefAudioSeconds,
|
||||||
|
"reference_mode": m.ReferenceMode,
|
||||||
|
"image_to_image": m.ImageToImage,
|
||||||
|
"description": m.Name,
|
||||||
|
"added": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogEntries lists the managed models (previously a static picklist of
|
||||||
|
// offerable provider models). With channel imports the DB IS the catalog —
|
||||||
|
// rows are already "added", so the admin picklist degrades gracefully.
|
||||||
|
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
|
||||||
models, err := h.admin.Models(c.Request.Context())
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, item := range models {
|
items := make([]gin.H, 0, len(models))
|
||||||
existing[item.ID] = true
|
for _, m := range models {
|
||||||
}
|
row := modelPreset(m)
|
||||||
}
|
row["id"] = m.EffectiveName()
|
||||||
for i := range items {
|
items = append(items, row)
|
||||||
items[i]["added"] = existing[items[i]["id"].(string)]
|
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
||||||
items := []gin.H{
|
// Fully-priced models only (配价即上架), same shape as before.
|
||||||
{
|
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
||||||
"id": "gpt-image-2",
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
"provider": "chatgpt",
|
if err != nil {
|
||||||
"kind": "image",
|
return nil, err
|
||||||
// See catalogEntries — ChatGPT only reliably does 1K and a limited
|
}
|
||||||
// ratio set; matches the Python reference. Keep both lists in sync.
|
items := make([]gin.H, 0, len(models))
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
for _, m := range models {
|
||||||
"resolutions": []string{"1K"},
|
// 配价即上架:未配齐价的模型对前台不可见(绑定/配价都满足才进 /v1)。
|
||||||
"description": "ChatGPT image generation",
|
if !service.ModelFullyPriced(&m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, gin.H{
|
||||||
|
"id": m.EffectiveName(),
|
||||||
|
"kind": m.Type,
|
||||||
|
"type": m.Type,
|
||||||
|
"ratios": repo.JSONStrings(m.Ratios),
|
||||||
|
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||||
|
"durations": repo.JSONStrings(m.Durations),
|
||||||
|
"description": m.Name,
|
||||||
"stub": false,
|
"stub": false,
|
||||||
},
|
})
|
||||||
{
|
|
||||||
"id": "firefly-gpt-image-2",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"description": "Adobe Firefly GPT Image",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "firefly-image-5",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
|
||||||
"resolutions": []string{"1K", "2K"},
|
|
||||||
"description": "Adobe Firefly Image 5",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runway-nano-banana-2",
|
|
||||||
"provider": "runway",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"description": "Runway Nano Banana 2",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runway-nano-banana-pro",
|
|
||||||
"provider": "runway",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"description": "Runway Nano Banana Pro",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nano-banana-2",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"description": "Nano Banana 2",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nano-banana-pro",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
|
||||||
"resolutions": []string{"1K", "2K", "4K"},
|
|
||||||
"description": "Nano Banana Pro",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-veo3.1-fast",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"description": "Veo 3.1 Fast",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "gemini-veo3.1",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Veo 3.1",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "firefly-ray",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Luma Ray video",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "firefly-video",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"description": "Adobe Firefly Video",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "adobe-seedance-2.0-fast",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Seedance 2.0 Fast",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "adobe-seedance-2.0",
|
|
||||||
"provider": "adobe",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p", "1080p"},
|
|
||||||
"description": "Seedance 2.0",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0-fast",
|
|
||||||
"provider": "creativefabrica",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0",
|
|
||||||
"provider": "creativefabrica",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runway-gen4-turbo",
|
|
||||||
"provider": "runway",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Runway Gen-4 Turbo video",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grok-video",
|
|
||||||
"provider": "grok",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Grok Imagine video",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grok-image",
|
|
||||||
"provider": "grok",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
|
||||||
"resolutions": []string{"1K", "2K"},
|
|
||||||
"description": "Grok Imagine image",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedream-4.5",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
|
||||||
"resolutions": []string{"2K", "4K"},
|
|
||||||
"description": "Leonardo Seedream 4.5",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0-不卡人脸",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Leonardo Seedance 2.0 (私有生成)",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seedance-2.0-fast-不卡人脸",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"720p"},
|
|
||||||
"description": "Leonardo Seedance 2.0 Fast (私有生成)",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "minimax-h3",
|
|
||||||
"provider": "leonardo",
|
|
||||||
"kind": "video",
|
|
||||||
"ratios": []string{"16:9", "9:16"},
|
|
||||||
"resolutions": []string{"1440p"},
|
|
||||||
"description": "Leonardo MiniMax H3 (1440p)",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "flux-klein-2",
|
|
||||||
"provider": "krea",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
|
||||||
"resolutions": []string{"1K", "2K"},
|
|
||||||
"description": "Krea Flux Klein",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "imagine-1.5",
|
|
||||||
"provider": "imagine",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
|
||||||
"resolutions": []string{"2K"},
|
|
||||||
"description": "Imagine 1.5",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "imagine-1.5pro",
|
|
||||||
"provider": "imagine",
|
|
||||||
"kind": "image",
|
|
||||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
|
||||||
"resolutions": []string{"4K"},
|
|
||||||
"description": "Imagine 1.5 Pro",
|
|
||||||
"stub": false,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
|
// Package handler — /v1/* 消费者 HTTP 接口(vivid 协议同款)。
|
||||||
|
// 这套接口同时是渠道上游契约:同构实例经相同的四个端点互相对接。
|
||||||
|
//
|
||||||
|
// POST /v1/generate 创建生成任务(NDJSON 流:首帧 jobId → 终态帧;断流携 jobId 重发续等)
|
||||||
|
// POST /v1/uploads 参考素材预签名直传(返回 key 随 generate 的 refs 提交)
|
||||||
|
// GET /v1/models 可售模型裸数组(启用 + 有渠道绑定 + 全部档位已配价)
|
||||||
|
// GET /v1/balance {balance}:余额积分
|
||||||
|
//
|
||||||
|
// 鉴权:Authorization: Bearer vk_...(用户 API Key,哈希落库)。
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backend/internal/model"
|
||||||
"backend/internal/service"
|
"backend/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// v1MaxWait 同步等待终态的上限。视频单渠道预算 30 分钟、最多换几个渠道,
|
||||||
|
// 留足余量取 2 小时(与 vivid 一致)。
|
||||||
|
const v1MaxWait = 2 * time.Hour
|
||||||
|
|
||||||
|
const v1PollInterval = time.Second
|
||||||
|
|
||||||
type V1Handler struct {
|
type V1Handler struct {
|
||||||
v1 *service.V1Service
|
v1 *service.V1Service
|
||||||
}
|
}
|
||||||
@@ -22,385 +36,213 @@ func NewV1Handler(v1 *service.V1Service) *V1Handler {
|
|||||||
return &V1Handler{v1: v1}
|
return &V1Handler{v1: v1}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *V1Handler) Models(c *gin.Context) {
|
func (h *V1Handler) auth(c *gin.Context) (*service.APIPrincipal, bool) {
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeAuthError(c, err)
|
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||||
return
|
return nil, false
|
||||||
|
}
|
||||||
|
return principal, true
|
||||||
}
|
}
|
||||||
_ = principal
|
|
||||||
|
|
||||||
items, err := h.v1.ListModels(c.Request.Context())
|
// v1Err writes the {"error":{"code","message"}} envelope — the wire shape every
|
||||||
if err != nil {
|
// vivid-protocol caller parses.
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
func v1Err(c *gin.Context, httpStatus int, code, msg string) {
|
||||||
return
|
c.JSON(httpStatus, gin.H{
|
||||||
}
|
"error": gin.H{"code": code, "message": msg},
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"object": "list",
|
|
||||||
"data": items,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserBalance — GET /v1/user/balance. 返回 API Key 所属用户的账户级余额
|
// v1Fail maps a service error to the vivid code/HTTP status pair.
|
||||||
// (剩余 / 累计已用),与具体令牌无关。
|
func v1Fail(c *gin.Context, err error) {
|
||||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
switch {
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
case errors.Is(err, service.ErrMissingAPIKey), errors.Is(err, service.ErrInvalidAPIKey):
|
||||||
if err != nil {
|
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||||
h.writeAuthError(c, err)
|
case errors.Is(err, service.ErrInsufficientFunds):
|
||||||
return
|
v1Err(c, http.StatusBadRequest, "4002", "积分不足,请先充值")
|
||||||
|
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||||
|
v1Err(c, http.StatusTooManyRequests, "4293", "当前并发已满,请稍后重试")
|
||||||
|
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", "任务不存在")
|
||||||
|
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||||
|
v1Err(c, http.StatusRequestEntityTooLarge, "4001", "参考素材超过大小上限")
|
||||||
|
case errors.Is(err, service.ErrUnknownModel):
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", "模型不存在或未启用")
|
||||||
|
case errors.Is(err, service.ErrNoProviderAccount):
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", "模型无可用渠道")
|
||||||
|
case errors.Is(err, service.ErrBannedPrompt):
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", "提示词包含违禁内容")
|
||||||
|
case errors.Is(err, service.ErrUnsupportedParams):
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||||
|
case errors.Is(err, service.ErrProviderTemporary), errors.Is(err, service.ErrProviderExecution),
|
||||||
|
errors.Is(err, service.ErrProviderAuth):
|
||||||
|
v1Err(c, http.StatusInternalServerError, "5001", "生成暂时不可用,积分已退还")
|
||||||
|
default:
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||||
}
|
}
|
||||||
resp, err := h.v1.UserBalance(c.Request.Context(), principal)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load balance"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only).
|
// ---- POST /v1/generate ----
|
||||||
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
|
|
||||||
// are mapped server-side. Returns {created, data:[{b64_json}]}.
|
func (h *V1Handler) Generate(c *gin.Context) {
|
||||||
func (h *V1Handler) ImageGenerations(c *gin.Context) {
|
principal, ok := h.auth(c)
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
if !ok {
|
||||||
if err != nil {
|
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var body service.V1JobRequest
|
||||||
var body struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
N int `json:"n"`
|
|
||||||
Size string `json:"size"`
|
|
||||||
Quality string `json:"quality"`
|
|
||||||
ResponseFormat string `json:"response_format"`
|
|
||||||
Background string `json:"background"`
|
|
||||||
OutputFormat string `json:"output_format"`
|
|
||||||
User string `json:"user"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var ev *model.EventLog
|
||||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
var err error
|
||||||
Model: body.Model,
|
// 重连模式:只带 jobId → 不建新任务,回读该任务并等待终态。
|
||||||
Prompt: body.Prompt,
|
if body.JobID != nil && strings.TrimSpace(*body.JobID) != "" {
|
||||||
N: body.N,
|
ev, err = h.v1.JobForUser(c.Request.Context(), principal, *body.JobID)
|
||||||
Size: body.Size,
|
|
||||||
BaseURL: requestBaseURL(c),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
h.writeV1Error(c, err, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageEdits — OpenAI POST /v1/images/edits (image-to-image). multipart/form-data
|
|
||||||
// only: image / image[] file uploads (+ optional mask), prompt, model, n, size,
|
|
||||||
// quality. Files become reference images. Returns {created, data:[{b64_json}]}.
|
|
||||||
func (h *V1Handler) ImageEdits(c *gin.Context) {
|
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
|
||||||
if err != nil {
|
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires multipart/form-data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refs := readMultipartImages(c, "image", "image[]")
|
|
||||||
if len(refs) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires at least one image file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("n")))
|
|
||||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
|
||||||
Model: c.PostForm("model"),
|
|
||||||
Prompt: c.PostForm("prompt"),
|
|
||||||
N: n,
|
|
||||||
Size: c.PostForm("size"),
|
|
||||||
ReferenceImages: refs,
|
|
||||||
BaseURL: requestBaseURL(c),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
h.writeV1Error(c, err, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateVideo — OpenAI POST /v1/videos. Creates an async job and returns the
|
|
||||||
// video object immediately ({id, status:"queued"}). Accepts JSON {model, prompt,
|
|
||||||
// seconds, size} or multipart (with an input_reference file). size→ratio+
|
|
||||||
// resolution, seconds→duration.
|
|
||||||
func (h *V1Handler) CreateVideo(c *gin.Context) {
|
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
|
||||||
if err != nil {
|
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var modelID, prompt, seconds, size, referenceMode string
|
|
||||||
var refs []string
|
|
||||||
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
|
||||||
if err := c.Request.ParseMultipartForm(256 << 20); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelID = c.PostForm("model")
|
|
||||||
prompt = c.PostForm("prompt")
|
|
||||||
seconds = c.PostForm("seconds")
|
|
||||||
size = c.PostForm("size")
|
|
||||||
referenceMode = c.PostForm("reference_mode")
|
|
||||||
refs = readMultipartImages(c, "input_reference", "input_reference[]")
|
|
||||||
} else {
|
} else {
|
||||||
var body struct {
|
ev, err = h.v1.CreateJob(c.Request.Context(), principal, body)
|
||||||
Model string `json:"model"`
|
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
Seconds json.RawMessage `json:"seconds"`
|
|
||||||
Size string `json:"size"`
|
|
||||||
ReferenceMode string `json:"reference_mode"`
|
|
||||||
InputReference []string `json:"input_reference"`
|
|
||||||
ReferenceImages []string `json:"reference_images"`
|
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modelID, prompt, size = body.Model, body.Prompt, body.Size
|
|
||||||
seconds = rawToString(body.Seconds)
|
|
||||||
referenceMode = strings.TrimSpace(body.ReferenceMode)
|
|
||||||
refs = append(body.InputReference, body.ReferenceImages...)
|
|
||||||
}
|
|
||||||
duration := strings.TrimSpace(seconds)
|
|
||||||
if duration != "" && !strings.HasSuffix(duration, "s") {
|
|
||||||
duration += "s"
|
|
||||||
}
|
|
||||||
aspect, resolution := videoSizeToInternal(size)
|
|
||||||
resp, err := h.v1.StartVideoJob(c.Request.Context(), principal, service.V1VideoRequest{
|
|
||||||
Model: modelID,
|
|
||||||
Prompt: prompt,
|
|
||||||
Duration: duration,
|
|
||||||
AspectRatio: aspect,
|
|
||||||
Resolution: resolution,
|
|
||||||
ReferenceImages: refs,
|
|
||||||
ReferenceMode: referenceMode,
|
|
||||||
BaseURL: requestBaseURL(c),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeV1Error(c, err, nil)
|
v1Fail(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object.
|
wait := 300 * time.Second
|
||||||
func (h *V1Handler) GetVideo(c *gin.Context) {
|
if h := strings.TrimSpace(c.GetHeader("X-Wait-Seconds")); h != "" {
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
if sec, serr := strconv.Atoi(h); serr == nil && sec > 0 && time.Duration(sec)*time.Second <= v1MaxWait {
|
||||||
if err != nil {
|
wait = time.Duration(sec) * time.Second
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
h.writeV1Error(c, err, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4
|
c.Writer.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
// by proxying the stored upstream URL (downloaded on demand, never persisted).
|
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||||
func (h *V1Handler) GetVideoContent(c *gin.Context) {
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
c.Writer.WriteHeader(http.StatusOK)
|
||||||
if err != nil {
|
flusher, _ := c.Writer.(http.Flusher)
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
writeFrame := func(v *model.EventLog) bool {
|
||||||
|
frame, _ := json.Marshal(v1JobBody(h.v1, v))
|
||||||
|
if _, werr := c.Writer.Write(append(frame, '\n')); werr != nil {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id"))
|
if flusher != nil {
|
||||||
if err != nil {
|
flusher.Flush()
|
||||||
h.writeV1Error(c, err, nil)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer body.Close()
|
return true
|
||||||
c.Header("Content-Type", contentType)
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
_, _ = io.Copy(c.Writer, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by
|
// 已终态:直接回终态帧关流。
|
||||||
// proxying its stored (possibly auth-gated) upstream URL. Never persisted.
|
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||||
func (h *V1Handler) GetImageContent(c *gin.Context) {
|
writeFrame(ev)
|
||||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
|
||||||
if err != nil {
|
|
||||||
h.writeAuthError(c, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
|
// 连上即回执 jobId(断流后携它重发的凭据)。
|
||||||
if err != nil {
|
if !writeFrame(ev) {
|
||||||
h.writeV1Error(c, err, nil)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer body.Close()
|
|
||||||
c.Header("Content-Type", contentType)
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
_, _ = io.Copy(c.Writer, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readMultipartImages reads the given file fields and returns each as base64.
|
deadline := time.Now().Add(wait)
|
||||||
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
poll := time.NewTicker(v1PollInterval)
|
||||||
var out []string
|
heartbeat := time.NewTicker(25 * time.Second)
|
||||||
form := c.Request.MultipartForm
|
defer poll.Stop()
|
||||||
if form == nil {
|
defer heartbeat.Stop()
|
||||||
return out
|
for {
|
||||||
}
|
select {
|
||||||
for _, key := range keys {
|
case <-c.Request.Context().Done():
|
||||||
for _, fh := range form.File[key] {
|
return // client gone — the job keeps running; caller may reconnect with jobId
|
||||||
f, e := fh.Open()
|
case <-poll.C:
|
||||||
if e != nil {
|
latest, lerr := h.v1.JobForUser(c.Request.Context(), principal, ev.ID)
|
||||||
|
if lerr != nil || latest == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1))
|
ev = latest
|
||||||
f.Close()
|
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||||
if len(b) > 0 {
|
writeFrame(ev)
|
||||||
out = append(out, base64.StdEncoding.EncodeToString(b))
|
return
|
||||||
}
|
}
|
||||||
|
case <-heartbeat.C:
|
||||||
|
if _, werr := c.Writer.WriteString("\n"); werr != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
return out
|
case <-time.After(time.Until(deadline)):
|
||||||
|
writeFrame(ev) // snapshot; client reconnects with jobId
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number.
|
|
||||||
func rawToString(raw json.RawMessage) string {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var s string
|
|
||||||
if json.Unmarshal(raw, &s) == nil {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
var n json.Number
|
|
||||||
if json.Unmarshal(raw, &n) == nil {
|
|
||||||
return n.String()
|
|
||||||
}
|
|
||||||
return strings.Trim(string(raw), `"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// videoSizeToInternal maps OpenAI's "WxH" size to our aspect ratio + resolution
|
|
||||||
// tier. An absent/unparsable size leaves the resolution empty so the caller can
|
|
||||||
// fall back to whatever tier the model actually prices — hardcoding 720p here
|
|
||||||
// rejects models that only offer 1440p.
|
|
||||||
func videoSizeToInternal(size string) (ratio, resolution string) {
|
|
||||||
var w, h int
|
|
||||||
if s := strings.TrimSpace(strings.ToLower(size)); s != "" {
|
|
||||||
_, _ = fmt.Sscanf(s, "%dx%d", &w, &h)
|
|
||||||
}
|
|
||||||
if w == 0 || h == 0 {
|
|
||||||
return "16:9", ""
|
|
||||||
}
|
|
||||||
// The "p" resolution is the SHORT edge (720p = 1280×720, 1080p = 1920×1080),
|
|
||||||
// so a standard 1280×720 must read as 720p — not 1080p off the long edge.
|
|
||||||
resolution = "720p"
|
|
||||||
switch {
|
|
||||||
case min(w, h) >= 1440:
|
|
||||||
resolution = "1440p"
|
|
||||||
case min(w, h) >= 1080:
|
|
||||||
resolution = "1080p"
|
|
||||||
}
|
|
||||||
return guessRatioWH(w, h), resolution
|
|
||||||
}
|
|
||||||
|
|
||||||
func guessRatioWH(w, h int) string {
|
|
||||||
if w == h {
|
|
||||||
return "1:1"
|
|
||||||
}
|
|
||||||
r := float64(w) / float64(h)
|
|
||||||
cands := []struct {
|
|
||||||
name string
|
|
||||||
v float64
|
|
||||||
}{{"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"4:3", 4.0 / 3}, {"3:4", 3.0 / 4}, {"1:1", 1}}
|
|
||||||
best, bestD := "16:9", 1e9
|
|
||||||
for _, cd := range cands {
|
|
||||||
d := r - cd.v
|
|
||||||
if d < 0 {
|
|
||||||
d = -d
|
|
||||||
}
|
|
||||||
if d < bestD {
|
|
||||||
best, bestD = cd.name, d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// openaiImageResponse strips our rich internal map down to OpenAI's image shape.
|
|
||||||
func openaiImageResponse(m map[string]any) gin.H {
|
|
||||||
out := gin.H{"created": m["created"]}
|
|
||||||
if d, ok := m["data"]; ok && d != nil {
|
|
||||||
out["data"] = d
|
|
||||||
} else {
|
|
||||||
out["data"] = []any{}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *V1Handler) writeAuthError(c *gin.Context, err error) {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, service.ErrMissingAPIKey):
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrInvalidAPIKey):
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate api key"})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]any) {
|
// v1JobBody assembles one NDJSON frame (running receipt and terminal frame
|
||||||
switch {
|
// share the shape; succeeded adds result+creditsCharged, failed adds error).
|
||||||
case errors.Is(err, service.ErrUnknownModel):
|
func v1JobBody(v1 *service.V1Service, ev *model.EventLog) map[string]any {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
body := map[string]any{"jobId": ev.ID, "status": service.JobStatus(ev)}
|
||||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
switch service.JobStatus(ev) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
case "succeeded":
|
||||||
case errors.Is(err, service.ErrInsufficientFunds):
|
body["result"] = v1.JobResultURL(ev)
|
||||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
|
body["creditsCharged"] = ev.Cost
|
||||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
case "failed":
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
|
body["error"] = ev.Error
|
||||||
case errors.Is(err, service.ErrNoProviderAccount):
|
body["refunded"] = true
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrProviderAuth):
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrProviderQuota):
|
|
||||||
// Match the Python contract: provider quota exhaustion maps to 401
|
|
||||||
// (QuotaExhaustedError is handled alongside AuthError in routes.py).
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrProviderTemporary):
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
|
||||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrVideoNotReady):
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrProviderUnsupported):
|
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrProviderExecution):
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
|
||||||
case errors.Is(err, service.ErrGenerationPending):
|
|
||||||
c.JSON(http.StatusNotImplemented, payload)
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
||||||
}
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- POST /v1/uploads ----
|
||||||
|
|
||||||
|
func (h *V1Handler) Uploads(c *gin.Context) {
|
||||||
|
principal, ok := h.auth(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Mime string `json:"mime"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.v1.PresignRefUpload(c.Request.Context(), principal, body.Kind, body.Mime)
|
||||||
|
if err != nil {
|
||||||
|
v1Fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET /v1/models ----
|
||||||
|
|
||||||
|
func (h *V1Handler) Models(c *gin.Context) {
|
||||||
|
if _, ok := h.auth(c); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.v1.SellableModels(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
v1Err(c, http.StatusInternalServerError, "5001", "模型加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET /v1/balance ----
|
||||||
|
|
||||||
|
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||||
|
principal, ok := h.auth(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bal, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||||
|
if err != nil {
|
||||||
|
v1Err(c, http.StatusInternalServerError, "5001", "余额查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"balance": bal})
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestBaseURL derives the scheme+host of the inbound request so the service
|
// requestBaseURL derives the scheme+host of the inbound request so the service
|
||||||
// layer can build absolute, directly-downloadable output URLs. Honors
|
// layer can build absolute /images URLs for the session path.
|
||||||
// X-Forwarded-Proto (reverse-proxy / TLS termination) before falling back to
|
|
||||||
// the connection's TLS state. Returns "" when the host is unknown, which makes
|
|
||||||
// the service fall back to a relative path.
|
|
||||||
func requestBaseURL(c *gin.Context) string {
|
func requestBaseURL(c *gin.Context) string {
|
||||||
host := c.Request.Host
|
host := c.Request.Host
|
||||||
if host == "" {
|
if host == "" {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type Handlers struct {
|
|||||||
CDK *handler.CDKHandler
|
CDK *handler.CDKHandler
|
||||||
UserTools *handler.UserToolsHandler
|
UserTools *handler.UserToolsHandler
|
||||||
UserGen *handler.UserGenerationHandler
|
UserGen *handler.UserGenerationHandler
|
||||||
ProviderAdmin *handler.ProviderAdminHandler
|
ChannelAdmin *handler.ChannelAdminHandler
|
||||||
ConcGroups *handler.ConcurrencyGroupHandler
|
ConcGroups *handler.ConcurrencyGroupHandler
|
||||||
Announcement *handler.AnnouncementHandler
|
Announcement *handler.AnnouncementHandler
|
||||||
Payment *handler.PaymentHandler
|
Payment *handler.PaymentHandler
|
||||||
@@ -59,16 +59,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
|
|
||||||
engine.GET("/health", handlers.Health.Handle)
|
engine.GET("/health", handlers.Health.Handle)
|
||||||
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
||||||
|
// vivid-protocol public API — same four endpoints a vivid instance exposes,
|
||||||
|
// which is what lets this service act as a channel upstream itself.
|
||||||
|
engine.POST("/v1/generate", handlers.V1.Generate)
|
||||||
|
engine.POST("/v1/uploads", handlers.V1.Uploads)
|
||||||
engine.GET("/v1/models", handlers.V1.Models)
|
engine.GET("/v1/models", handlers.V1.Models)
|
||||||
engine.GET("/v1/user/balance", handlers.V1.UserBalance)
|
engine.GET("/v1/balance", handlers.V1.UserBalance)
|
||||||
engine.POST("/v1/images/generations", handlers.V1.ImageGenerations)
|
|
||||||
engine.POST("/v1/images/edits", handlers.V1.ImageEdits)
|
|
||||||
// OpenAI Sora-style async video: create job → poll → stream content.
|
|
||||||
engine.POST("/v1/videos", handlers.V1.CreateVideo)
|
|
||||||
engine.GET("/v1/videos/:id", handlers.V1.GetVideo)
|
|
||||||
engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent)
|
|
||||||
// No-store image content proxy (auth-gated upstream URLs, e.g. chatgpt).
|
|
||||||
engine.GET("/v1/images/:id/content", handlers.V1.GetImageContent)
|
|
||||||
|
|
||||||
publicAdmin := engine.Group("/admin/api")
|
publicAdmin := engine.Group("/admin/api")
|
||||||
{
|
{
|
||||||
@@ -79,7 +75,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
// owner names is admin-only, see /stats/recent below.
|
// owner names is admin-only, see /stats/recent below.
|
||||||
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
|
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
|
||||||
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
|
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
|
||||||
publicAdmin.GET("/catalog", handlers.UserGen.Catalog)
|
|
||||||
publicAdmin.GET("/models", handlers.UserGen.Models)
|
publicAdmin.GET("/models", handlers.UserGen.Models)
|
||||||
publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet)
|
publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet)
|
||||||
// 易支付 async notify — called server-to-server by the pay platform, no auth.
|
// 易支付 async notify — called server-to-server by the pay platform, no auth.
|
||||||
@@ -105,6 +100,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
userAuthed.POST("/test", handlers.UserGen.Test)
|
userAuthed.POST("/test", handlers.UserGen.Test)
|
||||||
userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs)
|
userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs)
|
||||||
userAuthed.GET("/my-images", handlers.UserGen.MyImages)
|
userAuthed.GET("/my-images", handlers.UserGen.MyImages)
|
||||||
|
userAuthed.POST("/my-images", handlers.UserGen.UploadMyImage)
|
||||||
userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile)
|
userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile)
|
||||||
userAuthed.GET("/announcement", handlers.Announcement.Get)
|
userAuthed.GET("/announcement", handlers.Announcement.Get)
|
||||||
userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen)
|
userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen)
|
||||||
@@ -146,23 +142,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
authed.POST("/cdks", handlers.CDK.Create)
|
authed.POST("/cdks", handlers.CDK.Create)
|
||||||
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
||||||
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
|
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
|
||||||
authed.GET("/tokens", handlers.ProviderAdmin.TokensList)
|
authed.GET("/channels", handlers.ChannelAdmin.List)
|
||||||
authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate)
|
authed.POST("/channels", handlers.ChannelAdmin.Save)
|
||||||
authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken)
|
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
||||||
authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie)
|
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
||||||
authed.POST("/tokens/import-creativefabrica-cookie", handlers.ProviderAdmin.ImportCreativeFabricaCookie)
|
authed.POST("/channels/sync-models", handlers.ChannelAdmin.SyncModels)
|
||||||
authed.POST("/tokens/import-runway-token", handlers.ProviderAdmin.ImportRunwayToken)
|
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
||||||
authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie)
|
|
||||||
authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie)
|
|
||||||
authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken)
|
|
||||||
authed.POST("/tokens/import-grok-token", handlers.ProviderAdmin.ImportGrokToken)
|
|
||||||
authed.POST("/tokens/import-custom-account", handlers.ProviderAdmin.ImportCustomAccount)
|
|
||||||
authed.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk)
|
|
||||||
authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate)
|
|
||||||
authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete)
|
|
||||||
authed.GET("/accounts", handlers.ProviderAdmin.AccountsList)
|
|
||||||
authed.GET("/accounts/:pool/:id/quota", handlers.ProviderAdmin.AccountQuota)
|
|
||||||
authed.GET("/accounts/:pool/:id/email", handlers.ProviderAdmin.AccountEmail)
|
|
||||||
authed.GET("/providers", handlers.AdminRead.Providers)
|
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||||
authed.GET("/images", handlers.AdminRead.Images)
|
authed.GET("/images", handlers.AdminRead.Images)
|
||||||
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
||||||
@@ -171,10 +156,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
authed.POST("/banned-words/import", handlers.BannedWords.Import)
|
authed.POST("/banned-words/import", handlers.BannedWords.Import)
|
||||||
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
|
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
|
||||||
authed.GET("/banned-word-hits", handlers.BannedWords.Hits)
|
authed.GET("/banned-word-hits", handlers.BannedWords.Hits)
|
||||||
authed.GET("/refresh/profiles", handlers.ProviderAdmin.RefreshProfiles)
|
|
||||||
authed.POST("/refresh/profiles/:profile_id/refresh-now", handlers.ProviderAdmin.RefreshNow)
|
|
||||||
authed.PATCH("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshUpdate)
|
|
||||||
authed.DELETE("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshDelete)
|
|
||||||
authed.POST("/managed-models", handlers.AdminWrite.CreateModel)
|
authed.POST("/managed-models", handlers.AdminWrite.CreateModel)
|
||||||
authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
|
authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
|
||||||
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -97,15 +98,16 @@ type EventLog struct {
|
|||||||
DeAI bool `gorm:"not null;default:false"` // 去AI特征 was applied (image only)
|
DeAI bool `gorm:"not null;default:false"` // 去AI特征 was applied (image only)
|
||||||
RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload
|
RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload
|
||||||
Source string `gorm:"size:32;index"`
|
Source string `gorm:"size:32;index"`
|
||||||
// AccountID is the provider token/account chosen to fulfil this generation,
|
// AccountID is the channel chosen to fulfil this generation, stamped when
|
||||||
// stamped when the upstream call begins. Drives the accounts view's live
|
// the upstream call begins. (Field name kept from the pre-channel schema.)
|
||||||
// in-flight count (pending events per account) and lets an abandoned-event
|
|
||||||
// purge attribute the failure back to the account it was using.
|
|
||||||
AccountID string `gorm:"size:64;index"`
|
AccountID string `gorm:"size:64;index"`
|
||||||
// AccountEmail denormalizes the account's email at stamp time, so log rows
|
// AccountEmail denormalizes the channel's display name at stamp time, so log
|
||||||
// keep showing which mailbox fulfilled the generation even after the account
|
// rows keep showing which channel fulfilled the generation even after the
|
||||||
// is deleted or re-imported under a different ID.
|
// channel is deleted or renamed.
|
||||||
AccountEmail string `gorm:"size:255"`
|
AccountEmail string `gorm:"size:255"`
|
||||||
|
// UpstreamJob is the job id assigned by the upstream vivid instance (from
|
||||||
|
// the NDJSON stream's jobId field) — kept for cross-instance debugging.
|
||||||
|
UpstreamJob string `gorm:"size:64"`
|
||||||
UserID string `gorm:"size:32;index"`
|
UserID string `gorm:"size:32;index"`
|
||||||
Cost float64 `gorm:"not null;default:0"`
|
Cost float64 `gorm:"not null;default:0"`
|
||||||
// Refunded marks that this event's up-front charge has already been credited
|
// Refunded marks that this event's up-front charge has already been credited
|
||||||
@@ -125,7 +127,6 @@ type ModelConfig struct {
|
|||||||
Name string `gorm:"size:255;not null"`
|
Name string `gorm:"size:255;not null"`
|
||||||
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
||||||
Provider string `gorm:"size:100;index;not null"`
|
Provider string `gorm:"size:100;index;not null"`
|
||||||
Enabled bool `gorm:"not null;default:true"`
|
|
||||||
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
|
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
|
||||||
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
||||||
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||||
@@ -138,12 +139,20 @@ type ModelConfig struct {
|
|||||||
PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"`
|
PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"`
|
||||||
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
|
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
|
||||||
Durations datatypes.JSON `gorm:"type:jsonb"`
|
Durations datatypes.JSON `gorm:"type:jsonb"`
|
||||||
|
// DurationMin/DurationMax bound a free-form duration (seconds) for models
|
||||||
|
// whose duration spec is a range rather than discrete options. 0 = unset.
|
||||||
|
DurationMin int `gorm:"not null;default:0"`
|
||||||
|
DurationMax int `gorm:"not null;default:0"`
|
||||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||||
|
// Per-kind reference caps for upstreams that accept video/audio refs.
|
||||||
|
// 0 = that kind is not accepted.
|
||||||
|
MaxReferenceVideos int `gorm:"not null;default:0"`
|
||||||
|
MaxReferenceAudios int `gorm:"not null;default:0"`
|
||||||
|
// MaxRefVideoSeconds/MaxRefAudioSeconds cap the combined duration of
|
||||||
|
// reference videos/audios. 0 = unlimited.
|
||||||
|
MaxRefVideoSeconds int `gorm:"not null;default:0"`
|
||||||
|
MaxRefAudioSeconds int `gorm:"not null;default:0"`
|
||||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
||||||
// Custom-upstream models (provider="custom"): UpstreamModel is the model name
|
|
||||||
// sent to the upstream OpenAI-compatible API; the base_url + key live on the
|
|
||||||
// matching custom account (pool="custom", meta.base_url). Empty for built-ins.
|
|
||||||
UpstreamModel string `gorm:"size:255;not null;default:''"`
|
|
||||||
// Weight controls display order in the model dropdown / admin list: higher
|
// Weight controls display order in the model dropdown / admin list: higher
|
||||||
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
||||||
// back to created_at desc. Default 0.
|
// back to created_at desc. Default 0.
|
||||||
@@ -152,6 +161,10 @@ type ModelConfig struct {
|
|||||||
// successful generation. Independent of the event_log (which is subject to
|
// successful generation. Independent of the event_log (which is subject to
|
||||||
// retention / manual clearing), so the admin "次数" is a true running total.
|
// retention / manual clearing), so the admin "次数" is a true running total.
|
||||||
GenerationCount int64 `gorm:"not null;default:0"`
|
GenerationCount int64 `gorm:"not null;default:0"`
|
||||||
|
// DeletedAt soft-deletes the row (vivid parity): hidden from every normal
|
||||||
|
// path, but the row + its counters survive so a re-import revives it
|
||||||
|
// without losing generation_count or pricing.
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -176,53 +189,35 @@ type CDKCode struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenAccount struct {
|
// Channel is one upstream vivid-protocol instance this gateway routes
|
||||||
ID string `gorm:"primaryKey;size:64"`
|
// generation to. Replaces the old per-provider token account pool: a channel
|
||||||
Pool string `gorm:"size:64;index;not null"`
|
// is just a base_url + api_key plus routing knobs (enabled/weight/concurrency)
|
||||||
Value string `gorm:"type:text"`
|
// and local usage counters. ApiKey is stored AES-GCM encrypted.
|
||||||
Status string `gorm:"size:32;index;not null"`
|
type Channel struct {
|
||||||
Fails int `gorm:"not null;default:0"`
|
ID string `gorm:"primaryKey;size:40"`
|
||||||
FailTotal int `gorm:"not null;default:0"`
|
Name string `gorm:"size:255;not null"`
|
||||||
SuccessTotal int `gorm:"not null;default:0"`
|
BaseURL string `gorm:"size:500;not null"`
|
||||||
Dead bool `gorm:"not null;default:false"`
|
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
|
||||||
Meta datatypes.JSONMap `gorm:"type:jsonb"`
|
Enabled bool `gorm:"not null;default:true;index"`
|
||||||
AddedAt *time.Time
|
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = unlimited.
|
||||||
LastUsedAt *time.Time
|
|
||||||
CachedQuotaResetAfter string `gorm:"size:128"`
|
|
||||||
QuotaRecoverAt *time.Time
|
|
||||||
// Adobe quota is tracked separately for image vs video. An account only
|
|
||||||
// enters the shared "quota" waiting status when BOTH are limited; a single
|
|
||||||
// limit leaves the account usable for the other kind. Recovery time is shared
|
|
||||||
// (QuotaRecoverAt / CachedQuotaResetAfter) since Adobe resets both at once.
|
|
||||||
ImageLimited bool `gorm:"not null;default:false"`
|
|
||||||
VideoLimited bool `gorm:"not null;default:false"`
|
|
||||||
AccountEmail string `gorm:"size:255"`
|
|
||||||
AccountDisplayName string `gorm:"size:255"`
|
|
||||||
// Weight biases scheduling order for ANY account — higher weight is picked
|
|
||||||
// first within its pool (ties fall back to round-robin). Default 0.
|
|
||||||
Weight int `gorm:"not null;default:0"`
|
|
||||||
// Concurrency is the max simultaneous jobs for THIS account. Only custom
|
|
||||||
// (upstream) accounts honor it; built-in pools use their system default
|
|
||||||
// (1 per account, grok 10). 0 = use the system default.
|
|
||||||
Concurrency int `gorm:"not null;default:0"`
|
Concurrency int `gorm:"not null;default:0"`
|
||||||
|
// Weight biases weighted-random channel order: weight 2 is picked first at
|
||||||
|
// roughly 2x the rate of weight 1.
|
||||||
|
Weight int `gorm:"not null;default:1"`
|
||||||
|
SuccessTotal int64 `gorm:"not null;default:0"` // locally-tracked successful generations
|
||||||
|
UsedCredits float64 `gorm:"not null;default:0"` // upstream credits spent (reported by the upstream)
|
||||||
|
LastUsedAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefreshProfile struct {
|
// ChannelModel binds a local model_configs row to a channel and records the
|
||||||
ID string `gorm:"primaryKey;size:64"`
|
// upstream model name to send on the wire (the local row's alias stays the
|
||||||
Name string `gorm:"size:255;not null"`
|
// user-facing name).
|
||||||
Pool string `gorm:"size:64;index;not null"`
|
type ChannelModel struct {
|
||||||
Kind string `gorm:"size:64;index;not null"`
|
ChannelID string `gorm:"primaryKey;size:40"`
|
||||||
Cookie string `gorm:"type:text"`
|
ModelID string `gorm:"primaryKey;size:255;index"`
|
||||||
Enabled bool `gorm:"not null;default:true"`
|
UpstreamModel string `gorm:"size:255;not null"`
|
||||||
IntervalSeconds int `gorm:"not null;default:54000"`
|
|
||||||
ImportedAt *time.Time
|
|
||||||
LastAttemptAt *time.Time
|
|
||||||
LastSuccessAt *time.Time
|
|
||||||
LastError string `gorm:"type:text"`
|
|
||||||
NextRetryAt *time.Time
|
|
||||||
ConsecutiveFailures int `gorm:"not null;default:0"`
|
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -244,8 +239,8 @@ func AutoMigrateModels() []any {
|
|||||||
&EventLog{},
|
&EventLog{},
|
||||||
&ModelConfig{},
|
&ModelConfig{},
|
||||||
&CDKCode{},
|
&CDKCode{},
|
||||||
&TokenAccount{},
|
&Channel{},
|
||||||
&RefreshProfile{},
|
&ChannelModel{},
|
||||||
&SiteSetting{},
|
&SiteSetting{},
|
||||||
&StatCounter{},
|
&StatCounter{},
|
||||||
&ConcurrencyGroup{},
|
&ConcurrencyGroup{},
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package adobe
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// captured from live firefly.adobe.com traffic:
|
|
||||||
//
|
|
||||||
// ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|…
|
|
||||||
// ftr: dbd9d77a491b4437bc5c4d649a04a794_1785846934401_6890_UDF43-m4_31ck_YRQXWT0P1AE=-7389-v2_tt
|
|
||||||
//
|
|
||||||
// The Arkose slot is deliberately emitted empty rather than synthesized — see
|
|
||||||
// buildARPSessionID — so the expected ftr ends in "_31ck__tt".
|
|
||||||
var (
|
|
||||||
arkPat = regexp.MustCompile(`^[0-9a-f]{17}\.[1-9][0-9]{9}\|r=ap-southeast-1\|.*\|rid=[0-9]{1,2}\|ag=101\|`)
|
|
||||||
ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-m4_31ck__tt$`)
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestARPSessionIDShape(t *testing.T) {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID("tok-a"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("not base64: %v", err)
|
|
||||||
}
|
|
||||||
var got struct{ Sid, Ark, Ftr string }
|
|
||||||
if err := json.Unmarshal(raw, &got); err != nil {
|
|
||||||
t.Fatalf("not json: %v", err)
|
|
||||||
}
|
|
||||||
if !arkPat.MatchString(got.Ark) {
|
|
||||||
t.Errorf("ark shape mismatch:\n%s", got.Ark)
|
|
||||||
}
|
|
||||||
if !ftrPat.MatchString(got.Ftr) {
|
|
||||||
t.Errorf("ftr shape mismatch:\n%s", got.Ftr)
|
|
||||||
}
|
|
||||||
if len(got.Sid) != 36 {
|
|
||||||
t.Errorf("sid not a uuid: %q", got.Sid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ark must differ per call — a frozen blob is a cross-account correlation key.
|
|
||||||
func TestARKVariesAcrossCalls(t *testing.T) {
|
|
||||||
if buildARKBlob() == buildARKBlob() {
|
|
||||||
t.Error("ark is constant across calls")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pid is stable per token but distinct across tokens.
|
|
||||||
func TestPIDStablePerToken(t *testing.T) {
|
|
||||||
defer ReleasePID("tok-x")
|
|
||||||
defer ReleasePID("tok-y")
|
|
||||||
if allocPID("tok-x") != allocPID("tok-x") {
|
|
||||||
t.Error("pid changed for the same token")
|
|
||||||
}
|
|
||||||
if allocPID("tok-x") == allocPID("tok-y") {
|
|
||||||
t.Error("two tokens share a pid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package adobe
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
|
|
||||||
clientID = "projectx_webapp"
|
|
||||||
scopeValue = "AdobeID,firefly_api,openid"
|
|
||||||
// clioClientID / clioScopeValue mint the firefly.adobe.com token that is
|
|
||||||
// entitled to the Firefly-native seedance models (creative_production scope).
|
|
||||||
// Unlike the Express token this exchange requires user_id and no guest_allowed.
|
|
||||||
clioClientID = "clio-playground-web"
|
|
||||||
clioScopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,tk_platform,tk_platform_sync,profile"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
|
|
||||||
|
|
||||||
type CookieExchangeResult struct {
|
|
||||||
AccessToken string
|
|
||||||
ExpiresIn int
|
|
||||||
Raw map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExchangeCookieToAccessToken(ctx context.Context, client *http.Client, cookie string) (*CookieExchangeResult, error) {
|
|
||||||
_ = client
|
|
||||||
tlsClient, err := NewClient(clientID, "").newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return exchangeCookieWithTLSClient(ctx, tlsClient, cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeCookie(v string) string {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if strings.HasPrefix(strings.ToLower(v), "cookie:") {
|
|
||||||
v = strings.TrimSpace(v[len("cookie:"):])
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
package adobe
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type modelSpec struct {
|
|
||||||
UpstreamModelID string
|
|
||||||
UpstreamModelVersion string
|
|
||||||
}
|
|
||||||
|
|
||||||
var lumaSize = map[string]map[string][2]int{
|
|
||||||
"720p": {
|
|
||||||
"21:9": {1280, 548}, "16:9": {1280, 720}, "4:3": {960, 720},
|
|
||||||
"1:1": {720, 720}, "3:4": {720, 960}, "9:16": {720, 1280}, "9:21": {548, 1280},
|
|
||||||
},
|
|
||||||
"1080p": {
|
|
||||||
"21:9": {1920, 822}, "16:9": {1920, 1080}, "4:3": {1440, 1080},
|
|
||||||
"1:1": {1080, 1080}, "3:4": {1080, 1440}, "9:16": {1080, 1920}, "9:21": {822, 1920},
|
|
||||||
},
|
|
||||||
"4k": {
|
|
||||||
"21:9": {3840, 1646}, "16:9": {3840, 2160}, "4:3": {2880, 2160},
|
|
||||||
"1:1": {2160, 2160}, "3:4": {2160, 2880}, "9:16": {2160, 3840}, "9:21": {1646, 3840},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var gptImageSize = map[string]map[string][2]int{
|
|
||||||
"1K": {"1:1": {1024, 1024}, "5:4": {1120, 896}, "9:16": {720, 1280}, "21:9": {1456, 624}, "16:9": {1280, 720}, "4:3": {1152, 864}, "3:2": {1248, 832}, "4:5": {896, 1120}, "3:4": {864, 1152}, "2:3": {832, 1248}},
|
|
||||||
"2K": {"1:1": {2048, 2048}, "5:4": {2240, 1792}, "9:16": {1440, 2560}, "21:9": {3024, 1296}, "16:9": {2560, 1440}, "4:3": {2304, 1728}, "3:2": {2496, 1664}, "4:5": {1792, 2240}, "3:4": {1728, 2304}, "2:3": {1664, 2496}},
|
|
||||||
"4K": {"1:1": {2880, 2880}, "5:4": {3200, 2560}, "9:16": {2160, 3840}, "21:9": {3696, 1584}, "16:9": {3840, 2160}, "4:3": {3264, 2448}, "3:2": {3504, 2336}, "4:5": {2560, 3200}, "3:4": {2448, 3264}, "2:3": {2336, 3504}},
|
|
||||||
}
|
|
||||||
|
|
||||||
var fluxSize = map[string][2]int{
|
|
||||||
"1:1": {1024, 1024},
|
|
||||||
"16:9": {1408, 768},
|
|
||||||
"9:16": {768, 1408},
|
|
||||||
"4:3": {1280, 896},
|
|
||||||
"3:4": {896, 1280},
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultSize = map[string]map[string][2]int{
|
|
||||||
"1K": {"1:1": {1024, 1024}, "1:8": {384, 3072}, "1:4": {512, 2048}, "16:9": {1360, 768}, "9:16": {768, 1360}, "4:1": {2048, 512}, "4:3": {1152, 864}, "3:4": {864, 1152}, "8:1": {3072, 384}},
|
|
||||||
"2K": {"1:1": {2048, 2048}, "1:8": {768, 6144}, "1:4": {1024, 4096}, "16:9": {2752, 1536}, "9:16": {1536, 2752}, "4:1": {4096, 1024}, "4:3": {2048, 1536}, "3:4": {1536, 2048}, "8:1": {6144, 768}},
|
|
||||||
"4K": {"1:1": {4096, 4096}, "1:8": {1536, 12288}, "1:4": {2048, 8192}, "16:9": {5504, 3072}, "9:16": {3072, 5504}, "4:1": {8192, 2048}, "4:3": {4096, 3072}, "3:4": {3072, 4096}, "8:1": {12288, 1536}},
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveModelSpec(modelID string) modelSpec {
|
|
||||||
switch modelID {
|
|
||||||
case "firefly-gpt-image", "firefly-gpt-image-2":
|
|
||||||
return modelSpec{UpstreamModelID: "gpt-image", UpstreamModelVersion: "2"}
|
|
||||||
case "flux-kontext-max":
|
|
||||||
return modelSpec{UpstreamModelID: "flux", UpstreamModelVersion: "fluxKontextMax"}
|
|
||||||
case "nano-banana-2":
|
|
||||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-3"}
|
|
||||||
case "nano-banana-pro":
|
|
||||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-2"}
|
|
||||||
default:
|
|
||||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "gemini-3-pro-image-preview"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildImage5Payload builds the Adobe Firefly Image 5 request. It uses a distinct
|
|
||||||
// schema from the firefly-3p models: NO modelId/size, a top-level aspectRatio
|
|
||||||
// string label and a resolutionLevel (1K→1MP, 2K→4MP). Mirrors a captured
|
|
||||||
// working image-v5.ff.adobe.io request.
|
|
||||||
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
|
|
||||||
p := map[string]any{
|
|
||||||
"n": 1,
|
|
||||||
"seeds": []int{rand.Intn(999999)},
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
"prompt": prompt,
|
|
||||||
"referenceBlobs": []any{},
|
|
||||||
"modelSpecificPayload": map[string]any{"locale": "en-US", "prompt_reasoner": "quality"},
|
|
||||||
"modelVersion": "image5",
|
|
||||||
"resolutionLevel": image5ResolutionLevel(resolution),
|
|
||||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
|
||||||
}
|
|
||||||
if len(blobIDs) > 0 {
|
|
||||||
// Instruct-edit: aspect ratio is derived from the reference image; sending
|
|
||||||
// aspectRatio is rejected with a validation_error.
|
|
||||||
p["referenceBlobs"] = blobRefs(blobIDs, "general")
|
|
||||||
} else {
|
|
||||||
p["aspectRatio"] = defaultString(aspectRatio, "1:1")
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// image5ResolutionLevel maps the UI resolution tier to Image 5's megapixel level.
|
|
||||||
func image5ResolutionLevel(resolution string) string {
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(resolution)) {
|
|
||||||
case "1K":
|
|
||||||
return "1MP"
|
|
||||||
case "2K":
|
|
||||||
return "4MP"
|
|
||||||
default:
|
|
||||||
return "4MP"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildImagePayloadCandidates(modelID, prompt, aspectRatio, outputResolution string, blobIDs []string) []map[string]any {
|
|
||||||
spec := ResolveModelSpec(modelID)
|
|
||||||
ratio := defaultString(aspectRatio, "1:1")
|
|
||||||
resolution := defaultString(outputResolution, "2K")
|
|
||||||
|
|
||||||
switch spec.UpstreamModelID {
|
|
||||||
case "gpt-image":
|
|
||||||
return buildGPTImagePayloads(spec, prompt, ratio, resolution, blobIDs)
|
|
||||||
case "flux":
|
|
||||||
return buildFluxPayloads(spec, prompt, ratio, blobIDs)
|
|
||||||
default:
|
|
||||||
return buildDefaultPayloads(spec, prompt, ratio, resolution, blobIDs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
|
||||||
size := getSize(gptImageSize, resolution, ratio, "1:1")
|
|
||||||
// Mirrors the captured working gpt-image request shape: modelSpecificPayload.size,
|
|
||||||
// generationSettings.detailLevel 3, and NO top-level size / outputResolution
|
|
||||||
// (sending those got 403). Keeps the chosen size via modelSpecificPayload.size
|
|
||||||
// ("WxH") rather than "auto".
|
|
||||||
base := map[string]any{
|
|
||||||
"modelId": spec.UpstreamModelID,
|
|
||||||
"modelVersion": spec.UpstreamModelVersion,
|
|
||||||
"n": 1,
|
|
||||||
"prompt": prompt,
|
|
||||||
"seeds": []int{rand.Intn(999999)},
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
"referenceBlobs": []any{},
|
|
||||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
|
||||||
"modelSpecificPayload": map[string]any{"size": sizeString(size)},
|
|
||||||
"generationSettings": map[string]any{"detailLevel": 3},
|
|
||||||
}
|
|
||||||
if len(blobIDs) == 0 {
|
|
||||||
return []map[string]any{base}
|
|
||||||
}
|
|
||||||
subject := cloneMap(base)
|
|
||||||
subject["referenceBlobs"] = blobRefs(blobIDs, "subject")
|
|
||||||
return []map[string]any{subject}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFluxPayloads(spec modelSpec, prompt, ratio string, blobIDs []string) []map[string]any {
|
|
||||||
size := fluxSize[ratio]
|
|
||||||
if size == [2]int{} {
|
|
||||||
size = fluxSize["1:1"]
|
|
||||||
}
|
|
||||||
base := map[string]any{
|
|
||||||
"modelId": spec.UpstreamModelID,
|
|
||||||
"modelVersion": spec.UpstreamModelVersion,
|
|
||||||
"n": 1,
|
|
||||||
"prompt": prompt,
|
|
||||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
|
||||||
"seeds": []int{rand.Intn(999999)},
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
"referenceBlobs": []any{},
|
|
||||||
"modelSpecificPayload": map[string]any{
|
|
||||||
"prompt_upsampling": true,
|
|
||||||
"safety_tolerance": 2,
|
|
||||||
"aspect_ratio": ratio,
|
|
||||||
},
|
|
||||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
|
||||||
}
|
|
||||||
if len(blobIDs) == 0 {
|
|
||||||
return []map[string]any{base}
|
|
||||||
}
|
|
||||||
edited := cloneMap(base)
|
|
||||||
edited["generationMetadata"] = map[string]any{"module": "image2image", "submodule": "ff-image-generate"}
|
|
||||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
|
||||||
return []map[string]any{edited}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
|
||||||
size := getSize(defaultSize, resolution, ratio, "16:9")
|
|
||||||
// Shape mirrors a captured working firefly.adobe.com request exactly: top-level
|
|
||||||
// size object, modelSpecificPayload only {parameters:{addWatermark:false}},
|
|
||||||
// groundSearch:false, module "text2image" (even with a reference blob). NO
|
|
||||||
// skipCai and NO modelSpecificPayload.aspectRatio — sending those got 403.
|
|
||||||
base := map[string]any{
|
|
||||||
"modelId": spec.UpstreamModelID,
|
|
||||||
"modelVersion": spec.UpstreamModelVersion,
|
|
||||||
"n": 1,
|
|
||||||
"prompt": prompt,
|
|
||||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
|
||||||
"seeds": []int{rand.Intn(999999)},
|
|
||||||
"groundSearch": false,
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
"generationMetadata": map[string]any{
|
|
||||||
"module": "text2image",
|
|
||||||
"submodule": "ff-image-generate",
|
|
||||||
},
|
|
||||||
"modelSpecificPayload": map[string]any{
|
|
||||||
"parameters": map[string]any{"addWatermark": false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if len(blobIDs) == 0 {
|
|
||||||
base["referenceBlobs"] = []any{}
|
|
||||||
return []map[string]any{base}
|
|
||||||
}
|
|
||||||
edited := cloneMap(base)
|
|
||||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
|
||||||
return []map[string]any{edited}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSize(table map[string]map[string][2]int, resolution, ratio, fallbackRatio string) [2]int {
|
|
||||||
level := defaultString(resolution, "2K")
|
|
||||||
levelTable, ok := table[level]
|
|
||||||
if !ok {
|
|
||||||
levelTable = table["2K"]
|
|
||||||
}
|
|
||||||
size, ok := levelTable[ratio]
|
|
||||||
if !ok {
|
|
||||||
size = levelTable[fallbackRatio]
|
|
||||||
}
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
func sizeString(size [2]int) string {
|
|
||||||
return itoa(size[0]) + "x" + itoa(size[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
func blobRefs(ids []string, usage string) []any {
|
|
||||||
out := make([]any, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
out = append(out, map[string]any{"id": id, "usage": usage})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func referenceImagesByID(ids []string) []any {
|
|
||||||
out := make([]any, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
out = append(out, map[string]any{"id": id})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func referenceImagesByLocal(ids []string) []any {
|
|
||||||
out := make([]any, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
out = append(out, map[string]any{"localBlobRef": id})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneMap(in map[string]any) map[string]any {
|
|
||||||
out := make(map[string]any, len(in))
|
|
||||||
for k, v := range in {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string) map[string]any {
|
|
||||||
seedVal := rand.Intn(999999)
|
|
||||||
engine = defaultString(engine, "sora2")
|
|
||||||
resolution = defaultString(resolution, "720p")
|
|
||||||
aspectRatio = defaultString(aspectRatio, "16:9")
|
|
||||||
if durationSeconds <= 0 {
|
|
||||||
durationSeconds = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
switch engine {
|
|
||||||
case "firefly-video":
|
|
||||||
// Firefly-native video model — a distinct schema (mirrors a captured
|
|
||||||
// working video-v1.ff.adobe.io request): sizes[] carries width/height +
|
|
||||||
// numFrames (numFrames encodes duration, ~25.6fps so 5s = 128), and
|
|
||||||
// reference frames go under image.conditions with placement.start
|
|
||||||
// (0 = first frame / 首帧, 1 = last frame / 末帧). NO modelId / version /
|
|
||||||
// engine / duration / referenceBlobs fields.
|
|
||||||
w, h, frames := fireflyVideoSize(aspectRatio, resolution, durationSeconds)
|
|
||||||
payload := map[string]any{
|
|
||||||
"addOnTransparentBackground": false,
|
|
||||||
"prompt": prompt,
|
|
||||||
"seeds": []int{seedVal},
|
|
||||||
"sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}},
|
|
||||||
"videoSettings": map[string]any{},
|
|
||||||
"locale": "en-US",
|
|
||||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
}
|
|
||||||
if len(blobIDs) > 0 {
|
|
||||||
conds := make([]any, 0, 2)
|
|
||||||
conds = append(conds, map[string]any{
|
|
||||||
"source": map[string]any{"id": blobIDs[0]},
|
|
||||||
"placement": map[string]any{"start": 0},
|
|
||||||
})
|
|
||||||
if len(blobIDs) > 1 {
|
|
||||||
conds = append(conds, map[string]any{
|
|
||||||
"source": map[string]any{"id": blobIDs[1]},
|
|
||||||
"placement": map[string]any{"start": 1},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
payload["image"] = map[string]any{"conditions": conds}
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
case "veo31-fast", "veo31-standard":
|
|
||||||
modelVersion := "3.1-fast-generate"
|
|
||||||
if engine == "veo31-standard" {
|
|
||||||
modelVersion = "3.1-generate"
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
"modelId": "veo",
|
|
||||||
"modelVersion": modelVersion,
|
|
||||||
"size": videoSize(aspectRatio, resolution),
|
|
||||||
"seeds": []int{seedVal},
|
|
||||||
"prompt": prompt,
|
|
||||||
"negativePrompt": "",
|
|
||||||
"duration": durationSeconds,
|
|
||||||
"generateAudio": false,
|
|
||||||
"generationMetadata": map[string]any{
|
|
||||||
"module": "text2video",
|
|
||||||
"submodule": "ff-video-generate",
|
|
||||||
},
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
"referenceBlobs": []any{},
|
|
||||||
}
|
|
||||||
if len(blobIDs) > 0 {
|
|
||||||
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
|
|
||||||
refs := make([]any, 0, min(len(blobIDs), 3))
|
|
||||||
if referenceMode == "frame" {
|
|
||||||
for idx, id := range blobIDs[:min(len(blobIDs), 3)] {
|
|
||||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, id := range blobIDs[:min(len(blobIDs), 3)] {
|
|
||||||
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload["referenceBlobs"] = refs
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
case "seedance-2.0-fast", "seedance-2.0":
|
|
||||||
modelVersion := "seedance_2.0"
|
|
||||||
if engine == "seedance-2.0-fast" {
|
|
||||||
modelVersion = "seedance_2.0_fast"
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
"modelId": "seedance",
|
|
||||||
"modelVersion": modelVersion,
|
|
||||||
"size": videoSize(aspectRatio, resolution),
|
|
||||||
"seeds": []int{seedVal},
|
|
||||||
"prompt": prompt,
|
|
||||||
"negativePrompt": "",
|
|
||||||
"duration": durationSeconds,
|
|
||||||
"generateAudio": true,
|
|
||||||
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
|
|
||||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
"referenceBlobs": []any{},
|
|
||||||
}
|
|
||||||
if len(blobIDs) > 0 {
|
|
||||||
// Seedance keeps module "text2video" even with reference blobs
|
|
||||||
// (HAR-confirmed) — unlike veo/luma it does not switch to image2video.
|
|
||||||
if referenceMode == "frame" {
|
|
||||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
|
||||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
|
||||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
|
||||||
}
|
|
||||||
payload["referenceBlobs"] = refs
|
|
||||||
} else {
|
|
||||||
refs := make([]any, 0, min(len(blobIDs), 9))
|
|
||||||
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
|
|
||||||
refs = append(refs, map[string]any{"id": id, "usage": "style"})
|
|
||||||
}
|
|
||||||
payload["referenceBlobs"] = refs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Video/audio source refs (seedance)
|
|
||||||
for _, id := range videoBlobIDs[:min(len(videoBlobIDs), 3)] {
|
|
||||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
|
||||||
}
|
|
||||||
for _, id := range audioBlobIDs[:min(len(audioBlobIDs), 3)] {
|
|
||||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
case "luma":
|
|
||||||
payload := map[string]any{
|
|
||||||
"modelId": "luma",
|
|
||||||
"modelVersion": "3.14-ray",
|
|
||||||
"size": lumaVideoSize(aspectRatio, resolution),
|
|
||||||
"mode": "flex_2",
|
|
||||||
"prompt": prompt,
|
|
||||||
"negativePrompt": "",
|
|
||||||
"duration": durationSeconds,
|
|
||||||
"generationMetadata": map[string]any{
|
|
||||||
"module": "text2video",
|
|
||||||
"submodule": "ff-video-generate",
|
|
||||||
},
|
|
||||||
"modelSpecificPayload": map[string]any{
|
|
||||||
"resolution": strings.ToLower(resolution),
|
|
||||||
"aspect_ratio": aspectRatio,
|
|
||||||
},
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
}
|
|
||||||
if len(blobIDs) > 0 {
|
|
||||||
payload["generationMetadata"] = map[string]any{
|
|
||||||
"module": "image2video",
|
|
||||||
"submodule": "ff-video-generate",
|
|
||||||
}
|
|
||||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
|
||||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
|
||||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
|
||||||
}
|
|
||||||
payload["referenceBlobs"] = refs
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
default:
|
|
||||||
upstream := defaultString(upstreamModel, "openai:firefly:colligo:sora2")
|
|
||||||
payload := map[string]any{
|
|
||||||
"n": 1,
|
|
||||||
"seeds": []int{seedVal},
|
|
||||||
"modelId": "sora",
|
|
||||||
"modelVersion": "sora-2",
|
|
||||||
"size": videoSize(aspectRatio, resolution),
|
|
||||||
"duration": durationSeconds,
|
|
||||||
"fps": 24,
|
|
||||||
"prompt": buildVideoPromptJSON(prompt, durationSeconds),
|
|
||||||
"generationMetadata": map[string]any{"module": "text2video"},
|
|
||||||
"model": upstream,
|
|
||||||
"generateAudio": true,
|
|
||||||
"generateLoop": false,
|
|
||||||
"transparentBackground": false,
|
|
||||||
"seed": itoa(seedVal),
|
|
||||||
"locale": "en-US",
|
|
||||||
"camera": map[string]any{"angle": "none", "shotSize": "none", "motion": nil, "promptStyle": nil},
|
|
||||||
"negativePrompt": "",
|
|
||||||
"jobMode": "standard",
|
|
||||||
"debugGenerationEndpoint": "",
|
|
||||||
"referenceBlobs": []any{},
|
|
||||||
"referenceFrames": []any{},
|
|
||||||
"referenceVideo": nil,
|
|
||||||
"cameraMotionReferenceVideo": nil,
|
|
||||||
"characterReference": nil,
|
|
||||||
"editReferenceVideo": nil,
|
|
||||||
"output": map[string]any{"storeInputs": true},
|
|
||||||
}
|
|
||||||
if len(blobIDs) > 0 {
|
|
||||||
firstID := blobIDs[0]
|
|
||||||
payload["generationMetadata"] = map[string]any{"module": "image2video"}
|
|
||||||
payload["referenceBlobs"] = []any{
|
|
||||||
map[string]any{"id": firstID, "usage": "general", "promptReference": 1},
|
|
||||||
}
|
|
||||||
payload["referenceFrames"] = []any{map[string]any{"localBlobRef": firstID}, nil}
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fireflyVideoSizeTable maps the firefly-video resolution tier + aspect ratio to
|
|
||||||
// pixel dimensions. Only 1080p 9:16 (1080x1920) is HAR-confirmed; the rest follow
|
|
||||||
// the standard 540p/720p/1080p grid for each ratio.
|
|
||||||
var fireflyVideoSizeTable = map[string]map[string][2]int{
|
|
||||||
"540p": {"16:9": {960, 540}, "1:1": {540, 540}, "9:16": {540, 960}},
|
|
||||||
"720p": {"16:9": {1280, 720}, "1:1": {720, 720}, "9:16": {720, 1280}},
|
|
||||||
"1080p": {"16:9": {1920, 1080}, "1:1": {1080, 1080}, "9:16": {1080, 1920}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// fireflyVideoSize returns width, height and numFrames. numFrames encodes the
|
|
||||||
// clip length (~25.6fps; 5s = 128 frames, HAR-confirmed).
|
|
||||||
func fireflyVideoSize(aspectRatio, resolution string, durationSeconds int) (int, int, int) {
|
|
||||||
table, ok := fireflyVideoSizeTable[strings.ToLower(defaultString(resolution, "1080p"))]
|
|
||||||
if !ok {
|
|
||||||
table = fireflyVideoSizeTable["1080p"]
|
|
||||||
}
|
|
||||||
wh, ok := table[defaultString(aspectRatio, "9:16")]
|
|
||||||
if !ok {
|
|
||||||
wh = table["9:16"]
|
|
||||||
}
|
|
||||||
frames := durationSeconds * 128 / 5
|
|
||||||
if frames <= 0 {
|
|
||||||
frames = 128
|
|
||||||
}
|
|
||||||
return wh[0], wh[1], frames
|
|
||||||
}
|
|
||||||
|
|
||||||
func videoSize(aspectRatio, resolution string) map[string]any {
|
|
||||||
if strings.EqualFold(resolution, "1080p") {
|
|
||||||
if aspectRatio == "16:9" {
|
|
||||||
return map[string]any{"width": 1920, "height": 1080}
|
|
||||||
}
|
|
||||||
return map[string]any{"width": 1080, "height": 1920}
|
|
||||||
}
|
|
||||||
if aspectRatio == "16:9" {
|
|
||||||
return map[string]any{"width": 1280, "height": 720}
|
|
||||||
}
|
|
||||||
return map[string]any{"width": 720, "height": 1280}
|
|
||||||
}
|
|
||||||
|
|
||||||
func lumaVideoSize(aspectRatio, resolution string) map[string]any {
|
|
||||||
table, ok := lumaSize[strings.ToLower(defaultString(resolution, "720p"))]
|
|
||||||
if !ok {
|
|
||||||
table = lumaSize["720p"]
|
|
||||||
}
|
|
||||||
size, ok := table[defaultString(aspectRatio, "16:9")]
|
|
||||||
if !ok {
|
|
||||||
size = table["16:9"]
|
|
||||||
}
|
|
||||||
return map[string]any{"width": size[0], "height": size[1]}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildVideoPromptJSON(prompt string, durationSeconds int) string {
|
|
||||||
payload := map[string]any{
|
|
||||||
"id": 1,
|
|
||||||
"duration_sec": durationSeconds,
|
|
||||||
"prompt_text": prompt,
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(payload)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
package adobe
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"math/big"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// adobeUserIDPat matches Adobe IMS user IDs embedded in cookies (e.g.
|
|
||||||
// "4BDA81F069FC6DA40A495FAB@AdobeID").
|
|
||||||
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
|
|
||||||
|
|
||||||
// PID pool: one PID per token, reused across requests for the same account.
|
|
||||||
var (
|
|
||||||
arpPIDMu sync.Mutex
|
|
||||||
arpTokenPID = map[string]int{} // token → pid
|
|
||||||
arpPIDToken = map[int]string{} // pid → token
|
|
||||||
)
|
|
||||||
|
|
||||||
func extractUserIDFromCookie(cookie string) string {
|
|
||||||
decoded, err := url.QueryUnescape(cookie)
|
|
||||||
if err != nil {
|
|
||||||
decoded = cookie
|
|
||||||
}
|
|
||||||
return adobeUserIDPat.FindString(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringValue(v any) string {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case string:
|
|
||||||
return x
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return strings.TrimSpace(strings.ReplaceAll(toJSONScalar(x), "\n", " "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toJSONScalar(v any) string {
|
|
||||||
b, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func intValue(v any) int {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case int:
|
|
||||||
return x
|
|
||||||
case int64:
|
|
||||||
return int(x)
|
|
||||||
case float64:
|
|
||||||
return int(x)
|
|
||||||
case float32:
|
|
||||||
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 defaultString(v, fallback string) string {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if v == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(v int) string {
|
|
||||||
return strconv.Itoa(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeJWTPayload(token string) map[string]any {
|
|
||||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
|
|
||||||
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
|
|
||||||
// field sit between them and vary per session, so buildARKBlob randomizes those
|
|
||||||
// instead of shipping one frozen blob for every account.
|
|
||||||
const (
|
|
||||||
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
|
|
||||||
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
|
|
||||||
)
|
|
||||||
|
|
||||||
func buildARKBlob() string {
|
|
||||||
return randomHex(9)[:17] + "." + randomDigits(10) +
|
|
||||||
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildARPSessionID returns the x-arp-session-id value: base64 of
|
|
||||||
// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic:
|
|
||||||
//
|
|
||||||
// ark: <17hex>.<10digits>|…|rid=<n>|…
|
|
||||||
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-m4_31ck__tt
|
|
||||||
//
|
|
||||||
// The pid is allocated per token (allocPID) so it stays stable across an
|
|
||||||
// account's requests, the way a real browser session would.
|
|
||||||
//
|
|
||||||
// The Arkose segment is left empty on purpose. In a real browser that slot
|
|
||||||
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
|
|
||||||
// script; a synthesized one has the right shape but fails Adobe's server-side
|
|
||||||
// validation, and 3p-images/generate-async rejects it with a misleading
|
|
||||||
// {"error_code":"timeout_error","message":"system under load"}. Sending the
|
|
||||||
// slot empty reads as "no Arkose payload yet" and is accepted.
|
|
||||||
func buildARPSessionID(token string) string {
|
|
||||||
ftr := strings.Join([]string{
|
|
||||||
randomHex(16), // 32 hex chars, matching the capture
|
|
||||||
strconv.FormatInt(time.Now().UnixMilli(), 10),
|
|
||||||
strconv.Itoa(allocPID(token)),
|
|
||||||
"UDF43-m4",
|
|
||||||
"31ck",
|
|
||||||
"",
|
|
||||||
"tt",
|
|
||||||
}, "_")
|
|
||||||
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"sid": uuid.NewString(),
|
|
||||||
"ark": buildARKBlob(),
|
|
||||||
"ftr": ftr,
|
|
||||||
})
|
|
||||||
return base64.StdEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomDigits returns n decimal digits with a non-zero leading digit, so the
|
|
||||||
// rendered value keeps a constant width.
|
|
||||||
func randomDigits(n int) string {
|
|
||||||
if n <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(n)
|
|
||||||
b.WriteByte(byte('1' + randomInt(0, 8)))
|
|
||||||
for i := 1; i < n; i++ {
|
|
||||||
b.WriteByte(byte('0' + randomInt(0, 9)))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomBase64(n int) string {
|
|
||||||
buf := make([]byte, n)
|
|
||||||
rand.Read(buf)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func allocPID(token string) int {
|
|
||||||
arpPIDMu.Lock()
|
|
||||||
defer arpPIDMu.Unlock()
|
|
||||||
if pid, ok := arpTokenPID[token]; ok {
|
|
||||||
return pid
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
pid := randomInt(1000, 99999)
|
|
||||||
if _, used := arpPIDToken[pid]; !used {
|
|
||||||
arpPIDToken[pid] = token
|
|
||||||
arpTokenPID[token] = pid
|
|
||||||
return pid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReleasePID releases the PID bound to token so it can be reused by another
|
|
||||||
// account.
|
|
||||||
func ReleasePID(token string) {
|
|
||||||
arpPIDMu.Lock()
|
|
||||||
defer arpPIDMu.Unlock()
|
|
||||||
if pid, ok := arpTokenPID[token]; ok {
|
|
||||||
delete(arpPIDToken, pid)
|
|
||||||
delete(arpTokenPID, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomHex(n int) string {
|
|
||||||
if n <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
buf := make([]byte, n)
|
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
now := time.Now().UnixNano()
|
|
||||||
for i := range buf {
|
|
||||||
buf[i] = byte(now >> ((i % 8) * 8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomInt(min, max int) int {
|
|
||||||
if max <= min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
|
||||||
if err != nil {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
return min + int(n.Int64())
|
|
||||||
}
|
|
||||||
|
|
||||||
func intOrNil(v any) any {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case nil:
|
|
||||||
return nil
|
|
||||||
case int:
|
|
||||||
return x
|
|
||||||
case int64:
|
|
||||||
return int(x)
|
|
||||||
case float64:
|
|
||||||
return int(x)
|
|
||||||
case float32:
|
|
||||||
return int(x)
|
|
||||||
case json.Number:
|
|
||||||
n, err := x.Int64()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return int(n)
|
|
||||||
case string:
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(x))
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func emptyStringNil(v string) any {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package chatgpt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"math/rand"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/sha3"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
cores = []int{8, 16, 24, 32}
|
|
||||||
documentKeys = []string{"__reactContainer$fzelfjyxej8", "_reactListening5dehydibo78", "location"}
|
|
||||||
screenResolutions = [][2]int{{1920, 1080}, {1440, 900}, {2560, 1440}, {3840, 2160}}
|
|
||||||
navKeys = []string{
|
|
||||||
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
|
||||||
"storage−[object StorageManager]",
|
|
||||||
"locks−[object LockManager]",
|
|
||||||
"appCodeName−Mozilla",
|
|
||||||
"permissions−[object Permissions]",
|
|
||||||
"share−function share() { [native code] }",
|
|
||||||
"webdriver−false",
|
|
||||||
"vendor−Google Inc.",
|
|
||||||
"mediaDevices−[object MediaDevices]",
|
|
||||||
"cookieEnabled−true",
|
|
||||||
"onLine−true",
|
|
||||||
"mimeTypes−[object MimeTypeArray]",
|
|
||||||
"credentials−[object CredentialsContainer]",
|
|
||||||
"serviceWorker−[object ServiceWorkerContainer]",
|
|
||||||
"keyboard−[object Keyboard]",
|
|
||||||
"gpu−[object GPU]",
|
|
||||||
"doNotTrack",
|
|
||||||
"language−zh-CN",
|
|
||||||
"geolocation−[object Geolocation]",
|
|
||||||
"hardwareConcurrency−32",
|
|
||||||
}
|
|
||||||
winKeys = []string{
|
|
||||||
"0", "window", "self", "document", "name", "location", "history",
|
|
||||||
"navigation", "innerWidth", "innerHeight", "screen", "chrome",
|
|
||||||
"navigator", "performance", "crypto", "indexedDB", "sessionStorage",
|
|
||||||
"localStorage", "fetch", "matchMedia", "postMessage", "setTimeout",
|
|
||||||
"caches", "__NEXT_DATA__",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func buildLegacyRequirementsToken(userAgent string, scriptSources []string, dataBuild string) string {
|
|
||||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
|
||||||
body, _ := json.Marshal(cfg)
|
|
||||||
return "gAAAAAC" + base64.StdEncoding.EncodeToString(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildProofToken(seed, difficulty, userAgent string, scriptSources []string, dataBuild string) (string, error) {
|
|
||||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
|
||||||
answer, solved := powGenerate(seed, difficulty, cfg, 500000)
|
|
||||||
if !solved {
|
|
||||||
return "", errors.New("failed to solve proof token")
|
|
||||||
}
|
|
||||||
return "gAAAAAB" + answer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPOWConfig(userAgent string, scriptSources []string, dataBuild string) []any {
|
|
||||||
// scriptSources/dataBuild are no longer part of the sentinel config array
|
|
||||||
// (the current chatgpt.com client dropped them); kept in the signature for
|
|
||||||
// call-site compatibility.
|
|
||||||
_ = scriptSources
|
|
||||||
_ = dataBuild
|
|
||||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
screen := screenResolutions[rng.Intn(len(screenResolutions))]
|
|
||||||
loc := time.FixedZone("GMT+0800", 8*3600)
|
|
||||||
nowLocal := time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05") + " GMT+0800 (中国标准时间)"
|
|
||||||
perf := float64(time.Now().UnixNano()%1_000_000_000) / 1_000_000
|
|
||||||
return []any{
|
|
||||||
screen[0] + screen[1], // [0]
|
|
||||||
nowLocal, // [1] local time, JS Date.toString() shape
|
|
||||||
4395630592, // [2]
|
|
||||||
1, // [3] overwritten by powGenerate counter
|
|
||||||
userAgent, // [4]
|
|
||||||
nil, // [5] (was script source; now null)
|
|
||||||
defaultClientVersion, // [6] oai-client-version, must match header
|
|
||||||
"zh-CN", // [7] matches oai-language
|
|
||||||
"zh-CN,en,en-GB,en-US", // [8]
|
|
||||||
rng.Float64(), // [9] overwritten by powGenerate counter
|
|
||||||
navKeys[rng.Intn(len(navKeys))],
|
|
||||||
documentKeys[rng.Intn(len(documentKeys))],
|
|
||||||
winKeys[rng.Intn(len(winKeys))],
|
|
||||||
perf, // [13]
|
|
||||||
newUUID(), // [14]
|
|
||||||
"", // [15]
|
|
||||||
cores[rng.Intn(len(cores))], // [16]
|
|
||||||
float64(timeMillis()) - perf, // [17]
|
|
||||||
0, 0, 0, 0, 0, 0,
|
|
||||||
0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func powGenerate(seed, difficulty string, cfg []any, limit int) (string, bool) {
|
|
||||||
target, err := hex.DecodeString(strings.TrimSpace(difficulty))
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
diffLen := len(strings.TrimSpace(difficulty)) / 2
|
|
||||||
seedBytes := []byte(seed)
|
|
||||||
head1, _ := json.Marshal(cfg[:3])
|
|
||||||
head2, _ := json.Marshal(cfg[4:9])
|
|
||||||
head3, _ := json.Marshal(cfg[10:])
|
|
||||||
static1 := []byte(string(head1[:len(head1)-1]) + ",")
|
|
||||||
static2 := []byte("," + string(head2[1:len(head2)-1]) + ",")
|
|
||||||
static3 := []byte("," + string(head3[1:]))
|
|
||||||
|
|
||||||
for i := 0; i < limit; i++ {
|
|
||||||
finalJSON := append([]byte{}, static1...)
|
|
||||||
finalJSON = append(finalJSON, []byte(strconvItoa(i))...)
|
|
||||||
finalJSON = append(finalJSON, static2...)
|
|
||||||
finalJSON = append(finalJSON, []byte(strconvItoa(i>>1))...)
|
|
||||||
finalJSON = append(finalJSON, static3...)
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(finalJSON)
|
|
||||||
sum := sha3.Sum512(append(seedBytes, []byte(encoded)...))
|
|
||||||
if bytesCompare(sum[:diffLen], target) <= 0 {
|
|
||||||
return encoded, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fallback := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(`"`+seed+`"`))
|
|
||||||
return fallback, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesCompare(a, b []byte) int {
|
|
||||||
for i := 0; i < len(a) && i < len(b); i++ {
|
|
||||||
if a[i] < b[i] {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if a[i] > b[i] {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(a) < len(b) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if len(a) > len(b) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func strconvItoa(v int) string {
|
|
||||||
return strconv.Itoa(v)
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
package chatgpt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type orderedMap struct {
|
|
||||||
keys []string
|
|
||||||
values map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func newOrderedMap() *orderedMap {
|
|
||||||
return &orderedMap{values: map[string]any{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *orderedMap) add(key string, value any) {
|
|
||||||
if _, ok := m.values[key]; !ok {
|
|
||||||
m.keys = append(m.keys, key)
|
|
||||||
}
|
|
||||||
m.values[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func solveTurnstileToken(dx, p string) string {
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(dx)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var tokenList [][]any
|
|
||||||
if err := json.Unmarshal([]byte(xorString(string(decoded), p)), &tokenList); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
processMap := map[int]any{16: p}
|
|
||||||
start := time.Now()
|
|
||||||
result := ""
|
|
||||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
|
|
||||||
toStr := func(value any) string {
|
|
||||||
if value == nil {
|
|
||||||
return "undefined"
|
|
||||||
}
|
|
||||||
if s, ok := value.(string); ok {
|
|
||||||
special := map[string]string{
|
|
||||||
"window.Math": "[object Math]",
|
|
||||||
"window.Reflect": "[object Reflect]",
|
|
||||||
"window.performance": "[object Performance]",
|
|
||||||
"window.localStorage": "[object Storage]",
|
|
||||||
"window.Object": "function Object() { [native code] }",
|
|
||||||
"window.Reflect.set": "function set() { [native code] }",
|
|
||||||
"window.performance.now": "function () { [native code] }",
|
|
||||||
"window.Object.create": "function create() { [native code] }",
|
|
||||||
"window.Object.keys": "function keys() { [native code] }",
|
|
||||||
"window.Math.random": "function random() { [native code] }",
|
|
||||||
}
|
|
||||||
if specialValue, ok := special[s]; ok {
|
|
||||||
return specialValue
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
if list, ok := value.([]string); ok {
|
|
||||||
return strings.Join(list, ",")
|
|
||||||
}
|
|
||||||
return stringValue(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, token := range tokenList {
|
|
||||||
if len(token) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
op := intValue(token[0])
|
|
||||||
switch op {
|
|
||||||
case 2:
|
|
||||||
if len(token) >= 3 {
|
|
||||||
processMap[intValue(token[1])] = token[2]
|
|
||||||
}
|
|
||||||
case 3:
|
|
||||||
if len(token) >= 2 {
|
|
||||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
|
||||||
}
|
|
||||||
case 5:
|
|
||||||
if len(token) >= 3 {
|
|
||||||
e := intValue(token[1])
|
|
||||||
t := intValue(token[2])
|
|
||||||
cur := processMap[e]
|
|
||||||
inc := processMap[t]
|
|
||||||
if list, ok := cur.([]any); ok {
|
|
||||||
processMap[e] = append(list, inc)
|
|
||||||
} else if _, ok := cur.(string); ok {
|
|
||||||
processMap[e] = toStr(cur) + toStr(inc)
|
|
||||||
} else {
|
|
||||||
processMap[e] = "NaN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 6, 24:
|
|
||||||
if len(token) >= 4 {
|
|
||||||
e := intValue(token[1])
|
|
||||||
t := toStr(processMap[intValue(token[2])])
|
|
||||||
n := toStr(processMap[intValue(token[3])])
|
|
||||||
v := t + "." + n
|
|
||||||
if op == 6 && v == "window.document.location" {
|
|
||||||
v = "https://chatgpt.com/"
|
|
||||||
}
|
|
||||||
processMap[e] = v
|
|
||||||
}
|
|
||||||
case 8:
|
|
||||||
if len(token) >= 3 {
|
|
||||||
processMap[intValue(token[1])] = processMap[intValue(token[2])]
|
|
||||||
}
|
|
||||||
case 14:
|
|
||||||
if len(token) >= 3 {
|
|
||||||
var parsed any
|
|
||||||
if err := json.Unmarshal([]byte(toStr(processMap[intValue(token[2])])), &parsed); err == nil {
|
|
||||||
processMap[intValue(token[1])] = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 15:
|
|
||||||
if len(token) >= 3 {
|
|
||||||
b, _ := json.Marshal(processMap[intValue(token[2])])
|
|
||||||
processMap[intValue(token[1])] = string(b)
|
|
||||||
}
|
|
||||||
case 17:
|
|
||||||
if len(token) >= 3 {
|
|
||||||
e := intValue(token[1])
|
|
||||||
target := toStr(processMap[intValue(token[2])])
|
|
||||||
switch target {
|
|
||||||
case "window.performance.now":
|
|
||||||
processMap[e] = float64(time.Since(start).Nanoseconds())/1e6 + rng.Float64()
|
|
||||||
case "window.Object.create":
|
|
||||||
processMap[e] = newOrderedMap()
|
|
||||||
case "window.Object.keys":
|
|
||||||
processMap[e] = []string{
|
|
||||||
"STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4",
|
|
||||||
"STATSIG_LOCAL_STORAGE_STABLE_ID",
|
|
||||||
"client-correlated-secret",
|
|
||||||
"oai/apps/capExpiresAt",
|
|
||||||
"oai-did",
|
|
||||||
"STATSIG_LOCAL_STORAGE_LOGGING_REQUEST",
|
|
||||||
"UiState.isNavigationCollapsed.1",
|
|
||||||
}
|
|
||||||
case "window.Math.random":
|
|
||||||
processMap[e] = rng.Float64()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 18:
|
|
||||||
if len(token) >= 2 {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(toStr(processMap[intValue(token[1])]))
|
|
||||||
if err == nil {
|
|
||||||
processMap[intValue(token[1])] = string(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 19:
|
|
||||||
if len(token) >= 2 {
|
|
||||||
processMap[intValue(token[1])] = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
|
||||||
}
|
|
||||||
case 20:
|
|
||||||
if len(token) >= 4 {
|
|
||||||
if toStr(processMap[intValue(token[1])]) == toStr(processMap[intValue(token[2])]) {
|
|
||||||
if intValue(token[3]) == 3 && len(token) >= 5 {
|
|
||||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[4])])))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func xorString(text, key string) string {
|
|
||||||
if key == "" {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
out := make([]rune, 0, len(text))
|
|
||||||
keyRunes := []rune(key)
|
|
||||||
for i, ch := range text {
|
|
||||||
out = append(out, ch^keyRunes[i%len(keyRunes)])
|
|
||||||
}
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
package chatgpt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
baseURL = "https://chatgpt.com"
|
|
||||||
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0"
|
|
||||||
defaultClientVersion = "prod-db390ebea64862bf1899c420a4c736e0cf639747"
|
|
||||||
defaultClientBuildNumber = "7904904"
|
|
||||||
defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
|
||||||
// sseIdleGrace bounds how long startImageGeneration keeps reading the image
|
|
||||||
// SSE with no activity at all before giving up. ChatGPT's inline image pipeline
|
|
||||||
// holds the stream open with periodic ": ping" keepalives (~15s apart) while
|
|
||||||
// the image renders, so this is an *idle* timeout reset on every received line
|
|
||||||
// (see the watchdog in startImageGeneration): it only fires when the stream is
|
|
||||||
// genuinely silent, not on the normal render wait.
|
|
||||||
sseIdleGrace = 45 * time.Second
|
|
||||||
|
|
||||||
// pictureV2Command is the "@创建图片" ecosystem mention the ChatGPT web app
|
|
||||||
// prepends to an image (picture_v2) prompt; the server identifies the image
|
|
||||||
// command from this mention plus its custom_symbol_offset, so we send it to
|
|
||||||
// match the browser exactly.
|
|
||||||
pictureV2Command = "@创建图片"
|
|
||||||
// pictureV2MentionEnd is the UTF-16 length of pictureV2Command — the
|
|
||||||
// ecosystemMention custom_symbol_offset endIndex the web app reports.
|
|
||||||
pictureV2MentionEnd = 5
|
|
||||||
|
|
||||||
// submitConnectRetries / submitRetryBackoff bound how many times the image
|
|
||||||
// submit (the proxied /backend-api/f/conversation POST) is retried on a bare
|
|
||||||
// connection error (EOF / reset) before surfacing it. The proxy hop
|
|
||||||
// occasionally drops the connection; a fresh retry usually succeeds without
|
|
||||||
// failing over to another account.
|
|
||||||
submitConnectRetries = 2
|
|
||||||
submitRetryBackoff = 500 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
fileServiceIDPattern = regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`)
|
|
||||||
sedimentIDPattern = regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`)
|
|
||||||
realImageIDPattern = regexp.MustCompile(`\bfile_00000000[a-f0-9]{24}\b`)
|
|
||||||
conversationIDRE = regexp.MustCompile(`"conversation_id"\s*:\s*"([^"]+)"`)
|
|
||||||
scriptSrcRE = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
|
||||||
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
|
|
||||||
htmlDataBuildRE = regexp.MustCompile(`<html[^>]*data-build="([^"]*)"`)
|
|
||||||
|
|
||||||
// asyncMarkers signal that ChatGPT accepted the prompt and switched to the
|
|
||||||
// async image pipeline (image is delivered later via conversation polling
|
|
||||||
// rather than inline in the SSE stream). Their presence means "generating —
|
|
||||||
// keep polling", NOT failure.
|
|
||||||
asyncMarkers = []string{"image_gen_async", "image_gen_task_id", "trigger_async_ux", "ImageGenToolTemporal", "image_gen_title"}
|
|
||||||
|
|
||||||
// contentPolicyMarkers are stable substrings of ChatGPT's content-audit
|
|
||||||
// refusal message. When one appears in an assistant turn the prompt was
|
|
||||||
// rejected upstream — no image will ever arrive, so we must fail fast
|
|
||||||
// instead of polling until timeout.
|
|
||||||
contentPolicyMarkers = []string{
|
|
||||||
"this request may violate our content polic",
|
|
||||||
"this prompt may violate our content polic",
|
|
||||||
"may violate our content policies",
|
|
||||||
"i can't help with",
|
|
||||||
"i can\u2019t help with",
|
|
||||||
"i cannot help with",
|
|
||||||
"i can't assist with",
|
|
||||||
"i can\u2019t assist with",
|
|
||||||
"i cannot assist with",
|
|
||||||
"i'm unable to help with",
|
|
||||||
"i\u2019m unable to help with",
|
|
||||||
"can't create images",
|
|
||||||
"can\u2019t create images",
|
|
||||||
"cannot create images",
|
|
||||||
"can't generate images",
|
|
||||||
"can\u2019t generate images",
|
|
||||||
"cannot generate images",
|
|
||||||
"unable to create images",
|
|
||||||
"unable to generate images",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// containsAsyncMarker reports whether the SSE payload indicates the async image
|
|
||||||
// pipeline was engaged.
|
|
||||||
func containsAsyncMarker(text string) bool {
|
|
||||||
for _, m := range asyncMarkers {
|
|
||||||
if strings.Contains(text, m) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectContentPolicyRejection reports whether text contains a ChatGPT content
|
|
||||||
// audit refusal. Matching is case-insensitive for the English variants.
|
|
||||||
func detectContentPolicyRejection(text string) bool {
|
|
||||||
if text == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(text)
|
|
||||||
for _, m := range contentPolicyMarkers {
|
|
||||||
if strings.Contains(text, m) || strings.Contains(lower, m) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectText concatenates every string found under value (recursively) into sb.
|
|
||||||
func collectText(value any, sb *strings.Builder) {
|
|
||||||
switch x := value.(type) {
|
|
||||||
case string:
|
|
||||||
sb.WriteString(x)
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
case map[string]any:
|
|
||||||
for _, item := range x {
|
|
||||||
collectText(item, sb)
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for _, item := range x {
|
|
||||||
collectText(item, sb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// conversationRejected scans assistant turns of a fetched conversation for a
|
|
||||||
// content-policy refusal.
|
|
||||||
func conversationRejected(conversation map[string]any) bool {
|
|
||||||
mapping, _ := conversation["mapping"].(map[string]any)
|
|
||||||
for _, rawNode := range mapping {
|
|
||||||
node, _ := rawNode.(map[string]any)
|
|
||||||
message, _ := node["message"].(map[string]any)
|
|
||||||
if message == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
author, _ := message["author"].(map[string]any)
|
|
||||||
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
|
|
||||||
if role != "assistant" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
collectText(message["content"], &sb)
|
|
||||||
if detectContentPolicyRejection(sb.String()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// conversationEndedWithoutImage reports whether the model finished its turn
|
|
||||||
// with a plain-text answer and never engaged the async image pipeline (no tool
|
|
||||||
// turn, no image_gen task). ChatGPT localizes audit refusals, so this catches
|
|
||||||
// rejections in ANY language structurally: a closed text-only turn can never
|
|
||||||
// produce an image, and polling further only burns the budget.
|
|
||||||
func conversationEndedWithoutImage(conversation map[string]any) bool {
|
|
||||||
mapping, _ := conversation["mapping"].(map[string]any)
|
|
||||||
if len(mapping) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, rawNode := range mapping {
|
|
||||||
node, _ := rawNode.(map[string]any)
|
|
||||||
message, _ := node["message"].(map[string]any)
|
|
||||||
if message == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if metadata, _ := message["metadata"].(map[string]any); metadata != nil {
|
|
||||||
if stringValue(metadata["image_gen_task_id"]) != "" || metadata["image_gen_async"] == true {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
author, _ := message["author"].(map[string]any)
|
|
||||||
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
|
|
||||||
if role == "tool" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if role == "assistant" {
|
|
||||||
if content, _ := message["content"].(map[string]any); content != nil {
|
|
||||||
// Only tool-invoking content types mean generation is underway.
|
|
||||||
// Context nodes (model_editable_context, thoughts, …) also appear
|
|
||||||
// on refused turns and must NOT suppress the detection.
|
|
||||||
switch stringValue(content["content_type"]) {
|
|
||||||
case "code", "multimodal_text":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, rawNode := range mapping {
|
|
||||||
node, _ := rawNode.(map[string]any)
|
|
||||||
message, _ := node["message"].(map[string]any)
|
|
||||||
if message == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
author, _ := message["author"].(map[string]any)
|
|
||||||
if strings.ToLower(strings.TrimSpace(stringValue(author["role"]))) != "assistant" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if message["end_turn"] != true || stringValue(message["status"]) != "finished_successfully" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
collectText(message["content"], &sb)
|
|
||||||
if strings.TrimSpace(sb.String()) != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isConnResetErr reports whether err is a bare connection-level failure (the
|
|
||||||
// peer/proxy closed the connection before responding) rather than an HTTP-level
|
|
||||||
// error. Such failures are safe to retry: no response was received, so no
|
|
||||||
// conversation was created upstream.
|
|
||||||
func isConnResetErr(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
msg := strings.ToLower(err.Error())
|
|
||||||
for _, m := range []string{"eof", "connection reset", "reset by peer", "broken pipe", "connection refused"} {
|
|
||||||
if strings.Contains(msg, m) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringValue(v any) string {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case string:
|
|
||||||
return x
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return fmt.Sprint(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func intValue(v any) int {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case int:
|
|
||||||
return x
|
|
||||||
case int64:
|
|
||||||
return int(x)
|
|
||||||
case float64:
|
|
||||||
return int(x)
|
|
||||||
case float32:
|
|
||||||
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 decodeJWTPayload(token string) map[string]any {
|
|
||||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func newUUID() string {
|
|
||||||
return uuid.NewString()
|
|
||||||
}
|
|
||||||
|
|
||||||
func clip(v []byte, n int) string {
|
|
||||||
s := strings.TrimSpace(string(v))
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePOWResources(html string) ([]string, string) {
|
|
||||||
matches := scriptSrcRE.FindAllStringSubmatch(html, -1)
|
|
||||||
sources := make([]string, 0, len(matches))
|
|
||||||
dataBuild := ""
|
|
||||||
for _, match := range matches {
|
|
||||||
if len(match) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src := strings.TrimSpace(match[1])
|
|
||||||
if src == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sources = append(sources, src)
|
|
||||||
if dataBuild == "" {
|
|
||||||
if path := dataBuildPathRE.FindString(src); path != "" {
|
|
||||||
dataBuild = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dataBuild == "" {
|
|
||||||
if match := htmlDataBuildRE.FindStringSubmatch(html); len(match) >= 2 {
|
|
||||||
dataBuild = strings.TrimSpace(match[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(sources) == 0 {
|
|
||||||
sources = []string{defaultPOWScript}
|
|
||||||
}
|
|
||||||
return sources, dataBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
func timeMillis() int64 {
|
|
||||||
return time.Now().UnixMilli()
|
|
||||||
}
|
|
||||||
@@ -1,629 +0,0 @@
|
|||||||
// Package creativefabrica implements the Creative Fabrica Studio
|
|
||||||
// (studio.creativefabrica.com) video-generation upstream.
|
|
||||||
//
|
|
||||||
// One-shot accounts: every account's coins are just enough for exactly one
|
|
||||||
// generation, so a successful render kills the account. The credential is a
|
|
||||||
// .creativefabrica.com session cookie; a short-lived JWT is minted from it on
|
|
||||||
// demand via GraphQL /query/userAuth, and every model request authenticates
|
|
||||||
// with that JWT (the cookie is sent along as a fallback).
|
|
||||||
package creativefabrica
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
fhttp "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
"github.com/bogdanfinn/tls-client/profiles"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrAuth = errors.New("creativefabrica auth failed")
|
|
||||||
ErrQuotaExhausted = errors.New("creativefabrica quota exhausted")
|
|
||||||
ErrTemporaryUpstream = errors.New("creativefabrica upstream temporary error")
|
|
||||||
ErrDeadUpstream = errors.New("creativefabrica upstream fatal error")
|
|
||||||
ErrRateLimited = errors.New("creativefabrica rate limited")
|
|
||||||
// ErrPaymentRequired marks an account whose payment intent is in a failed
|
|
||||||
// state — it can never generate (the studio answers 400 failed_precondition
|
|
||||||
// "payment required ... COIN_PAYMENT_INTENT_STATUS_FAILED"). It wraps ErrAuth
|
|
||||||
// so the pool kills the account and fails over instead of burning retries.
|
|
||||||
ErrPaymentRequired = fmt.Errorf("%w: payment required", ErrAuth)
|
|
||||||
)
|
|
||||||
|
|
||||||
// isPaymentRequired reports whether a non-200 body is the account-level
|
|
||||||
// "payment required" rejection rather than a request-level parameter error.
|
|
||||||
//
|
|
||||||
// Connect unary errors don't always carry the marker in plaintext: the studio
|
|
||||||
// answers failed_precondition with the real detail base64-protobuf-encoded in
|
|
||||||
// details[].value ("payment required. payment status: COIN_PAYMENT_INTENT_...").
|
|
||||||
// Decode those values and scan the decoded bytes, so a failed coin intent still
|
|
||||||
// kills the account instead of being misread as a request-level 400.
|
|
||||||
func isPaymentRequired(status int, body string) bool {
|
|
||||||
if status != 400 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
b := strings.ToLower(body)
|
|
||||||
if strings.Contains(b, "payment required") || strings.Contains(b, "coin_payment_intent") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
var env struct {
|
|
||||||
Details []struct {
|
|
||||||
Value string `json:"value"`
|
|
||||||
} `json:"details"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, d := range env.Details {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(d.Value)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
low := strings.ToLower(string(raw))
|
|
||||||
if strings.Contains(low, "payment required") ||
|
|
||||||
strings.Contains(low, "coin_payment_intent") ||
|
|
||||||
strings.Contains(low, "coin_error_code_payment_required") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
graphQLHost = "https://graphql-gw.creativefabrica.com"
|
|
||||||
mediaMatrixHost = "https://studio-media-matrix.creativefabrica.com"
|
|
||||||
userAuthPath = "/query/userAuth"
|
|
||||||
userBalancePath = "/query/userBalance"
|
|
||||||
userPath = "/query/user"
|
|
||||||
initiatePath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/InitiateSession"
|
|
||||||
listSessionsPath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/ListSessions"
|
|
||||||
origin = "https://studio.creativefabrica.com"
|
|
||||||
pollInterval = 5 * time.Second
|
|
||||||
pollTimeout = 16 * time.Minute
|
|
||||||
downloadTimeout = 3 * time.Minute
|
|
||||||
videoServiceType = "SERVICE_TYPE_VIDEO_GENERATOR"
|
|
||||||
videoFrameRef = "VIDEO_FRAME_TYPE_REFERENCE"
|
|
||||||
visibilityPrivate = "SESSION_VISIBILITY_PRIVATE"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Model is one Creative Fabrica video model: the local catalog id, the upstream
|
|
||||||
// enum, the fixed duration in seconds, and the upstream resolution label.
|
|
||||||
type Model struct {
|
|
||||||
ID string // local model_configs id, e.g. "seedance-2.0"
|
|
||||||
Enum string // upstream enum, e.g. VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2
|
|
||||||
Duration int // fixed seconds (account plan is fixed-length)
|
|
||||||
Resolution string // upstream resolution label, e.g. 720p
|
|
||||||
}
|
|
||||||
|
|
||||||
// Models returns the two Creative Fabrica seedance models. The upstream enum
|
|
||||||
// differs between the two (SEEDANCE_2_FAST vs SEEDDREAM_2), matching the
|
|
||||||
// studio frontend's InitiateSession payloads.
|
|
||||||
func Models() map[string]Model {
|
|
||||||
return map[string]Model{
|
|
||||||
"seedance-2.0-fast": {
|
|
||||||
ID: "seedance-2.0-fast",
|
|
||||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDANCE_2_FAST",
|
|
||||||
Duration: 14,
|
|
||||||
Resolution: "720p",
|
|
||||||
},
|
|
||||||
"seedance-2.0": {
|
|
||||||
ID: "seedance-2.0",
|
|
||||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2",
|
|
||||||
Duration: 10,
|
|
||||||
Resolution: "720p",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupModel resolves a local model id to its upstream config. Returns ok=false
|
|
||||||
// when the id isn't a Creative Fabrica model.
|
|
||||||
func LookupModel(modelID string) (Model, bool) {
|
|
||||||
m, ok := Models()[modelID]
|
|
||||||
return m, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client talks to the Creative Fabrica Studio API through a Chrome-fingerprinted
|
|
||||||
// TLS client so the Cloudflare-protected Connect endpoints don't reject us.
|
|
||||||
type Client struct {
|
|
||||||
proxy string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(proxy string) *Client {
|
|
||||||
return &Client{proxy: strings.TrimSpace(proxy)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SetProxy(proxy string) {
|
|
||||||
c.proxy = strings.TrimSpace(proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExchangeToken mints the short-lived JWT from the account cookie via GraphQL
|
|
||||||
// /query/userAuth. Returns the token and the user id. A null / missing me means
|
|
||||||
// the cookie no longer authenticates → ErrAuth.
|
|
||||||
func (c *Client) ExchangeToken(ctx context.Context, cookie string) (token, userID string, err error) {
|
|
||||||
query := `{"query":"\n query userAuth {\n me {\n token\n user {\n id\n isTemporary\n }\n }\n}\n "}`
|
|
||||||
var payload struct {
|
|
||||||
Data struct {
|
|
||||||
Me *struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
User struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"user"`
|
|
||||||
} `json:"me"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
body, err := c.postGraphQL(ctx, cookie, "", userAuthPath, query)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return "", "", fmt.Errorf("%w: bad token response: %s", ErrAuth, clip(body, 300))
|
|
||||||
}
|
|
||||||
if payload.Data.Me == nil || strings.TrimSpace(payload.Data.Me.Token) == "" {
|
|
||||||
return "", "", fmt.Errorf("%w: cookie did not authenticate", ErrAuth)
|
|
||||||
}
|
|
||||||
return payload.Data.Me.Token, payload.Data.Me.User.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchBalance reads the coin balance via GraphQL /query/userBalance (the
|
|
||||||
// request authenticates with the cookie alone). Negative value on error.
|
|
||||||
func (c *Client) FetchBalance(ctx context.Context, cookie string) (int64, error) {
|
|
||||||
query := `{"query":"\nquery userBalance {\n userBalance {\n balance\n }\n}\n\n"}`
|
|
||||||
var payload struct {
|
|
||||||
Data struct {
|
|
||||||
UserBalance *struct {
|
|
||||||
Balance json.Number `json:"balance"`
|
|
||||||
} `json:"userBalance"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
body, err := c.postGraphQL(ctx, cookie, "", userBalancePath, query)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return -1, fmt.Errorf("bad balance response: %s", clip(body, 300))
|
|
||||||
}
|
|
||||||
if payload.Data.UserBalance == nil {
|
|
||||||
return -1, ErrAuth
|
|
||||||
}
|
|
||||||
b, _ := payload.Data.UserBalance.Balance.Int64()
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchUser reads the profile (email, name) via GraphQL /query/user, which the
|
|
||||||
// studio browser hits on every page load. The request authenticates with the
|
|
||||||
// cookie alone. Returns the email (empty on error); a null me means the cookie
|
|
||||||
// no longer authenticates → ErrAuth.
|
|
||||||
func (c *Client) FetchUser(ctx context.Context, cookie string) (string, error) {
|
|
||||||
query := `{"query":"\n query user {\n me {\n token\n user {\n id\n email\n }\n }\n}\n "}`
|
|
||||||
var payload struct {
|
|
||||||
Data struct {
|
|
||||||
Me *struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
User struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
} `json:"user"`
|
|
||||||
} `json:"me"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
body, err := c.postGraphQL(ctx, cookie, "", userPath, query)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return "", fmt.Errorf("%w: bad user response: %s", ErrAuth, clip(body, 300))
|
|
||||||
}
|
|
||||||
if payload.Data.Me == nil || payload.Data.Me.User.ID == "" {
|
|
||||||
return "", ErrAuth
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(payload.Data.Me.User.Email), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateVideo runs the full generation: InitiateSession → PUT reference
|
|
||||||
// images to the presigned S3 URLs → poll ListSessions until COMPLETED → (when
|
|
||||||
// downloadResult) fetch the MP4. Returns the bytes (nil when url-only) and the
|
|
||||||
// previewMediaUrl. durationSeconds is ignored: the plan fixes the length per
|
|
||||||
// model (LookupModel.Duration).
|
|
||||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, token, modelID, prompt, aspectRatio string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
|
||||||
m, ok := LookupModel(modelID)
|
|
||||||
if !ok {
|
|
||||||
return nil, "", fmt.Errorf("creativefabrica: unknown model %q", modelID)
|
|
||||||
}
|
|
||||||
sessionID, uploads, err := c.initiateSession(ctx, cookie, token, m, prompt, aspectRatio, refs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
// Each ref uploads to the presigned URL the session returned for it.
|
|
||||||
for i, u := range uploads {
|
|
||||||
if err := c.putS3(ctx, u, refs[i]); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
videoURL, err := c.pollSession(ctx, cookie, token, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, videoURL, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, videoURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return data, videoURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// initiateSession submits the generation and returns the session id plus the
|
|
||||||
// presigned upload URLs (one per reference image).
|
|
||||||
func (c *Client) initiateSession(ctx context.Context, cookie, token string, m Model, prompt, aspectRatio string, refs [][]byte) (string, []string, error) {
|
|
||||||
frames := make([]any, 0, len(refs))
|
|
||||||
refPrompt := strings.TrimSpace(prompt)
|
|
||||||
for i := range refs {
|
|
||||||
ref := fmt.Sprintf("img%d", i+1)
|
|
||||||
frames = append(frames, map[string]any{
|
|
||||||
"type": videoFrameRef,
|
|
||||||
"fileSize": fmt.Sprintf("%d", len(refs[i])),
|
|
||||||
"fileName": randomFileName(i + 1),
|
|
||||||
"ref": ref,
|
|
||||||
})
|
|
||||||
if !strings.Contains(refPrompt, "["+ref+"]") {
|
|
||||||
refPrompt += " Use [" + ref + "]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reqBody := map[string]any{
|
|
||||||
"visibility": visibilityPrivate,
|
|
||||||
"sessionRequestPromptToVideoGeneratorContent": map[string]any{
|
|
||||||
"serviceType": videoServiceType,
|
|
||||||
"promptContent": map[string]any{"prompt": refPrompt},
|
|
||||||
"resolution": resolutionEnum(m.Resolution),
|
|
||||||
"model": m.Enum,
|
|
||||||
"frames": frames,
|
|
||||||
"aspectRatio": aspectRatioEnum(aspectRatio),
|
|
||||||
"directorConfig": map[string]any{
|
|
||||||
"filmStock": map[string]any{"color": "DIRECTOR_FILM_STOCK_COLOR_FULL_COLOR"},
|
|
||||||
},
|
|
||||||
"videoDuration": map[string]any{"inSeconds": m.Duration},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
resp, err := c.postConnect(ctx, cookie, token, initiatePath, body, true)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Session struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
// The presigned upload URLs are nested under promptToVideoGeneratorContent.frames
|
|
||||||
// (each frame echoes the request frame + the S3 presigned `url`), not under a
|
|
||||||
// top-level session.frames. Reading the wrong level yields 0 uploads.
|
|
||||||
PromptToVideoGeneratorContent struct {
|
|
||||||
Frames []struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"frames"`
|
|
||||||
} `json:"promptToVideoGeneratorContent"`
|
|
||||||
} `json:"session"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
|
||||||
return "", nil, fmt.Errorf("creativefabrica bad initiate response: %s", clip(resp, 300))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(payload.Session.ID) == "" {
|
|
||||||
return "", nil, fmt.Errorf("creativefabrica initiate missing session: %s", clip(resp, 300))
|
|
||||||
}
|
|
||||||
respFrames := payload.Session.PromptToVideoGeneratorContent.Frames
|
|
||||||
uploads := make([]string, 0, len(respFrames))
|
|
||||||
for _, f := range respFrames {
|
|
||||||
if u := strings.TrimSpace(f.URL); u != "" {
|
|
||||||
uploads = append(uploads, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(uploads) != len(refs) {
|
|
||||||
return "", nil, fmt.Errorf("creativefabrica initiate returned %d upload urls for %d refs", len(uploads), len(refs))
|
|
||||||
}
|
|
||||||
return payload.Session.ID, uploads, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollSession polls ListSessions until the session is COMPLETED / FAILED and
|
|
||||||
// returns previewMediaUrl on success.
|
|
||||||
func (c *Client) pollSession(ctx context.Context, cookie, token, sessionID string) (string, error) {
|
|
||||||
reqBody, _ := json.Marshal(map[string]any{
|
|
||||||
"serviceType": videoServiceType,
|
|
||||||
"pagination": map[string]any{"take": 100},
|
|
||||||
"surface": "SURFACE_STUDIO",
|
|
||||||
})
|
|
||||||
deadline := time.Now().Add(pollTimeout)
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return "", fmt.Errorf("creativefabrica generation timed out after %v", pollTimeout)
|
|
||||||
}
|
|
||||||
resp, err := c.postConnect(ctx, cookie, token, listSessionsPath, reqBody, false)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Sessions []map[string]any `json:"sessions"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
|
||||||
return "", fmt.Errorf("creativefabrica bad list response: %s", clip(resp, 300))
|
|
||||||
}
|
|
||||||
for _, s := range payload.Sessions {
|
|
||||||
id, _ := s["id"].(string)
|
|
||||||
if id != sessionID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status, _ := s["status"].(string)
|
|
||||||
switch status {
|
|
||||||
case "SESSION_STATUS_COMPLETED":
|
|
||||||
if u := strings.TrimSpace(stringValue(s["previewMediaUrl"])); u != "" {
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("creativefabrica session completed without preview url")
|
|
||||||
case "SESSION_STATUS_FAILED", "SESSION_STATUS_CANCELLED", "SESSION_STATUS_ERROR":
|
|
||||||
detail := ""
|
|
||||||
for _, k := range []string{"errorMessage", "failureReason", "error", "message"} {
|
|
||||||
if v, ok := s[k]; ok {
|
|
||||||
if d := strings.TrimSpace(stringValue(v)); d != "" {
|
|
||||||
detail = d
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("creativefabrica session %s%s", status, withDetail(detail))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-time.After(pollInterval):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// putS3 uploads a reference image to the presigned URL. S3 doesn't care about
|
|
||||||
// TLS fingerprinting, so a plain client is fine here.
|
|
||||||
func (c *Client) putS3(ctx context.Context, presigned string, data []byte) error {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, presigned, bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "image/png")
|
|
||||||
req.Header.Set("Origin", origin)
|
|
||||||
req.Header.Set("Referer", origin+"/")
|
|
||||||
req.Header.Set("User-Agent", defaultUA())
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creativefabrica s3 upload: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("creativefabrica s3 upload failed: %d %s", resp.StatusCode, clip(body, 200))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// download fetches the finished MP4 from the public video-v2 URL.
|
|
||||||
func (c *Client) download(parent context.Context, videoURL string) ([]byte, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), downloadTimeout)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "*/*")
|
|
||||||
req.Header.Set("User-Agent", defaultUA())
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: download: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("creativefabrica download failed: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: read body: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// postGraphQL calls a graphql-gw endpoint with the raw JSON body. Authenticated
|
|
||||||
// by cookie (balance) and optionally the JWT too. Direct connection (no proxy).
|
|
||||||
func (c *Client) postGraphQL(ctx context.Context, cookie, token, path, body string) ([]byte, error) {
|
|
||||||
return c.postJSON(ctx, graphQLHost+path, token, cookie, []byte(body), false, true, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// postConnect calls a Connect unary endpoint on the media-matrix service. The
|
|
||||||
// Connect-Protocol-Version header marks the POST as a Connect RPC (distinct from
|
|
||||||
// a plain JSON REST POST) — the studio browser always sends it. Only the
|
|
||||||
// InitiateSession call (下单) egresses through the proxy; polling runs direct.
|
|
||||||
func (c *Client) postConnect(ctx context.Context, cookie, token, path string, body []byte, useProxy bool) ([]byte, error) {
|
|
||||||
return c.postJSON(ctx, mediaMatrixHost+path, token, cookie, body, true, false, useProxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) postJSON(ctx context.Context, url, token, cookie string, body []byte, connect, graphql, useProxy bool) ([]byte, error) {
|
|
||||||
sess, err := c.newTLSClient(useProxy)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := fhttp.NewRequest(fhttp.MethodPost, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = fhttp.Header{
|
|
||||||
"content-type": {"application/json"},
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"origin": {origin},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"user-agent": {defaultUA()},
|
|
||||||
}
|
|
||||||
if graphql {
|
|
||||||
req.Header.Set("accept", "application/json, multipart/mixed")
|
|
||||||
}
|
|
||||||
if connect {
|
|
||||||
req.Header.Set("connect-protocol-version", "1")
|
|
||||||
}
|
|
||||||
if cookie != "" {
|
|
||||||
req.Header.Set("cookie", cookie)
|
|
||||||
}
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
resp, err := sess.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("creativefabrica request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
|
||||||
return nil, fmt.Errorf("%w (%d: %s)", ErrAuth, resp.StatusCode, clip(data, 300))
|
|
||||||
case isPaymentRequired(resp.StatusCode, string(data)):
|
|
||||||
return nil, fmt.Errorf("%w (%d: %s)", ErrPaymentRequired, resp.StatusCode, clip(data, 300))
|
|
||||||
case resp.StatusCode == 429:
|
|
||||||
return nil, fmt.Errorf("%w (429)", ErrRateLimited)
|
|
||||||
case resp.StatusCode >= 500:
|
|
||||||
return nil, fmt.Errorf("%w (%d: %s)", ErrDeadUpstream, resp.StatusCode, clip(data, 300))
|
|
||||||
case resp.StatusCode != 200:
|
|
||||||
return nil, fmt.Errorf("creativefabrica %d: %s", resp.StatusCode, clip(data, 300))
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolutionEnum(res string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(res)) {
|
|
||||||
case "720p":
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
|
||||||
case "1080p":
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_1080P"
|
|
||||||
default:
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func aspectRatioEnum(ratio string) string {
|
|
||||||
switch strings.ReplaceAll(strings.TrimSpace(ratio), " ", "") {
|
|
||||||
case "16:9":
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
|
||||||
case "9:16":
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_9_16"
|
|
||||||
case "1:1":
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_1_1"
|
|
||||||
case "4:3":
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_4_3"
|
|
||||||
case "3:4":
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_3_4"
|
|
||||||
default:
|
|
||||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomFileName mimics the studio's client-generated reference name
|
|
||||||
// ("_<base36-ish id>_<n>.png"). The value only needs to be unique per upload.
|
|
||||||
func randomFileName(n int) string {
|
|
||||||
return "_" + randomID() + "_" + fmt.Sprintf("%d", n) + ".png"
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomID() string {
|
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
b := make([]byte, 26)
|
|
||||||
for i := range b {
|
|
||||||
v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
|
||||||
b[i] = alphabet[v.Int64()]
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLS client plumbing: a Chrome-fingerprinted client so the Cloudflare front of
|
|
||||||
// the Connect endpoints sees a plausible browser handshake.
|
|
||||||
var fingerprints = []profiles.ClientProfile{
|
|
||||||
profiles.Chrome_146,
|
|
||||||
profiles.Chrome_144,
|
|
||||||
profiles.Chrome_133,
|
|
||||||
profiles.Chrome_131,
|
|
||||||
}
|
|
||||||
|
|
||||||
type tlsSession struct {
|
|
||||||
client tlsclient.HttpClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) newTLSClient(useProxy bool) (*tlsSession, error) {
|
|
||||||
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(fingerprints))))
|
|
||||||
options := []tlsclient.HttpClientOption{
|
|
||||||
tlsclient.WithTimeoutSeconds(60),
|
|
||||||
tlsclient.WithClientProfile(fingerprints[idx.Int64()]),
|
|
||||||
tlsclient.WithNotFollowRedirects(),
|
|
||||||
tlsclient.WithRandomTLSExtensionOrder(),
|
|
||||||
}
|
|
||||||
if useProxy && c.proxy != "" {
|
|
||||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
|
||||||
}
|
|
||||||
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &tlsSession{client: client}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultUA() string {
|
|
||||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
|
||||||
}
|
|
||||||
|
|
||||||
func clip(v []byte, n int) string {
|
|
||||||
s := strings.TrimSpace(string(v))
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringValue coerces a decoded JSON value to its string form.
|
|
||||||
func stringValue(v any) string {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case string:
|
|
||||||
return x
|
|
||||||
case float64:
|
|
||||||
return fmt.Sprintf("%.0f", x)
|
|
||||||
case json.Number:
|
|
||||||
return x.String()
|
|
||||||
case bool:
|
|
||||||
if x {
|
|
||||||
return "true"
|
|
||||||
}
|
|
||||||
return "false"
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%v", x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withDetail appends a failure detail to an error message when present.
|
|
||||||
func withDetail(detail string) string {
|
|
||||||
if detail == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return " (" + detail + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package creativefabrica
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBalance(t *testing.T) {
|
|
||||||
cookie := os.Getenv("CF_COOKIE")
|
|
||||||
if cookie == "" {
|
|
||||||
t.Skip("CF_COOKIE not set")
|
|
||||||
}
|
|
||||||
c := NewClient("")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
tok, uid, err := c.ExchangeToken(ctx, cookie)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("exchange: %v", err)
|
|
||||||
}
|
|
||||||
bal, err := c.FetchBalance(ctx, cookie)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("balance: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("user=%s token=%d bal=%d", uid, len(tok), bal)
|
|
||||||
}
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
// Package custom implements a generic OpenAI-compatible upstream client. A
|
|
||||||
// "custom" model forwards generation to any OpenAI-compatible API: the upstream
|
|
||||||
// base_url + api_key live on a custom account (pool="custom"), the upstream model
|
|
||||||
// name on the model config (UpstreamModel). Calls go DIRECT (no tls-client, no
|
|
||||||
// proxy) — the upstream is a normal API with no anti-bot.
|
|
||||||
package custom
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrAuth = errors.New("custom upstream auth failed")
|
|
||||||
ErrQuotaExhausted = errors.New("custom upstream quota exhausted")
|
|
||||||
ErrTemporaryUpstream = errors.New("custom upstream temporary error")
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct{}
|
|
||||||
|
|
||||||
func NewClient() *Client { return &Client{} }
|
|
||||||
|
|
||||||
// sanitizeErr strips the upstream URL/host from a network error so a user's
|
|
||||||
// private upstream URL never leaks into the event log / API response.
|
|
||||||
func sanitizeErr(err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s := err.Error()
|
|
||||||
switch {
|
|
||||||
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
|
||||||
return "request timeout"
|
|
||||||
case strings.Contains(s, "connection refused"):
|
|
||||||
return "connection refused"
|
|
||||||
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
|
||||||
return "cannot reach upstream"
|
|
||||||
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
|
||||||
return "TLS error"
|
|
||||||
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
|
||||||
return "connection reset"
|
|
||||||
}
|
|
||||||
var ue *url.Error
|
|
||||||
if errors.As(err, &ue) {
|
|
||||||
return strings.ToLower(ue.Op) + " upstream failed"
|
|
||||||
}
|
|
||||||
return "upstream request failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
func httpClient() *http.Client { return &http.Client{Timeout: 30 * time.Minute} }
|
|
||||||
|
|
||||||
// GenerateImage calls the upstream OpenAI image API. With reference images it
|
|
||||||
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
|
|
||||||
// the raw image bytes (decoded from b64_json, or downloaded from url).
|
|
||||||
func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
|
||||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
||||||
if baseURL == "" || apiKey == "" {
|
|
||||||
return nil, "", ErrAuth
|
|
||||||
}
|
|
||||||
var req *http.Request
|
|
||||||
var err error
|
|
||||||
if len(refs) > 0 {
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
w := multipart.NewWriter(body)
|
|
||||||
_ = w.WriteField("model", model)
|
|
||||||
_ = w.WriteField("prompt", prompt)
|
|
||||||
// Ask the upstream for a URL (not base64) so we can pass it through directly.
|
|
||||||
_ = w.WriteField("response_format", "url")
|
|
||||||
if size != "" {
|
|
||||||
_ = w.WriteField("size", size)
|
|
||||||
}
|
|
||||||
for i, r := range refs {
|
|
||||||
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
|
|
||||||
if e != nil {
|
|
||||||
return nil, "", e
|
|
||||||
}
|
|
||||||
_, _ = fw.Write(r)
|
|
||||||
}
|
|
||||||
_ = w.Close()
|
|
||||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
||||||
} else {
|
|
||||||
payload := map[string]any{"model": model, "prompt": prompt, "n": 1, "response_format": "url"}
|
|
||||||
if size != "" {
|
|
||||||
payload["size"] = size
|
|
||||||
}
|
|
||||||
if quality != "" {
|
|
||||||
payload["quality"] = quality
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(payload)
|
|
||||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
resp, err := httpClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if e := mapStatus(resp.StatusCode, body); e != nil {
|
|
||||||
return nil, "", e
|
|
||||||
}
|
|
||||||
return imageFromResponse(ctx, body, downloadResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateVideo drives the upstream Sora-style async video API:
|
|
||||||
// POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content.
|
|
||||||
// Reference frames (image-to-video / first-last frames) are sent as multipart
|
|
||||||
// input_reference[] files, matching the OpenAI videos API. When downloadResult
|
|
||||||
// is false it returns the upstream content URL instead.
|
|
||||||
func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prompt, size string, seconds int, frames [][]byte, downloadResult bool) ([]byte, string, error) {
|
|
||||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
||||||
if baseURL == "" || apiKey == "" {
|
|
||||||
return nil, "", ErrAuth
|
|
||||||
}
|
|
||||||
var created map[string]any
|
|
||||||
var err error
|
|
||||||
if len(frames) > 0 {
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
w := multipart.NewWriter(body)
|
|
||||||
_ = w.WriteField("model", model)
|
|
||||||
_ = w.WriteField("prompt", prompt)
|
|
||||||
if size != "" {
|
|
||||||
_ = w.WriteField("size", size)
|
|
||||||
}
|
|
||||||
if seconds > 0 {
|
|
||||||
_ = w.WriteField("seconds", fmt.Sprintf("%d", seconds))
|
|
||||||
}
|
|
||||||
for i, f := range frames {
|
|
||||||
fw, e := w.CreateFormFile("input_reference[]", fmt.Sprintf("frame_%d.png", i+1))
|
|
||||||
if e != nil {
|
|
||||||
return nil, "", e
|
|
||||||
}
|
|
||||||
_, _ = fw.Write(f)
|
|
||||||
}
|
|
||||||
_ = w.Close()
|
|
||||||
created, err = c.doMultipart(ctx, baseURL+"/v1/videos", apiKey, body, w.FormDataContentType())
|
|
||||||
} else {
|
|
||||||
payload := map[string]any{"model": model, "prompt": prompt}
|
|
||||||
if size != "" {
|
|
||||||
payload["size"] = size
|
|
||||||
}
|
|
||||||
if seconds > 0 {
|
|
||||||
payload["seconds"] = fmt.Sprintf("%d", seconds)
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(payload)
|
|
||||||
created, err = c.doJSON(ctx, http.MethodPost, baseURL+"/v1/videos", apiKey, raw)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
jobID := strings.TrimSpace(stringValue(created["id"]))
|
|
||||||
if jobID == "" {
|
|
||||||
return nil, "", fmt.Errorf("%w: video create missing id", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
// Poll until terminal.
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
job, err := c.doJSON(ctx, http.MethodGet, baseURL+"/v1/videos/"+jobID, apiKey, nil)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, ErrTemporaryUpstream) {
|
|
||||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
|
||||||
return nil, "", ctx.Err()
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
switch strings.ToLower(strings.TrimSpace(stringValue(job["status"]))) {
|
|
||||||
case "completed", "succeeded", "success":
|
|
||||||
contentURL := baseURL + "/v1/videos/" + jobID + "/content"
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, contentURL, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, contentURL, apiKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return data, contentURL, nil
|
|
||||||
case "failed", "error", "canceled", "cancelled":
|
|
||||||
reason := stringValue(job["error"])
|
|
||||||
if isCreditError(reason) {
|
|
||||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160))
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160))
|
|
||||||
}
|
|
||||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
|
||||||
return nil, "", ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body []byte) (map[string]any, error) {
|
|
||||||
var reader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
reader = bytes.NewReader(body)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(method, url, reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
resp, err := httpClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
|
||||||
if e := mapStatus(resp.StatusCode, raw); e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return map[string]any{}, nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) doMultipart(ctx context.Context, url, apiKey string, body io.Reader, contentType string) (map[string]any, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
req.Header.Set("Content-Type", contentType)
|
|
||||||
resp, err := httpClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
|
||||||
if e := mapStatus(resp.StatusCode, raw); e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return map[string]any{}, nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, error) {
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
resp, err := httpClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil, fmt.Errorf("%w: empty download", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageBytesFromResponse extracts image bytes from an OpenAI images response:
|
|
||||||
// data[0].b64_json (preferred) or data[0].url (downloaded).
|
|
||||||
// imageFromResponse parses an OpenAI image response and returns the upstream URL.
|
|
||||||
// We always request response_format=url, so the response must carry a URL — a
|
|
||||||
// base64-only response is treated as an error (no base64 pass-through). With
|
|
||||||
// downloadResult=false the URL is returned directly (no download).
|
|
||||||
func imageFromResponse(ctx context.Context, body []byte, downloadResult bool) ([]byte, string, error) {
|
|
||||||
var out struct {
|
|
||||||
Data []struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 {
|
|
||||||
return nil, "", fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160))
|
|
||||||
}
|
|
||||||
url := strings.TrimSpace(out.Data[0].URL)
|
|
||||||
if url == "" {
|
|
||||||
return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, url, nil
|
|
||||||
}
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
resp, err := httpClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
return raw, url, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapStatus(status int, body []byte) error {
|
|
||||||
switch {
|
|
||||||
case status >= 200 && status < 300:
|
|
||||||
return nil
|
|
||||||
case status == 401 || status == 403:
|
|
||||||
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
|
|
||||||
case status == 429:
|
|
||||||
// Custom upstreams have NO "quota exhausted" lock — a 429 is just rate
|
|
||||||
// limiting, treated as a temporary error (fail over, account stays active).
|
|
||||||
return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160))
|
|
||||||
case status >= 500:
|
|
||||||
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
|
|
||||||
default:
|
|
||||||
if isCreditError(string(body)) {
|
|
||||||
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("custom: %d %s", status, clip(body, 160))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCreditError(s string) bool {
|
|
||||||
s = strings.ToLower(s)
|
|
||||||
return strings.Contains(s, "insufficient") || strings.Contains(s, "quota") ||
|
|
||||||
strings.Contains(s, "credit") || strings.Contains(s, "balance")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 clip(b []byte, n int) string {
|
|
||||||
s := strings.TrimSpace(string(b))
|
|
||||||
if len(s) > n {
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
|
||||||
t := time.NewTimer(d)
|
|
||||||
defer t.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-t.C:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,935 +0,0 @@
|
|||||||
// Package grok implements the Grok (grok.com / xAI) provider client. Auth is the
|
|
||||||
// website "sso" session cookie (a JWT whose only claim is a session_id — no exp,
|
|
||||||
// no refresh: when the session dies upstream the account is simply dead, never
|
|
||||||
// renewed). grok.com gates requests with an x-statsig-id header; its value
|
|
||||||
// is a 70-byte anti-bot record — header[49] | counter_le32 |
|
|
||||||
// sha256("METHOD!path!counter"+suffix)[:16] | trailer — XOR-masked with one
|
|
||||||
// random byte and base64-encoded; we reproduce it exactly in statsigID (the
|
|
||||||
// build-specific header/suffix/trailer are env-overridable when grok rotates
|
|
||||||
// them). Uses tls-client so the JA3/JA4 fingerprint matches Chrome.
|
|
||||||
package grok
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"math/rand/v2"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
"github.com/bogdanfinn/tls-client/profiles"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
apiBase = "https://grok.com"
|
|
||||||
origin = "https://grok.com"
|
|
||||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
|
||||||
// fullCredits is the weekly grant — UI shows "100 满额".
|
|
||||||
fullCredits = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrAuth = errors.New("grok auth failed")
|
|
||||||
ErrQuotaExhausted = errors.New("grok quota exhausted")
|
|
||||||
ErrTemporaryUpstream = errors.New("grok upstream temporary error")
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
proxy string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(proxy string) *Client {
|
|
||||||
return &Client{proxy: strings.TrimSpace(proxy)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SetProxy(proxy string) {
|
|
||||||
c.proxy = strings.TrimSpace(proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsGrokToken reports whether a JWT looks like a Grok website "sso" cookie: a
|
|
||||||
// payload whose ONLY claim is "session_id". That disambiguates it from a runway
|
|
||||||
// token (id + sso claims) or a chatgpt token (openai.com claims).
|
|
||||||
func IsGrokToken(token string) bool {
|
|
||||||
claims := decodeJWTPayload(token)
|
|
||||||
if len(claims) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, ok := claims["session_id"]; !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Reject tokens that ALSO carry other-provider markers.
|
|
||||||
for k := range claims {
|
|
||||||
if k == "session_id" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if k == "sso" || k == "id" || strings.HasPrefix(k, "https://api.openai.com/") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionIDFromToken returns the sso session id (for dedup / display).
|
|
||||||
func SessionIDFromToken(token string) string {
|
|
||||||
return strings.TrimSpace(stringValue(decodeJWTPayload(token)["session_id"]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractAccountInfo returns the free (no-network) account view. grok sso has no
|
|
||||||
// email/exp claim, so identity falls back to the session id.
|
|
||||||
func ExtractAccountInfo(token string) map[string]any {
|
|
||||||
sid := SessionIDFromToken(token)
|
|
||||||
return map[string]any{
|
|
||||||
"email": emptyStringNil(sid),
|
|
||||||
"session_id": emptyStringNil(sid),
|
|
||||||
"expires_at": nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchCreditsBalance reads the account's live credit balance via the billing
|
|
||||||
// gRPC-web endpoint GetGrokCreditsConfig (empty request). The response carries
|
|
||||||
// the remaining credits (field 1, a float32) and the weekly reset timestamp
|
|
||||||
// (field 5). A 401/403 maps to ErrAuth (the session is dead). Returns the
|
|
||||||
// normalized map the TokenService quota plumbing expects.
|
|
||||||
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return unknownBalance("empty token"), nil
|
|
||||||
}
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.ensureChallenge(ctx, client, token)
|
|
||||||
// gRPC-web empty message frame: 1-byte flag + 4-byte length (both zero).
|
|
||||||
body := []byte{0, 0, 0, 0, 0}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", strings.NewReader(string(body)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
c.applyHeaders(req, token, map[string]string{
|
|
||||||
"content-type": "application/grpc-web+proto",
|
|
||||||
"x-grpc-web": "1",
|
|
||||||
"accept": "application/grpc-web+proto",
|
|
||||||
})
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return unknownBalance("network: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(raw, 160))), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGrokCreditsConfig field #1 is the credits USED this period (not remaining):
|
|
||||||
// an exhausted account reads 100, a fresh one reads ~0. Remaining = 100 - used.
|
|
||||||
// Field #5 carries the credits' own reset timestamp (weekly grant refill) —
|
|
||||||
// this is the 恢复时间 we surface, NOT the subscription's billing-period end.
|
|
||||||
used, resetUnix, ok := parseCreditsConfig(raw)
|
|
||||||
if !ok {
|
|
||||||
return unknownBalance("unparsable credits config"), nil
|
|
||||||
}
|
|
||||||
if used < 0 {
|
|
||||||
used = 0
|
|
||||||
}
|
|
||||||
if used > fullCredits {
|
|
||||||
used = fullCredits
|
|
||||||
}
|
|
||||||
remaining := fullCredits - used
|
|
||||||
|
|
||||||
// 恢复时间: the credits-config weekly reset (when the free grant refills).
|
|
||||||
reset := strings.TrimSpace(resetUnix)
|
|
||||||
return map[string]any{
|
|
||||||
"remaining": remaining,
|
|
||||||
"used": used,
|
|
||||||
"total": fullCredits,
|
|
||||||
"reset_after": emptyStringNil(reset),
|
|
||||||
"unknown": false,
|
|
||||||
"error": nil,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscription is the membership view parsed from GET /rest/subscriptions.
|
|
||||||
type Subscription struct {
|
|
||||||
Member bool // a subscription with status ACTIVE exists (INACTIVE entries don't count)
|
|
||||||
Tier string // e.g. SUBSCRIPTION_TIER_GROK_PRO ("" for free)
|
|
||||||
Status string // e.g. SUBSCRIPTION_STATUS_ACTIVE
|
|
||||||
BillingPeriodEnd string // RFC3339; when the plan renews / credits reset
|
|
||||||
FreeTrial bool // currently in a free-trial offer
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchSubscription reads GET /rest/subscriptions and reports the account's
|
|
||||||
// membership. Member is true only when an entry with SUBSCRIPTION_STATUS_ACTIVE
|
|
||||||
// exists: a lapsed membership keeps its entry but flips to
|
|
||||||
// SUBSCRIPTION_STATUS_INACTIVE, and an empty array means never subscribed —
|
|
||||||
// both read as Member=false (the entry's tier/status are still surfaced).
|
|
||||||
// A 401/403 maps to ErrAuth; other transport/HTTP errors are returned so callers
|
|
||||||
// can treat them as best-effort (they already have the credit balance).
|
|
||||||
func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscription, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.ensureChallenge(ctx, client, token)
|
|
||||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
c.applyHeaders(req, token, nil)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("%w: subscriptions http %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Subscriptions []struct {
|
|
||||||
Tier string `json:"tier"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
BillingPeriodEnd string `json:"billingPeriodEnd"`
|
|
||||||
ActiveOffer struct {
|
|
||||||
FreeTrial *struct {
|
|
||||||
TrialDays int `json:"trialDays"`
|
|
||||||
} `json:"freeTrial"`
|
|
||||||
} `json:"activeOffer"`
|
|
||||||
} `json:"subscriptions"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &body); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: subscriptions non-json", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
out := &Subscription{}
|
|
||||||
// Surface the ACTIVE subscription if any (falling back to the first entry
|
|
||||||
// for tier/status info), but only an ACTIVE one sets Member.
|
|
||||||
for i, s := range body.Subscriptions {
|
|
||||||
active := strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE")
|
|
||||||
if i == 0 || active {
|
|
||||||
out.Member = active
|
|
||||||
out.Tier = strings.TrimSpace(s.Tier)
|
|
||||||
out.Status = strings.TrimSpace(s.Status)
|
|
||||||
out.BillingPeriodEnd = strings.TrimSpace(s.BillingPeriodEnd)
|
|
||||||
out.FreeTrial = s.ActiveOffer.FreeTrial != nil
|
|
||||||
if active {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchSession reads the account profile via GET /api/auth/session and returns
|
|
||||||
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
|
|
||||||
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return "", "", ErrAuth
|
|
||||||
}
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
c.ensureChallenge(ctx, client, token)
|
|
||||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/auth/session", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
c.applyHeaders(req, token, nil)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
||||||
return "", "", ErrAuth
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", "", fmt.Errorf("%w: session http %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Session struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
} `json:"session"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &body); err != nil {
|
|
||||||
return "", "", fmt.Errorf("%w: session non-json", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
// A dead sso cookie answers 200 {"status":"unauthenticated"} rather than 401.
|
|
||||||
if !strings.EqualFold(body.Status, "authenticated") {
|
|
||||||
return "", "", ErrAuth
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// grok's x-statsig-id is a per-session anti-bot token. Its 3-byte "F" tail is a
|
|
||||||
// browser fingerprint the server recomputes from the homepage seed + curve set,
|
|
||||||
// and the byte-indexing that derives it ROTATES on every grok web reship — so any
|
|
||||||
// hand-ported algorithm goes stale within a day (403 anti-bot). The durable path
|
|
||||||
// therefore runs grok's OWN signer in goja (statsig_engine.go): we fetch the seed
|
|
||||||
// + curves from the homepage and let grok's code do all the (rotating) indexing.
|
|
||||||
// statsigID prefers that engine; the hand-ported computeStatsigTail below and the
|
|
||||||
// static env-overridable defaults are only a last-resort fallback. See the package
|
|
||||||
// doc and statsig_engine.go for the full picture.
|
|
||||||
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
|
|
||||||
const (
|
|
||||||
statsigEpoch = 1682924400
|
|
||||||
defaultStatsigHeader = "00a1adb5012bd32f844f4426c62680d91c6129361eb9459a759710e179a888a99b21678e1f0b1e8952de6a6b3ca019f74b"
|
|
||||||
defaultStatsigSuffix = "obfiowerehiringd244100f5c28f5c28f5c047ae147ae147b047ae147ae147b0f5c28f5c28f5c00"
|
|
||||||
defaultStatsigTrailer = 3
|
|
||||||
statsigSaltPrefix = "obfiowerehiring"
|
|
||||||
statsigAnimDuration = 4096
|
|
||||||
statsigTTL = 5 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
statsigHeader = resolveStatsigHeader()
|
|
||||||
statsigSuffix = envOr("GROK_STATSIG_SUFFIX", defaultStatsigSuffix)
|
|
||||||
statsigTrailer = resolveStatsigTrailer()
|
|
||||||
|
|
||||||
statsigMetaRe = regexp.MustCompile(`name="grok[^"]*verification"[^>]*content="([^"]+)"`)
|
|
||||||
|
|
||||||
statsigMu sync.Mutex
|
|
||||||
statsigCache = map[string]statsigChallenge{} // keyed by sso token
|
|
||||||
)
|
|
||||||
|
|
||||||
// statsigChallenge is a resolved, self-consistent (header, salt) pair for one
|
|
||||||
// grok session, derived from the homepage seed + curves.
|
|
||||||
type statsigChallenge struct {
|
|
||||||
header []byte
|
|
||||||
suffix string
|
|
||||||
trailer byte
|
|
||||||
fetchedAt time.Time
|
|
||||||
|
|
||||||
// Inputs for the durable goja signer (statsig_js.go): the raw <meta> seed
|
|
||||||
// content and the curves JSON. Empty when parsing failed (engine then skipped).
|
|
||||||
seedB64 string
|
|
||||||
curvesJSON string
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsigCurve is one entry of the per-load curve set injected via the Next.js
|
|
||||||
// RSC stream; the server uses it (with the seed) to recompute F.
|
|
||||||
type statsigCurve struct {
|
|
||||||
Color []int `json:"color"`
|
|
||||||
Deg int `json:"deg"`
|
|
||||||
Bezier []int `json:"bezier"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveStatsigHeader() []byte {
|
|
||||||
h := envOr("GROK_STATSIG_HEADER_HEX", defaultStatsigHeader)
|
|
||||||
b, err := hex.DecodeString(h)
|
|
||||||
if err != nil || len(b) != 49 {
|
|
||||||
b, _ = hex.DecodeString(defaultStatsigHeader)
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveStatsigTrailer() byte {
|
|
||||||
if v := strings.TrimSpace(os.Getenv("GROK_STATSIG_TRAILER")); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 255 {
|
|
||||||
return byte(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultStatsigTrailer
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOr(key, def string) string {
|
|
||||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureChallenge refreshes the cached (header, salt) for the session if missing
|
|
||||||
// or stale. Any failure is non-fatal: statsigID then falls back to the static
|
|
||||||
// defaults. An explicit env override disables dynamic fetching entirely.
|
|
||||||
func (c *Client) ensureChallenge(ctx context.Context, client tlsclient.HttpClient, token string) {
|
|
||||||
if token == "" || client == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
statsigMu.Lock()
|
|
||||||
cur, ok := statsigCache[token]
|
|
||||||
fresh := ok && time.Since(cur.fetchedAt) < statsigTTL
|
|
||||||
statsigMu.Unlock()
|
|
||||||
if fresh {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := fetchStatsigChallenge(ctx, client)
|
|
||||||
if err != nil {
|
|
||||||
// Silent fallback to static defaults is the #1 cause of a recurring
|
|
||||||
// "403 anti-bot": the homepage structure changed and we never notice.
|
|
||||||
// Surface it so the failure mode (fetch/parse broke vs. offsets rotated)
|
|
||||||
// is diagnosable from logs instead of guessing.
|
|
||||||
log.Printf("grok statsig: self-heal failed, using stale static defaults (403 likely): %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("grok statsig: self-heal ok header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
|
|
||||||
statsigMu.Lock()
|
|
||||||
statsigCache[token] = ch
|
|
||||||
statsigMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchStatsigChallenge does a browser-free homepage GET and derives a
|
|
||||||
// self-consistent (header, salt) pair: header = 0x00 + seed, salt = prefix + F.
|
|
||||||
//
|
|
||||||
// The request is ANONYMOUS (no sso cookie): grok redirects a request carrying an
|
|
||||||
// sso cookie to the sign-in page, whose HTML omits the anti-bot seed <meta> and
|
|
||||||
// the curves array — so a cookied fetch silently loses the challenge inputs and
|
|
||||||
// statsigID falls back to the stale static recipe (403 anti-bot). The seed/curves
|
|
||||||
// are per-build and account-independent (the seed is transmitted inside the
|
|
||||||
// x-statsig-id header the server re-verifies), so the anonymous landing page is
|
|
||||||
// the reliable source for every session.
|
|
||||||
func fetchStatsigChallenge(ctx context.Context, client tlsclient.HttpClient) (statsigChallenge, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/", nil)
|
|
||||||
if err != nil {
|
|
||||||
return statsigChallenge{}, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
http.HeaderOrderKey: {"accept", "accept-language", "user-agent"},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return statsigChallenge{}, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return statsigChallenge{}, err
|
|
||||||
}
|
|
||||||
html := string(raw)
|
|
||||||
|
|
||||||
mm := statsigMetaRe.FindStringSubmatch(html)
|
|
||||||
if mm == nil {
|
|
||||||
return statsigChallenge{}, errors.New("statsig: seed meta not found")
|
|
||||||
}
|
|
||||||
curves, err := parseStatsigCurves(html)
|
|
||||||
if err != nil {
|
|
||||||
return statsigChallenge{}, err
|
|
||||||
}
|
|
||||||
curvesJSON, err := json.Marshal(curves)
|
|
||||||
if err != nil {
|
|
||||||
return statsigChallenge{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primary path is the goja signer (statsig_js.go); set it up / refresh it for
|
|
||||||
// this build. Static (header, salt) below is only a last-resort fallback.
|
|
||||||
ensureEngine(ctx, client, html)
|
|
||||||
|
|
||||||
ch := statsigChallenge{
|
|
||||||
header: statsigHeader,
|
|
||||||
suffix: statsigSuffix,
|
|
||||||
trailer: statsigTrailer,
|
|
||||||
fetchedAt: time.Now(),
|
|
||||||
seedB64: mm[1],
|
|
||||||
curvesJSON: string(curvesJSON),
|
|
||||||
}
|
|
||||||
// NOTE: the old hand-ported per-session derivation (0x00+seed header +
|
|
||||||
// computeStatsigTail salt) is intentionally NOT applied here. Verified
|
|
||||||
// 2026-07-13 against /rest/app-chat/conversations/new: that dynamic token is
|
|
||||||
// rejected ("Request rejected by anti-bot rules.", 403), while the static
|
|
||||||
// (header, salt) defaults are accepted (200) — so we keep the static values
|
|
||||||
// as the fallback. When the goja signer (ensureEngine above) locates and
|
|
||||||
// verifies grok's own chunk it still takes over via seedB64/curvesJSON in
|
|
||||||
// statsigID; computeStatsigTail is retained only for reference/tests.
|
|
||||||
return ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeStatsigSeed(s string) ([]byte, error) {
|
|
||||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 48 {
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
b, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("statsig: seed decode: %w", err)
|
|
||||||
}
|
|
||||||
if len(b) != 48 {
|
|
||||||
return nil, fmt.Errorf("statsig: seed len %d", len(b))
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStatsigCurves extracts the [[{color,deg,bezier}...]...] array from the
|
|
||||||
// RSC-escaped homepage HTML (the one immediately followed by color/bezier keys).
|
|
||||||
func parseStatsigCurves(html string) ([][]statsigCurve, error) {
|
|
||||||
marker := -1
|
|
||||||
for from := 0; ; {
|
|
||||||
i := strings.Index(html[from:], "curves")
|
|
||||||
if i < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
i += from
|
|
||||||
end := i + 160
|
|
||||||
if end > len(html) {
|
|
||||||
end = len(html)
|
|
||||||
}
|
|
||||||
w := html[i:end]
|
|
||||||
if strings.Contains(w, "color") && strings.Contains(w, "bezier") {
|
|
||||||
marker = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
from = i + 6
|
|
||||||
}
|
|
||||||
if marker < 0 {
|
|
||||||
return nil, errors.New("statsig: curves not found")
|
|
||||||
}
|
|
||||||
rel := strings.IndexByte(html[marker:], '[')
|
|
||||||
if rel < 0 {
|
|
||||||
return nil, errors.New("statsig: curves array start not found")
|
|
||||||
}
|
|
||||||
start := marker + rel
|
|
||||||
depth, stop := 0, -1
|
|
||||||
for k := start; k < len(html); k++ {
|
|
||||||
switch html[k] {
|
|
||||||
case '[':
|
|
||||||
depth++
|
|
||||||
case ']':
|
|
||||||
depth--
|
|
||||||
if depth == 0 {
|
|
||||||
stop = k + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stop > 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stop < 0 {
|
|
||||||
return nil, errors.New("statsig: curves array end not found")
|
|
||||||
}
|
|
||||||
sub := strings.ReplaceAll(html[start:stop], `\`, "")
|
|
||||||
var out [][]statsigCurve
|
|
||||||
if err := json.Unmarshal([]byte(sub), &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("statsig: curves json: %w", err)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeStatsigTail reproduces the browser's 3-byte color + 6-number transform
|
|
||||||
// matrix "F" tail: it selects a curve by the seed, samples the curve's keyframe
|
|
||||||
// animation (color lerp + rotate) at a seed-derived paused currentTime, and
|
|
||||||
// serializes getComputedStyle(color)+getComputedStyle(transform) exactly as the
|
|
||||||
// signer does (each number -> Number(v.toFixed(2)).toString(16), '.'/'-' stripped).
|
|
||||||
func computeStatsigTail(seed []byte, curves [][]statsigCurve) (string, error) {
|
|
||||||
if len(seed) < 46 {
|
|
||||||
return "", errors.New("statsig: short seed")
|
|
||||||
}
|
|
||||||
if len(curves) == 0 {
|
|
||||||
return "", errors.New("statsig: no curves")
|
|
||||||
}
|
|
||||||
group := int(seed[5]) % len(curves)
|
|
||||||
if len(curves[group]) == 0 {
|
|
||||||
return "", errors.New("statsig: empty curve group")
|
|
||||||
}
|
|
||||||
idx := int(seed[45]) % len(curves[group])
|
|
||||||
cv := curves[group][idx]
|
|
||||||
if len(cv.Color) < 6 || len(cv.Bezier) < 4 {
|
|
||||||
return "", errors.New("statsig: malformed curve")
|
|
||||||
}
|
|
||||||
|
|
||||||
n := (int(seed[1]) % 16) * (int(seed[6]) % 16) * (int(seed[13]) % 16)
|
|
||||||
currentTime := jsRound(float64(n)/10) * 10
|
|
||||||
progress := float64(currentTime) / statsigAnimDuration
|
|
||||||
|
|
||||||
x1 := toFixed2(float64(cv.Bezier[0]) / 255)
|
|
||||||
y1 := toFixed2(float64(cv.Bezier[1])*2/255 - 1)
|
|
||||||
x2 := toFixed2(float64(cv.Bezier[2]) / 255)
|
|
||||||
y2 := toFixed2(float64(cv.Bezier[3])*2/255 - 1)
|
|
||||||
eased := cubicBezierEase(x1, y1, x2, y2, progress)
|
|
||||||
|
|
||||||
nums := make([]float64, 0, 9)
|
|
||||||
for k := 0; k < 3; k++ {
|
|
||||||
v := jsRound(float64(cv.Color[k]) + (float64(cv.Color[k+3])-float64(cv.Color[k]))*eased)
|
|
||||||
if v < 0 {
|
|
||||||
v = 0
|
|
||||||
}
|
|
||||||
if v > 255 {
|
|
||||||
v = 255
|
|
||||||
}
|
|
||||||
nums = append(nums, float64(v))
|
|
||||||
}
|
|
||||||
theta := int(math.Floor(float64(cv.Deg)*300/255 + 60))
|
|
||||||
rad := float64(theta) * eased * math.Pi / 180
|
|
||||||
cos, sin := math.Cos(rad), math.Sin(rad)
|
|
||||||
nums = append(nums, cos, sin, -sin, cos, 0, 0)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
for _, v := range nums {
|
|
||||||
b.WriteString(jsHex(v))
|
|
||||||
}
|
|
||||||
out := strings.NewReplacer(".", "", "-", "").Replace(b.String())
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsRound matches JavaScript Math.round (round half up toward +Inf).
|
|
||||||
func jsRound(x float64) int {
|
|
||||||
return int(math.Floor(x + 0.5))
|
|
||||||
}
|
|
||||||
|
|
||||||
// toFixed2 matches JavaScript Number(v.toFixed(2)).
|
|
||||||
func toFixed2(v float64) float64 {
|
|
||||||
f, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 2, 64), 64)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsHex matches JavaScript Number(v.toFixed(2)).toString(16).
|
|
||||||
func jsHex(v float64) string {
|
|
||||||
v = toFixed2(v)
|
|
||||||
neg := ""
|
|
||||||
if v < 0 {
|
|
||||||
neg = "-"
|
|
||||||
v = -v
|
|
||||||
}
|
|
||||||
ip := int64(math.Floor(v))
|
|
||||||
frac := v - float64(ip)
|
|
||||||
s := neg + strconv.FormatInt(ip, 16)
|
|
||||||
if frac == 0 {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
const digits = "0123456789abcdef"
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(s)
|
|
||||||
b.WriteByte('.')
|
|
||||||
for i := 0; i < 20 && frac != 0; i++ {
|
|
||||||
frac *= 16
|
|
||||||
d := int(frac)
|
|
||||||
b.WriteByte(digits[d])
|
|
||||||
frac -= float64(d)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// cubicBezierEase evaluates a CSS cubic-bezier(x1,y1,x2,y2) easing at input
|
|
||||||
// fraction p: solve X(t)=p for t (bisection), then return Y(t).
|
|
||||||
func cubicBezierEase(x1, y1, x2, y2, p float64) float64 {
|
|
||||||
bez := func(t, a, b float64) float64 {
|
|
||||||
mt := 1 - t
|
|
||||||
return 3*a*mt*mt*t + 3*b*mt*t*t + t*t*t
|
|
||||||
}
|
|
||||||
lo, hi := 0.0, 1.0
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
mid := (lo + hi) / 2
|
|
||||||
if bez(mid, x1, x2) < p {
|
|
||||||
lo = mid
|
|
||||||
} else {
|
|
||||||
hi = mid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bez((lo+hi)/2, y1, y2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsigID reproduces grok.com's x-statsig-id anti-bot token for a request. The
|
|
||||||
// token binds to the request METHOD and URL path and to a coarse timestamp, so
|
|
||||||
// it must be regenerated per request. See the package doc for the layout. It uses
|
|
||||||
// the session's self-healed (header, salt) when available, else static defaults.
|
|
||||||
func statsigID(path, method, token string) string {
|
|
||||||
header, suffix, trailer := statsigHeader, statsigSuffix, statsigTrailer
|
|
||||||
statsigMu.Lock()
|
|
||||||
ch, ok := statsigCache[token]
|
|
||||||
statsigMu.Unlock()
|
|
||||||
if ok {
|
|
||||||
header, suffix, trailer = ch.header, ch.suffix, ch.trailer
|
|
||||||
// Primary: run grok's own signer in goja (durable across reships). Falls
|
|
||||||
// through to the static computation below on any failure.
|
|
||||||
if ch.seedB64 != "" && ch.curvesJSON != "" {
|
|
||||||
if id, err := signWithEngine(ch.seedB64, ch.curvesJSON, path, method); err == nil {
|
|
||||||
return id
|
|
||||||
} else if !errors.Is(err, errEngineNotReady) {
|
|
||||||
log.Printf("grok statsig: js signer failed, using static fallback: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
counter := uint32(time.Now().Unix() - statsigEpoch)
|
|
||||||
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, suffix)
|
|
||||||
hash := sha256.Sum256([]byte(sig))
|
|
||||||
|
|
||||||
raw := make([]byte, 0, 70)
|
|
||||||
raw = append(raw, header...)
|
|
||||||
raw = binary.LittleEndian.AppendUint32(raw, counter)
|
|
||||||
raw = append(raw, hash[:16]...)
|
|
||||||
raw = append(raw, trailer)
|
|
||||||
|
|
||||||
key := byte(rand.IntN(256))
|
|
||||||
for i := range raw {
|
|
||||||
raw[i] ^= key
|
|
||||||
}
|
|
||||||
return base64.RawStdEncoding.EncodeToString(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyHeaders sets the browser-like header set + sso cookie + spoofed statsig id.
|
|
||||||
// extra overrides/adds per-request headers (e.g. content-type).
|
|
||||||
func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]string) {
|
|
||||||
h := http.Header{
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"content-type": {"application/json"},
|
|
||||||
"origin": {origin},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"x-statsig-id": {statsigID(req.URL.Path, req.Method, token)},
|
|
||||||
"x-xai-request-id": {uuid.NewString()},
|
|
||||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
|
||||||
"sec-ch-ua-platform": {`"Windows"`},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"same-origin"},
|
|
||||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
h[k] = []string{v}
|
|
||||||
}
|
|
||||||
h[http.HeaderOrderKey] = []string{
|
|
||||||
"accept", "accept-language", "content-type", "origin", "referer",
|
|
||||||
"user-agent", "x-statsig-id", "x-xai-request-id", "x-grpc-web",
|
|
||||||
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
|
||||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
|
|
||||||
}
|
|
||||||
req.Header = h
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
|
||||||
|
|
||||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
|
||||||
// reference-frame upload and result (video) download; only the generate submit
|
|
||||||
// (/rest/app-chat/conversations/new) uses the proxy.
|
|
||||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
|
||||||
|
|
||||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
|
||||||
options := []tlsclient.HttpClientOption{
|
|
||||||
// Video generation streams inline until progress=100; a 15s clip can take
|
|
||||||
// several minutes, so allow up to 10m (caller's genCtx caps at 12m).
|
|
||||||
tlsclient.WithTimeoutSeconds(600),
|
|
||||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
|
||||||
tlsclient.WithRandomTLSExtensionOrder(),
|
|
||||||
}
|
|
||||||
if useProxy && c.proxy != "" {
|
|
||||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
|
||||||
}
|
|
||||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- gRPC-web / protobuf decoding for GetGrokCreditsConfig ---
|
|
||||||
|
|
||||||
// parseCreditsConfig extracts (remaining credits, reset RFC3339-ish unix string)
|
|
||||||
// from the gRPC-web framed protobuf. Layout (reverse-engineered):
|
|
||||||
//
|
|
||||||
// frame: 1-byte flag + 4-byte big-endian length + payload
|
|
||||||
// payload: field 1 (message) {
|
|
||||||
// field 1: float32 -> remaining credits
|
|
||||||
// field 5: message { field 1: varint -> reset unix seconds }
|
|
||||||
// }
|
|
||||||
func parseCreditsConfig(buf []byte) (remaining int, resetUnix string, ok bool) {
|
|
||||||
for len(buf) >= 5 {
|
|
||||||
flag := buf[0]
|
|
||||||
ln := int(buf[1])<<24 | int(buf[2])<<16 | int(buf[3])<<8 | int(buf[4])
|
|
||||||
buf = buf[5:]
|
|
||||||
if ln > len(buf) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
payload := buf[:ln]
|
|
||||||
buf = buf[ln:]
|
|
||||||
if flag&0x80 != 0 { // trailer frame (grpc-status), skip
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// payload: expect field 1 (wire type 2) wrapping the config message.
|
|
||||||
fn, wt, val, rest, good := readField(payload)
|
|
||||||
if !good || fn != 1 || wt != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rem, reset, found := scanConfigMessage(val)
|
|
||||||
_ = rest
|
|
||||||
if found {
|
|
||||||
return rem, reset, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanConfigMessage(msg []byte) (used int, resetUnix string, ok bool) {
|
|
||||||
var usedF float32
|
|
||||||
seen := false
|
|
||||||
for len(msg) > 0 {
|
|
||||||
fn, wt, val, rest, good := readField(msg)
|
|
||||||
if !good {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
msg = rest
|
|
||||||
seen = true
|
|
||||||
switch {
|
|
||||||
case fn == 1 && wt == 5: // float32 credits USED this period
|
|
||||||
usedF = float32FromLE(val)
|
|
||||||
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
|
|
||||||
if sec, sok := firstVarint(val); sok {
|
|
||||||
resetUnix = strconv.FormatInt(sec, 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A valid config message may OMIT field #1 when used == 0 (proto3 drops zero
|
|
||||||
// scalars) — a full-quota account. So as long as the message had any field,
|
|
||||||
// treat it as parsed with used defaulting to 0 (= 100 remaining).
|
|
||||||
return int(usedF), resetUnix, seen
|
|
||||||
}
|
|
||||||
|
|
||||||
// readField reads one protobuf field: returns (fieldNum, wireType, value, rest, ok).
|
|
||||||
// For wt 2 value is the length-delimited bytes; wt 5 the 4 LE bytes; wt 0 the
|
|
||||||
// raw varint bytes; wt 1 the 8 bytes.
|
|
||||||
func readField(b []byte) (fn int, wt int, val []byte, rest []byte, ok bool) {
|
|
||||||
tag, n := readVarint(b)
|
|
||||||
if n == 0 {
|
|
||||||
return 0, 0, nil, b, false
|
|
||||||
}
|
|
||||||
b = b[n:]
|
|
||||||
fn = int(tag >> 3)
|
|
||||||
wt = int(tag & 7)
|
|
||||||
switch wt {
|
|
||||||
case 0:
|
|
||||||
_, m := readVarint(b)
|
|
||||||
if m == 0 {
|
|
||||||
return 0, 0, nil, b, false
|
|
||||||
}
|
|
||||||
return fn, wt, b[:m], b[m:], true
|
|
||||||
case 1:
|
|
||||||
if len(b) < 8 {
|
|
||||||
return 0, 0, nil, b, false
|
|
||||||
}
|
|
||||||
return fn, wt, b[:8], b[8:], true
|
|
||||||
case 2:
|
|
||||||
ln, m := readVarint(b)
|
|
||||||
if m == 0 || int(ln) > len(b)-m {
|
|
||||||
return 0, 0, nil, b, false
|
|
||||||
}
|
|
||||||
return fn, wt, b[m : m+int(ln)], b[m+int(ln):], true
|
|
||||||
case 5:
|
|
||||||
if len(b) < 4 {
|
|
||||||
return 0, 0, nil, b, false
|
|
||||||
}
|
|
||||||
return fn, wt, b[:4], b[4:], true
|
|
||||||
default:
|
|
||||||
return 0, 0, nil, b, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstVarint(b []byte) (int64, bool) {
|
|
||||||
fn, wt, val, _, ok := readField(b)
|
|
||||||
if !ok || fn != 1 || wt != 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v, _ := readVarint(val)
|
|
||||||
return int64(v), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func readVarint(b []byte) (uint64, int) {
|
|
||||||
var v uint64
|
|
||||||
var s uint
|
|
||||||
for i := 0; i < len(b); i++ {
|
|
||||||
v |= uint64(b[i]&0x7f) << s
|
|
||||||
if b[i]&0x80 == 0 {
|
|
||||||
return v, i + 1
|
|
||||||
}
|
|
||||||
s += 7
|
|
||||||
}
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func float32FromLE(b []byte) float32 {
|
|
||||||
if len(b) < 4 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
bits := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
|
|
||||||
return math.Float32frombits(bits)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- small helpers (mirror the other provider clients) ---
|
|
||||||
|
|
||||||
func decodeJWTPayload(token string) map[string]any {
|
|
||||||
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
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 emptyStringNil(v string) any {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func unknownBalance(reason string) map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"remaining": nil, "used": nil, "total": nil,
|
|
||||||
"unknown": true, "error": reason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clip(b []byte, n int) string {
|
|
||||||
s := strings.TrimSpace(string(b))
|
|
||||||
if len(s) > n {
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,609 +0,0 @@
|
|||||||
package grok
|
|
||||||
|
|
||||||
// Grok Console (console.x.ai) is xAI's own product console. It serves the same
|
|
||||||
// grok-imagine media models as grok.com, but over a plain JSON API that is NOT
|
|
||||||
// anti-bot gated: no x-statsig-id, no media-post + streaming-conversation dance.
|
|
||||||
// Auth reuses the very same website "sso" cookie — POST /v1/dpop/token exchanges
|
|
||||||
// it for a short-lived access token bound to a locally generated P-256 key, and
|
|
||||||
// every call then carries "Authorization: DPoP <token>" plus a per-request ES256
|
|
||||||
// DPoP proof (RFC 9449) over (method, url, token hash).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
consoleBase = "https://console.x.ai"
|
|
||||||
consoleOrigin = "https://console.x.ai"
|
|
||||||
|
|
||||||
// Upstream model ids of the Console media catalog. The -quality image model
|
|
||||||
// is what the reference-image (edit) path uses.
|
|
||||||
ConsoleVideoModel = "grok-imagine-video"
|
|
||||||
ConsoleImageModel = "grok-imagine-image"
|
|
||||||
ConsoleImageQualityModel = "grok-imagine-image-quality"
|
|
||||||
|
|
||||||
// consoleTokenSkew refreshes a DPoP token slightly before it expires.
|
|
||||||
consoleTokenSkew = 20 * time.Second
|
|
||||||
// consoleMaxEditImages is the upstream cap for image-to-image references
|
|
||||||
// (video takes a single 首图).
|
|
||||||
consoleMaxEditImages = 3
|
|
||||||
|
|
||||||
consoleVideoPollEvery = 2 * time.Second
|
|
||||||
consoleVideoDeadline = 30 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// consoleSession is one minted DPoP token plus the key it is bound to. skew is
|
|
||||||
// (server time − local time) learned from the mint response Date header; the
|
|
||||||
// proof iat is shifted by it so a drifting host clock doesn't fail every proof.
|
|
||||||
type consoleSession struct {
|
|
||||||
accessToken string
|
|
||||||
key *ecdsa.PrivateKey
|
|
||||||
jwk consoleJWK
|
|
||||||
expiresAt time.Time
|
|
||||||
skew time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type consoleJWK struct {
|
|
||||||
Crv string `json:"crv"`
|
|
||||||
Kty string `json:"kty"`
|
|
||||||
X string `json:"x"`
|
|
||||||
Y string `json:"y"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
consoleMu sync.Mutex
|
|
||||||
consoleSessions = map[string]consoleSession{} // keyed by sso token hash
|
|
||||||
)
|
|
||||||
|
|
||||||
// GenerateConsoleVideo runs Console's video pipeline: POST /v1/videos/generations
|
|
||||||
// returns a request_id, GET /v1/videos/{id} is polled until the clip is rendered
|
|
||||||
// and reports its vidgen.x.ai URL. frames is optional (image-to-video, one 首图
|
|
||||||
// max) and is inlined as a data URL — Console takes the image in the request, so
|
|
||||||
// there is no separate upload step. When downloadResult is false, returns nil
|
|
||||||
// bytes and the artifact URL in meta["video_url"]; otherwise downloads the mp4.
|
|
||||||
func (c *Client) GenerateConsoleVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
prompt = strings.TrimSpace(prompt)
|
|
||||||
if prompt == "" && len(frames) == 0 {
|
|
||||||
return nil, nil, fmt.Errorf("grok console: prompt required")
|
|
||||||
}
|
|
||||||
if seconds < 1 || seconds > 15 {
|
|
||||||
seconds = 10
|
|
||||||
}
|
|
||||||
submitClient, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
directClient, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]any{"model": ConsoleVideoModel, "duration": seconds}
|
|
||||||
if prompt != "" {
|
|
||||||
payload["prompt"] = prompt
|
|
||||||
}
|
|
||||||
if ratio := strings.TrimSpace(aspectRatio); ratio != "" {
|
|
||||||
payload["aspect_ratio"] = ratio
|
|
||||||
}
|
|
||||||
if res := consoleVideoResolution(resolution); res != "" {
|
|
||||||
payload["resolution"] = res
|
|
||||||
}
|
|
||||||
for _, f := range frames {
|
|
||||||
if len(f) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
payload["image"] = map[string]any{"url": dataURL(f)}
|
|
||||||
break // 上游只吃 1 张首图
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, "/v1/videos/generations", payload)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
requestID := strings.TrimSpace(stringValue(created["request_id"]))
|
|
||||||
if requestID == "" {
|
|
||||||
return nil, nil, fmt.Errorf("%w: video create missing request_id", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(consoleVideoDeadline)
|
|
||||||
videoURL := ""
|
|
||||||
for {
|
|
||||||
status, pollErr := c.consoleJSON(ctx, submitClient, token, http.MethodGet, "/v1/videos/"+url.PathEscape(requestID), nil)
|
|
||||||
if pollErr != nil {
|
|
||||||
return nil, nil, pollErr
|
|
||||||
}
|
|
||||||
url, done, sErr := parseConsoleVideoStatus(status)
|
|
||||||
if sErr != nil {
|
|
||||||
return nil, nil, sErr
|
|
||||||
}
|
|
||||||
if done {
|
|
||||||
videoURL = url
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return nil, nil, fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, nil, ctx.Err()
|
|
||||||
case <-time.After(consoleVideoPollEvery):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := map[string]any{
|
|
||||||
"provider": "grok",
|
|
||||||
"request_id": requestID,
|
|
||||||
"video_url": videoURL,
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, meta, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, directClient, token, videoURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateConsoleImage runs Console's image pipeline: /v1/images/generations on
|
|
||||||
// grok-imagine-image for text-to-image, and /v1/images/edits on the
|
|
||||||
// grok-imagine-image-quality model (up to 3 inlined reference images) as soon as
|
|
||||||
// references are supplied — 带参考图自动走 quality 那个上游模型. Returns the
|
|
||||||
// artifact URL in meta["image_url"]; the bytes are downloaded unless urlOnly.
|
|
||||||
func (c *Client) GenerateConsoleImage(ctx context.Context, token, prompt, aspectRatio, resolution string, refs [][]byte, urlOnly bool) ([]byte, map[string]any, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(prompt) == "" {
|
|
||||||
return nil, nil, fmt.Errorf("grok console: prompt required")
|
|
||||||
}
|
|
||||||
submitClient, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
directClient, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]any{
|
|
||||||
"model": ConsoleImageModel, "prompt": strings.TrimSpace(prompt),
|
|
||||||
"n": 1, "response_format": "url",
|
|
||||||
}
|
|
||||||
|
|
||||||
if ratio := consoleImageAspectRatio(aspectRatio); ratio != "" {
|
|
||||||
payload["aspect_ratio"] = ratio
|
|
||||||
}
|
|
||||||
if res := consoleImageResolution(resolution); res != "" {
|
|
||||||
payload["resolution"] = res
|
|
||||||
}
|
|
||||||
path := "/v1/images/generations"
|
|
||||||
var images []map[string]any
|
|
||||||
for _, r := range refs {
|
|
||||||
if len(r) == 0 || len(images) >= consoleMaxEditImages {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
images = append(images, map[string]any{"type": "image_url", "url": dataURL(r)})
|
|
||||||
}
|
|
||||||
if len(images) > 0 {
|
|
||||||
path = "/v1/images/edits"
|
|
||||||
payload["model"] = ConsoleImageQualityModel
|
|
||||||
if len(images) == 1 {
|
|
||||||
payload["image"] = images[0]
|
|
||||||
} else {
|
|
||||||
payload["images"] = images
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, path, payload)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
items, _ := res["data"].([]any)
|
|
||||||
imageURL := ""
|
|
||||||
for _, item := range items {
|
|
||||||
entry, _ := item.(map[string]any)
|
|
||||||
if entry == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(stringValue(entry["url"])); v != "" {
|
|
||||||
imageURL = v
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if imageURL == "" {
|
|
||||||
return nil, nil, fmt.Errorf("%w: image response missing url", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
meta := map[string]any{"provider": "grok", "image_url": imageURL}
|
|
||||||
if urlOnly {
|
|
||||||
return nil, meta, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, directClient, token, imageURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleJSON does an authed Console JSON call and parses the response object. A
|
|
||||||
// 401 means the DPoP token went stale (or the sso died): the cached session is
|
|
||||||
// dropped and the call retried once with a freshly minted token.
|
|
||||||
func (c *Client) consoleJSON(ctx context.Context, client tlsclient.HttpClient, token, method, path string, body any) (map[string]any, error) {
|
|
||||||
var payload []byte
|
|
||||||
if body != nil {
|
|
||||||
encoded, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload = encoded
|
|
||||||
}
|
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
|
||||||
session, err := c.consoleSession(ctx, client, token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var reader io.Reader
|
|
||||||
if len(payload) > 0 {
|
|
||||||
reader = strings.NewReader(string(payload))
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(method, consoleBase+path, reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
if err := c.applyConsoleHeaders(req, token, session); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
raw, readErr := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode == 401 && attempt == 0 {
|
|
||||||
consoleForget(token, session.accessToken)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if readErr != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, readErr)
|
|
||||||
}
|
|
||||||
if e := consoleMapStatus(path, resp.StatusCode, raw); e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
out := map[string]any{}
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("%w: console retry state invalid", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleSession returns a live DPoP session for the sso token, minting one when
|
|
||||||
// the cache is empty or the token is about to expire.
|
|
||||||
func (c *Client) consoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
|
|
||||||
key := consoleCacheKey(token)
|
|
||||||
consoleMu.Lock()
|
|
||||||
cached, ok := consoleSessions[key]
|
|
||||||
consoleMu.Unlock()
|
|
||||||
if ok && cached.expiresAt.After(time.Now().Add(consoleTokenSkew)) {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
session, err := c.mintConsoleSession(ctx, client, token)
|
|
||||||
if err != nil {
|
|
||||||
return consoleSession{}, err
|
|
||||||
}
|
|
||||||
consoleMu.Lock()
|
|
||||||
consoleSessions[key] = session
|
|
||||||
consoleMu.Unlock()
|
|
||||||
return session, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleForget drops a cached session (only if it is still the one that failed,
|
|
||||||
// so a concurrent refresh isn't thrown away).
|
|
||||||
func consoleForget(token, accessToken string) {
|
|
||||||
key := consoleCacheKey(token)
|
|
||||||
consoleMu.Lock()
|
|
||||||
if current, ok := consoleSessions[key]; ok && current.accessToken == accessToken {
|
|
||||||
delete(consoleSessions, key)
|
|
||||||
}
|
|
||||||
consoleMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func consoleCacheKey(token string) string {
|
|
||||||
sum := sha256.Sum256([]byte(token))
|
|
||||||
return fmt.Sprintf("%x", sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// mintConsoleSession posts the freshly generated public JWK to /v1/dpop/token
|
|
||||||
// with the sso cookie and keeps the returned DPoP-bound access token.
|
|
||||||
func (c *Client) mintConsoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
|
|
||||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
||||||
if err != nil {
|
|
||||||
return consoleSession{}, err
|
|
||||||
}
|
|
||||||
jwk := consoleJWK{
|
|
||||||
Crv: "P-256", Kty: "EC",
|
|
||||||
X: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.FillBytes(make([]byte, 32))),
|
|
||||||
Y: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.FillBytes(make([]byte, 32))),
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(map[string]any{"jwk": jwk})
|
|
||||||
if err != nil {
|
|
||||||
return consoleSession{}, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, consoleBase+"/v1/dpop/token", strings.NewReader(string(body)))
|
|
||||||
if err != nil {
|
|
||||||
return consoleSession{}, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
c.applyConsoleBrowserHeaders(req, token, nil)
|
|
||||||
before := time.Now().UTC()
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
after := time.Now().UTC()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
if e := consoleMapStatus("/v1/dpop/token", resp.StatusCode, raw); e != nil {
|
|
||||||
return consoleSession{}, e
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return consoleSession{}, fmt.Errorf("%w: dpop token non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(out.AccessToken) == "" || !strings.EqualFold(strings.TrimSpace(out.TokenType), "DPoP") {
|
|
||||||
return consoleSession{}, fmt.Errorf("%w: dpop token response invalid", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
if out.ExpiresIn <= 0 || out.ExpiresIn > 3600 {
|
|
||||||
out.ExpiresIn = 300
|
|
||||||
}
|
|
||||||
return consoleSession{
|
|
||||||
accessToken: out.AccessToken,
|
|
||||||
key: privateKey,
|
|
||||||
jwk: jwk,
|
|
||||||
expiresAt: time.Now().Add(time.Duration(out.ExpiresIn) * time.Second),
|
|
||||||
skew: consoleClockSkew(resp.Header.Get("Date"), before, after),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyConsoleHeaders sets the browser-like header set plus the DPoP
|
|
||||||
// Authorization header and its per-request proof.
|
|
||||||
func (c *Client) applyConsoleHeaders(req *http.Request, token string, session consoleSession) error {
|
|
||||||
proof, err := consoleProof(session, req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.applyConsoleBrowserHeaders(req, token, map[string]string{
|
|
||||||
"authorization": "DPoP " + session.accessToken,
|
|
||||||
"dpop": proof,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) applyConsoleBrowserHeaders(req *http.Request, token string, extra map[string]string) {
|
|
||||||
h := http.Header{
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"content-type": {"application/json"},
|
|
||||||
"origin": {consoleOrigin},
|
|
||||||
"referer": {consoleOrigin + "/"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
|
||||||
"sec-ch-ua-platform": {`"Windows"`},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"same-origin"},
|
|
||||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
h[k] = []string{v}
|
|
||||||
}
|
|
||||||
h[http.HeaderOrderKey] = []string{
|
|
||||||
"accept", "accept-language", "authorization", "content-type", "dpop",
|
|
||||||
"origin", "referer", "user-agent", "sec-ch-ua", "sec-ch-ua-mobile",
|
|
||||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
|
|
||||||
}
|
|
||||||
req.Header = h
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleProof builds the ES256 DPoP proof JWT for one request: it binds the
|
|
||||||
// method + URL (without query) and the access token hash (ath), signed with the
|
|
||||||
// key the token was issued against.
|
|
||||||
func consoleProof(session consoleSession, req *http.Request) (string, error) {
|
|
||||||
if session.key == nil || strings.TrimSpace(session.accessToken) == "" || req == nil || req.URL == nil {
|
|
||||||
return "", errors.New("grok console: dpop session invalid")
|
|
||||||
}
|
|
||||||
path := req.URL.EscapedPath()
|
|
||||||
if path == "" {
|
|
||||||
path = "/"
|
|
||||||
}
|
|
||||||
ath := sha256.Sum256([]byte(session.accessToken))
|
|
||||||
header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": session.jwk}
|
|
||||||
claims := map[string]any{
|
|
||||||
"jti": uuid.NewString(),
|
|
||||||
"htm": strings.ToUpper(req.Method),
|
|
||||||
"htu": req.URL.Scheme + "://" + req.URL.Host + path,
|
|
||||||
"iat": time.Now().Add(session.skew).UTC().Unix(),
|
|
||||||
"ath": base64.RawURLEncoding.EncodeToString(ath[:]),
|
|
||||||
}
|
|
||||||
headerJSON, err := json.Marshal(header)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
claimsJSON, err := json.Marshal(claims)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON)
|
|
||||||
digest := sha256.Sum256([]byte(signingInput))
|
|
||||||
r, s, err := ecdsa.Sign(rand.Reader, session.key, digest[:])
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
signature := make([]byte, 64)
|
|
||||||
r.FillBytes(signature[:32])
|
|
||||||
s.FillBytes(signature[32:])
|
|
||||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleClockSkew mirrors the console.x.ai frontend: proof iat is local time
|
|
||||||
// corrected by (server Date − local time) so host clock drift doesn't break
|
|
||||||
// every proof. before/after bound the RTT around the mint response.
|
|
||||||
func consoleClockSkew(dateHeader string, before, after time.Time) time.Duration {
|
|
||||||
dateHeader = strings.TrimSpace(dateHeader)
|
|
||||||
if dateHeader == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
serverTime, err := http.ParseTime(dateHeader)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if after.Before(before) {
|
|
||||||
after = before
|
|
||||||
}
|
|
||||||
mid := before.Add(after.Sub(before) / 2)
|
|
||||||
return serverTime.UTC().Sub(mid.UTC()).Round(time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseConsoleVideoStatus reads one poll response: (url, done, error).
|
|
||||||
func parseConsoleVideoStatus(res map[string]any) (string, bool, error) {
|
|
||||||
status := strings.ToLower(strings.TrimSpace(stringValue(res["status"])))
|
|
||||||
switch status {
|
|
||||||
case "done", "completed", "succeeded", "success", "ready":
|
|
||||||
video, _ := res["video"].(map[string]any)
|
|
||||||
url := ""
|
|
||||||
if video != nil {
|
|
||||||
url = strings.TrimSpace(stringValue(video["url"]))
|
|
||||||
}
|
|
||||||
if url == "" {
|
|
||||||
return "", false, fmt.Errorf("%w: video done without content url", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return url, true, nil
|
|
||||||
case "pending", "processing", "in_progress", "queued", "":
|
|
||||||
return "", false, nil
|
|
||||||
default:
|
|
||||||
message := consoleErrorMessage(res["error"])
|
|
||||||
if isCreditError(message) {
|
|
||||||
return "", false, fmt.Errorf("%w: %s", ErrQuotaExhausted, message)
|
|
||||||
}
|
|
||||||
if message == "" {
|
|
||||||
message = status
|
|
||||||
}
|
|
||||||
return "", false, fmt.Errorf("grok console: video generation failed: %s", message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleMapStatus maps a Console HTTP status to the shared provider sentinels.
|
|
||||||
// Console is not anti-bot gated, so a 403 is a real rejection (dead sso / no
|
|
||||||
// access) unless the body is a Cloudflare interstitial.
|
|
||||||
func consoleMapStatus(path string, status int, raw []byte) error {
|
|
||||||
body := string(raw)
|
|
||||||
switch {
|
|
||||||
case status >= 200 && status < 300:
|
|
||||||
return nil
|
|
||||||
case status == 401:
|
|
||||||
return fmt.Errorf("%w: %s 401 %s", ErrAuth, path, clip(raw, 160))
|
|
||||||
case status == 403:
|
|
||||||
if isBotChallenge(body) {
|
|
||||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%w: %s 403 %s", ErrAuth, path, clip(raw, 160))
|
|
||||||
case status == 429:
|
|
||||||
if isCreditError(body) {
|
|
||||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
|
||||||
case status >= 500:
|
|
||||||
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
|
|
||||||
default:
|
|
||||||
if isCreditError(body) {
|
|
||||||
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("grok console: %s %d %s", path, status, clip(raw, 160))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func consoleErrorMessage(value any) string {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
return strings.TrimSpace(v)
|
|
||||||
case map[string]any:
|
|
||||||
for _, key := range []string{"message", "code", "type"} {
|
|
||||||
if text := strings.TrimSpace(stringValue(v[key])); text != "" {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleImageAspectRatio keeps only the ratios Console's image models accept;
|
|
||||||
// anything else is dropped so the upstream picks its default instead of 400ing.
|
|
||||||
func consoleImageAspectRatio(value string) string {
|
|
||||||
switch strings.TrimSpace(value) {
|
|
||||||
case "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2":
|
|
||||||
return strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleImageResolution maps our tier to the Console enum (only 1k / 2k exist).
|
|
||||||
func consoleImageResolution(value string) string {
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
|
||||||
case "1K":
|
|
||||||
return "1k"
|
|
||||||
case "2K", "4K":
|
|
||||||
return "2k"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// consoleVideoResolution clamps to the two tiers Console accepts.
|
|
||||||
func consoleVideoResolution(value string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "480p":
|
|
||||||
return "480p"
|
|
||||||
default:
|
|
||||||
return "720p"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dataURL inlines reference image bytes; Console takes images in the request
|
|
||||||
// body, so no upload endpoint is involved.
|
|
||||||
func dataURL(img []byte) string {
|
|
||||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
package grok
|
|
||||||
|
|
||||||
// This file makes x-statsig-id durable across grok web reships by executing grok's
|
|
||||||
// OWN obfuscated signer (a Turbopack chunk) inside an embedded JS engine (goja),
|
|
||||||
// under a synthesized DOM + Web-Animations getComputedStyle shim (statsig_shim.js).
|
|
||||||
// grok's code does all the per-build byte-indexing / curve-selection; we only supply
|
|
||||||
// the stable browser primitives. This replaces the brittle hand-ported byte-offset
|
|
||||||
// algorithm in computeStatsigTail (kept as a last-resort fallback). See the package
|
|
||||||
// doc and the grok-statsig-signer memory for the reverse-engineering details.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/sha512"
|
|
||||||
_ "embed"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
"github.com/dop251/goja"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed statsig_shim.js
|
|
||||||
var statsigShimJS string
|
|
||||||
|
|
||||||
const sigPoolSize = 4
|
|
||||||
|
|
||||||
// errEngineNotReady means the durable signer engine has not been built yet (no
|
|
||||||
// homepage fetched, or chunk location failed). Callers fall back to the static path.
|
|
||||||
var errEngineNotReady = errors.New("statsig engine not ready")
|
|
||||||
|
|
||||||
// locateConcurrency bounds parallel chunk fetches during signer discovery.
|
|
||||||
const locateConcurrency = 24
|
|
||||||
|
|
||||||
var (
|
|
||||||
// chunkPathRe matches chunk URLs in the homepage; allChunkRefRe additionally
|
|
||||||
// matches the loader-manifest's lazy refs (which drop the /_next/ prefix).
|
|
||||||
// The Turbopack content-hash alphabet includes '~' (e.g. 0~4v1h2zpw7n0.js) -
|
|
||||||
// the signer chunk is frequently one of these, so the class MUST list it or
|
|
||||||
// the signer is never fetched/located (403 anti-bot, static fallback goes stale).
|
|
||||||
chunkPathRe = regexp.MustCompile(`/_next/static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
|
||||||
allChunkRefRe = regexp.MustCompile(`(?:/_next/)?static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
|
||||||
// goja's parser tries to fetch //# sourceMappingURL=... from disk and errors.
|
|
||||||
sourceMapRe = regexp.MustCompile(`(?m)//[#@]\s*sourceMappingURL=\S*`)
|
|
||||||
|
|
||||||
sigMgrMu sync.Mutex
|
|
||||||
sigBuildKey string // hash of the homepage chunk list; changes on reship
|
|
||||||
sigChunkSrc string // current signer chunk source
|
|
||||||
sigPool chan *sigEngine // pool of ready engines for sigChunkSrc
|
|
||||||
)
|
|
||||||
|
|
||||||
// sigEngine wraps one goja runtime with grok's signer chunk loaded. A goja runtime
|
|
||||||
// is not safe for concurrent use; the pool hands each engine to one goroutine at a
|
|
||||||
// time so no per-engine locking is needed.
|
|
||||||
type sigEngine struct {
|
|
||||||
rt *goja.Runtime
|
|
||||||
fire goja.Callable // __grokSignInto
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSigEngine(chunkSrc string) (*sigEngine, error) {
|
|
||||||
rt := goja.New()
|
|
||||||
// SHA-256 bridge for crypto.subtle.digest.
|
|
||||||
if err := rt.Set("__goSha256", func(call goja.FunctionCall) goja.Value {
|
|
||||||
data := jsBytes(rt, call.Argument(0))
|
|
||||||
sum := sha256.Sum256(data)
|
|
||||||
return rt.ToValue(rt.NewArrayBuffer(sum[:]))
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := rt.RunString(statsigShimJS); err != nil {
|
|
||||||
return nil, fmt.Errorf("shim: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := rt.RunString(sourceMapRe.ReplaceAllString(chunkSrc, "")); err != nil {
|
|
||||||
return nil, fmt.Errorf("chunk eval: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := rt.RunString("__grokBootstrap()"); err != nil {
|
|
||||||
return nil, fmt.Errorf("bootstrap: %w", err)
|
|
||||||
}
|
|
||||||
fire, ok := goja.AssertFunction(rt.Get("__grokSignInto"))
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("statsig js: __grokSignInto missing")
|
|
||||||
}
|
|
||||||
return &sigEngine{rt: rt, fire: fire}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsigID runs grok's signer for one request. seedB64 is the raw <meta> content;
|
|
||||||
// curvesJSON is [[{color,deg,bezier}...]...].
|
|
||||||
func (e *sigEngine) statsigID(seedB64, curvesJSON, path, method string) (string, error) {
|
|
||||||
_ = e.rt.Set("__SEED", seedB64)
|
|
||||||
_ = e.rt.Set("__CURVES", curvesJSON)
|
|
||||||
_ = e.rt.Set("__PATH", path)
|
|
||||||
_ = e.rt.Set("__METHOD", method)
|
|
||||||
// RunString drains goja's microtask queue, settling the async signer's promise.
|
|
||||||
if _, err := e.fire(goja.Undefined()); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if errv := e.rt.Get("__grokErr"); errv != nil && !goja.IsNull(errv) && !goja.IsUndefined(errv) {
|
|
||||||
return "", fmt.Errorf("statsig js: %s", errv.String())
|
|
||||||
}
|
|
||||||
res := e.rt.Get("__grokResult")
|
|
||||||
if res == nil || goja.IsNull(res) || goja.IsUndefined(res) {
|
|
||||||
return "", errors.New("statsig js: promise did not settle")
|
|
||||||
}
|
|
||||||
id := res.String()
|
|
||||||
if id == "" {
|
|
||||||
return "", errors.New("statsig js: empty id")
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsBytes extracts the byte contents of a JS Uint8Array / ArrayBuffer value.
|
|
||||||
func jsBytes(rt *goja.Runtime, v goja.Value) []byte {
|
|
||||||
if ab, ok := v.Export().(goja.ArrayBuffer); ok {
|
|
||||||
return ab.Bytes()
|
|
||||||
}
|
|
||||||
obj := v.ToObject(rt)
|
|
||||||
if buf := obj.Get("buffer"); buf != nil {
|
|
||||||
if ab, ok := buf.Export().(goja.ArrayBuffer); ok {
|
|
||||||
return ab.Bytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
n := int(obj.Get("length").ToInteger())
|
|
||||||
out := make([]byte, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out[i] = byte(obj.Get(strconv.Itoa(i)).ToInteger())
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// signWithEngine borrows an engine from the pool (building one on demand), signs,
|
|
||||||
// and returns it. Returns an error if the engine subsystem is not ready.
|
|
||||||
func signWithEngine(seedB64, curvesJSON, path, method string) (string, error) {
|
|
||||||
sigMgrMu.Lock()
|
|
||||||
src, pool := sigChunkSrc, sigPool
|
|
||||||
sigMgrMu.Unlock()
|
|
||||||
if src == "" || pool == nil {
|
|
||||||
return "", errEngineNotReady
|
|
||||||
}
|
|
||||||
var eng *sigEngine
|
|
||||||
select {
|
|
||||||
case eng = <-pool:
|
|
||||||
default:
|
|
||||||
var err error
|
|
||||||
if eng, err = newSigEngine(src); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
id, err := eng.statsigID(seedB64, curvesJSON, path, method)
|
|
||||||
select {
|
|
||||||
case pool <- eng:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureEngine refreshes the global engine pool when the homepage's chunk set
|
|
||||||
// changes (i.e. grok reshipped). It locates the signer chunk build-agnostically and
|
|
||||||
// rebuilds the pool. Cheap no-op when the build is unchanged.
|
|
||||||
func ensureEngine(ctx context.Context, client tlsclient.HttpClient, homeHTML string) {
|
|
||||||
paths := chunkPathRe.FindAllString(homeHTML, -1)
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := hashStrings(paths)
|
|
||||||
|
|
||||||
sigMgrMu.Lock()
|
|
||||||
unchanged := key == sigBuildKey && sigPool != nil
|
|
||||||
sigMgrMu.Unlock()
|
|
||||||
if unchanged {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inputs for build-agnostic behavioral verification of candidate chunks.
|
|
||||||
mm := statsigMetaRe.FindStringSubmatch(homeHTML)
|
|
||||||
if mm == nil {
|
|
||||||
log.Printf("grok statsig: no seed meta in homepage; cannot locate signer")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seed, err := decodeStatsigSeed(mm[1])
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("grok statsig: seed decode failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
curves, err := parseStatsigCurves(homeHTML)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("grok statsig: curves parse failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cj, err := json.Marshal(curves)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := locateSignerChunk(ctx, client, dedupe(paths), mm[1], string(cj), seed)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("grok statsig: locate signer chunk failed (will use static fallback): %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// smoke-test: a build must produce a loadable engine before we commit to it.
|
|
||||||
eng, err := newSigEngine(src)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("grok statsig: signer chunk did not load in goja (static fallback): %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pool := make(chan *sigEngine, sigPoolSize)
|
|
||||||
pool <- eng // reuse the smoke-test engine instead of discarding it
|
|
||||||
sigMgrMu.Lock()
|
|
||||||
sigBuildKey = key
|
|
||||||
sigChunkSrc = src
|
|
||||||
sigPool = pool
|
|
||||||
sigMgrMu.Unlock()
|
|
||||||
log.Printf("grok statsig: self-heal engine ready (build %s..)", key[:8])
|
|
||||||
}
|
|
||||||
|
|
||||||
// locateSignerChunk finds grok's obfuscated anti-bot signer chunk build-agnostically,
|
|
||||||
// with no dependency on rotating literals (header name, class names, module ids).
|
|
||||||
// It screens every reachable chunk with a cheap obfuscator.io fingerprint, then
|
|
||||||
// confirms the true signer by RUNNING it in goja and checking its output embeds the
|
|
||||||
// homepage seed (signerEmbedsSeed). The signer is lazily loaded (not referenced in
|
|
||||||
// the HTML directly), so candidates also include the chunk paths named inside the
|
|
||||||
// homepage's Turbopack loader manifest.
|
|
||||||
func locateSignerChunk(ctx context.Context, client tlsclient.HttpClient, homeChunks []string, seedB64, curvesJSON string, seed []byte) (string, error) {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var lazy []string
|
|
||||||
for _, p := range homeChunks {
|
|
||||||
seen[p] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 1 (sequential): fetch the homepage chunks, verify them directly, and
|
|
||||||
// harvest every chunk path they reference (the loader manifest lists the signer).
|
|
||||||
for _, p := range homeChunks {
|
|
||||||
body, err := fetchChunk(ctx, client, p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
|
|
||||||
return src, nil
|
|
||||||
}
|
|
||||||
for _, ref := range allChunkRefRe.FindAllString(body, -1) {
|
|
||||||
np := normalizeChunkPath(ref)
|
|
||||||
if !seen[np] {
|
|
||||||
seen[np] = true
|
|
||||||
lazy = append(lazy, np)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 2 (concurrent): fetch the lazily-referenced chunks, cheap-fingerprint each,
|
|
||||||
// behaviorally verify the matches, and stop at the first chunk that round-trips seed.
|
|
||||||
ctx2, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
|
||||||
found := make(chan string, 1)
|
|
||||||
sem := make(chan struct{}, locateConcurrency)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for _, p := range lazy {
|
|
||||||
if ctx2.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
sem <- struct{}{}
|
|
||||||
wg.Add(1)
|
|
||||||
go func(p string) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-sem }()
|
|
||||||
if ctx2.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := fetchChunk(ctx2, client, p)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
|
|
||||||
select {
|
|
||||||
case found <- src:
|
|
||||||
cancel()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(p)
|
|
||||||
}
|
|
||||||
go func() { wg.Wait(); close(found) }()
|
|
||||||
if src, ok := <-found; ok {
|
|
||||||
return src, nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("statsig signer chunk not found among %d candidates", len(homeChunks)+len(lazy))
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifySignerChunk returns the goja-ready source if body is grok's signer: it must
|
|
||||||
// carry the obfuscator.io fingerprint AND, when executed, produce an x-statsig-id
|
|
||||||
// whose decoded record embeds the homepage seed.
|
|
||||||
func verifySignerChunk(body, seedB64, curvesJSON string, seed []byte) (string, bool) {
|
|
||||||
if !isObfuscatedSigner(body) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
clean := sourceMapRe.ReplaceAllString(body, "")
|
|
||||||
eng, err := newSigEngine(clean)
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
id, err := eng.statsigID(seedB64, curvesJSON, "/rest/app-chat/conversations/new", "POST")
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if !signerEmbedsSeed(id, seed) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return clean, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeChunkPath turns a bare or /_next/-prefixed chunk ref into a fetch path.
|
|
||||||
func normalizeChunkPath(ref string) string {
|
|
||||||
return "/_next/" + strings.TrimPrefix(ref, "/_next/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isObfuscatedSigner cheaply screens a chunk for the obfuscator.io string-array
|
|
||||||
// decoder that grok applies ONLY to its anti-bot signer (the rest of the app is
|
|
||||||
// plain Turbopack output). The RC4-style byte decoder (`...^...%256`) is the stable
|
|
||||||
// tell; it matches a handful of chunks, which behavioral verification then narrows
|
|
||||||
// to exactly one. This is deliberately build-agnostic (no rotating class/id/header).
|
|
||||||
func isObfuscatedSigner(src string) bool {
|
|
||||||
return strings.Contains(src, "%256") &&
|
|
||||||
strings.Contains(src, "String.fromCharCode") &&
|
|
||||||
strings.Contains(src, "charCodeAt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// signerEmbedsSeed decodes a candidate x-statsig-id, strips the per-call XOR mask
|
|
||||||
// (plaintext[0] is 0x00, so masked[0] is the key), and checks the plaintext record
|
|
||||||
// begins with 0x00 + the exact homepage seed. Only grok's real signer round-trips
|
|
||||||
// OUR seed, so this uniquely identifies the signer chunk regardless of obfuscation.
|
|
||||||
func signerEmbedsSeed(id string, seed []byte) bool {
|
|
||||||
raw, err := base64.RawStdEncoding.DecodeString(id)
|
|
||||||
if err != nil || len(raw) < 1+len(seed) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
key := raw[0] // plaintext[0] is 0x00, so the mask key == masked byte 0
|
|
||||||
for i := 0; i < len(seed); i++ {
|
|
||||||
if raw[1+i]^key != seed[i] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchChunk(ctx context.Context, client tlsclient.HttpClient, path string) (string, error) {
|
|
||||||
if !strings.HasPrefix(path, "http") {
|
|
||||||
path = apiBase + path
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, path, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
http.HeaderOrderKey: {"accept", "user-agent"},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("chunk http %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
|
||||||
return string(b), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashStrings(ss []string) string {
|
|
||||||
uniq := dedupe(ss)
|
|
||||||
h := sha512.New()
|
|
||||||
for _, s := range uniq {
|
|
||||||
_, _ = io.WriteString(h, s)
|
|
||||||
_, _ = io.WriteString(h, "\n")
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%x", h.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupe(ss []string) []string {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
out := ss[:0:0]
|
|
||||||
for _, s := range ss {
|
|
||||||
if !seen[s] {
|
|
||||||
seen[s] = true
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
package grok
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/page"
|
|
||||||
"github.com/chromedp/chromedp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The x-statsig-id recipe (49-byte header + salt suffix) encodes grok's per-load
|
|
||||||
// browser fingerprint "F". grok recomputes F server-side from the homepage
|
|
||||||
// seed+curves and the byte-indexing that derives it ROTATES on every web reship,
|
|
||||||
// so any hand-ported or baked-in recipe goes stale (403 "Request rejected by
|
|
||||||
// anti-bot rules.") within days. The durable fix is to let grok's OWN signer
|
|
||||||
// produce the recipe: we drive a headless Chrome to grok.com, read the exact
|
|
||||||
// string grok feeds to crypto.subtle.digest (reveals the current suffix) and one
|
|
||||||
// of the x-statsig-id headers its client attaches (reveals the current 49-byte
|
|
||||||
// header + trailer), then feed those into statsigID's existing per-request
|
|
||||||
// computation. Because we read grok's live output we never reverse-engineer the
|
|
||||||
// (rotating) obfuscation; a reship just needs a re-capture, which the auto-refresh
|
|
||||||
// loop does on a timer and whenever a live request hits an anti-bot 403.
|
|
||||||
//
|
|
||||||
// The captured recipe is persisted (site settings) so a restart keeps the last
|
|
||||||
// good values, and every failure is non-fatal — statsigID keeps using whatever
|
|
||||||
// recipe is currently live (persisted → last capture → static defaults).
|
|
||||||
|
|
||||||
// hookJS is installed before grok's own scripts run. It records (a) every
|
|
||||||
// crypto.subtle.digest input that contains the statsig salt marker — the plaintext
|
|
||||||
// "METHOD!/path!counter<suffix>" — and (b) every 70-byte x-statsig-id header grok
|
|
||||||
// attaches to its API calls. Both are only produced once grok's signer succeeds
|
|
||||||
// (after its keyframe animation settles), so a short wait yields valid values.
|
|
||||||
const statsigHookJS = `
|
|
||||||
(() => {
|
|
||||||
window.__statsigDigests = []; window.__statsigCaps = [];
|
|
||||||
try {
|
|
||||||
const td = new TextDecoder('utf-8', {fatal:false});
|
|
||||||
const orig = crypto.subtle.digest.bind(crypto.subtle);
|
|
||||||
crypto.subtle.digest = function(alg, data) {
|
|
||||||
try {
|
|
||||||
let buf = data; if (data && data.buffer) buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
||||||
const txt = td.decode(new Uint8Array(buf.slice ? buf.slice(0) : buf));
|
|
||||||
if (txt.indexOf('` + statsigSaltPrefix + `') >= 0) window.__statsigDigests.push(txt);
|
|
||||||
} catch (e) {}
|
|
||||||
return orig(alg, data);
|
|
||||||
};
|
|
||||||
const of = window.fetch;
|
|
||||||
window.fetch = function(input, init) {
|
|
||||||
try {
|
|
||||||
let hdrs = (init && init.headers); let sid = null;
|
|
||||||
if (hdrs) { if (typeof hdrs.get === 'function') sid = hdrs.get('x-statsig-id'); else for (const k in hdrs) if (k.toLowerCase() === 'x-statsig-id') sid = hdrs[k]; }
|
|
||||||
if (sid && sid.length === 94) window.__statsigCaps.push(sid);
|
|
||||||
} catch (e) {}
|
|
||||||
return of.apply(this, arguments);
|
|
||||||
};
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
`
|
|
||||||
|
|
||||||
var (
|
|
||||||
statsigRefreshTrigger = make(chan struct{}, 1)
|
|
||||||
statsigRefreshStarted sync.Once
|
|
||||||
statsigLastCapture time.Time
|
|
||||||
statsigLastCaptureMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetStatsigRecipe atomically replaces the live (header, suffix, trailer) used by
|
|
||||||
// statsigID and drops the per-session challenge cache so the next request adopts
|
|
||||||
// the new values. headerHex must decode to 49 bytes; suffix must be non-empty.
|
|
||||||
func SetStatsigRecipe(headerHex, suffix string, trailer int) error {
|
|
||||||
h, err := hex.DecodeString(strings.TrimSpace(headerHex))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(h) != 49 {
|
|
||||||
return errors.New("statsig: header must be 49 bytes")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(suffix) == "" {
|
|
||||||
return errors.New("statsig: empty suffix")
|
|
||||||
}
|
|
||||||
if trailer < 0 || trailer > 255 {
|
|
||||||
return errors.New("statsig: trailer out of range")
|
|
||||||
}
|
|
||||||
statsigMu.Lock()
|
|
||||||
statsigHeader = h
|
|
||||||
statsigSuffix = suffix
|
|
||||||
statsigTrailer = byte(trailer)
|
|
||||||
statsigCache = map[string]statsigChallenge{}
|
|
||||||
statsigMu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// chromeExecPath resolves the Chrome/Chromium binary for headless capture, from
|
|
||||||
// GROK_STATSIG_CHROME or the usual names on PATH. Empty means none available.
|
|
||||||
func chromeExecPath() string {
|
|
||||||
if p := strings.TrimSpace(os.Getenv("GROK_STATSIG_CHROME")); p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
for _, name := range []string{"google-chrome-stable", "google-chrome", "chromium", "chromium-browser"} {
|
|
||||||
if p, err := exec.LookPath(name); err == nil {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type statsigCapResult struct {
|
|
||||||
Digs []string `json:"digs"`
|
|
||||||
Caps []string `json:"caps"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptureStatsigRecipe drives a headless Chrome to grok.com and reads grok's own
|
|
||||||
// signer output, returning the current (headerHex, suffix, trailer). It never
|
|
||||||
// mutates global state. Returns an error if Chrome is unavailable or grok's
|
|
||||||
// signer output could not be observed (e.g. blocked before the signer ran).
|
|
||||||
func CaptureStatsigRecipe(ctx context.Context) (headerHex, suffix string, trailer int, err error) {
|
|
||||||
chromePath := chromeExecPath()
|
|
||||||
if chromePath == "" {
|
|
||||||
return "", "", 0, errors.New("statsig: no chrome binary (set GROK_STATSIG_CHROME)")
|
|
||||||
}
|
|
||||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
|
||||||
chromedp.ExecPath(chromePath),
|
|
||||||
chromedp.Flag("headless", "new"),
|
|
||||||
chromedp.Flag("no-sandbox", true),
|
|
||||||
chromedp.Flag("disable-gpu", true),
|
|
||||||
chromedp.Flag("disable-dev-shm-usage", true),
|
|
||||||
chromedp.UserAgent(userAgent),
|
|
||||||
)
|
|
||||||
if proxy := strings.TrimSpace(os.Getenv("GROK_STATSIG_PROXY")); proxy != "" {
|
|
||||||
opts = append(opts, chromedp.ProxyServer(proxy))
|
|
||||||
}
|
|
||||||
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
|
|
||||||
defer cancel()
|
|
||||||
bctx, cancel2 := chromedp.NewContext(allocCtx)
|
|
||||||
defer cancel2()
|
|
||||||
|
|
||||||
var res statsigCapResult
|
|
||||||
runErr := chromedp.Run(bctx,
|
|
||||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
||||||
_, err := page.AddScriptToEvaluateOnNewDocument(statsigHookJS).Do(ctx)
|
|
||||||
return err
|
|
||||||
}),
|
|
||||||
chromedp.Navigate(apiBase+"/"),
|
|
||||||
chromedp.Sleep(14*time.Second),
|
|
||||||
chromedp.Evaluate(`({digs: window.__statsigDigests || [], caps: window.__statsigCaps || []})`, &res),
|
|
||||||
)
|
|
||||||
if runErr != nil {
|
|
||||||
return "", "", 0, runErr
|
|
||||||
}
|
|
||||||
return parseStatsigCapture(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStatsigCapture extracts (headerHex, suffix, trailer) from the observed
|
|
||||||
// digest plaintexts and x-statsig-id headers of one page load. Both must be
|
|
||||||
// present and are self-consistent (same per-load fingerprint F).
|
|
||||||
func parseStatsigCapture(res statsigCapResult) (headerHex, suffix string, trailer int, err error) {
|
|
||||||
for _, d := range res.Digs {
|
|
||||||
if i := strings.Index(d, statsigSaltPrefix); i >= 0 {
|
|
||||||
suffix = d[i:]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if suffix == "" {
|
|
||||||
return "", "", 0, errors.New("statsig: no signer digest observed (blocked before signer ran?)")
|
|
||||||
}
|
|
||||||
var header []byte
|
|
||||||
for _, sid := range res.Caps {
|
|
||||||
raw, derr := base64.RawStdEncoding.DecodeString(sid)
|
|
||||||
if derr != nil || len(raw) != 70 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := raw[0] // header[0] is 0x00, so the XOR mask key == byte 0
|
|
||||||
r := make([]byte, 70)
|
|
||||||
for i := range raw {
|
|
||||||
r[i] = raw[i] ^ key
|
|
||||||
}
|
|
||||||
if r[0] != 0x00 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
header = r[0:49]
|
|
||||||
trailer = int(r[69])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if header == nil {
|
|
||||||
return "", "", 0, errors.New("statsig: no valid x-statsig-id header observed")
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(header), suffix, trailer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerStatsigRefresh asks the auto-refresh loop to re-capture soon (coalesced).
|
|
||||||
// Called when a live request hits an anti-bot 403 — the recipe likely went stale.
|
|
||||||
func TriggerStatsigRefresh() {
|
|
||||||
select {
|
|
||||||
case statsigRefreshTrigger <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartStatsigAutoRefresh launches the headless capture loop once. It is
|
|
||||||
// event-driven, NOT a polling loop: it seeds the live recipe from persisted
|
|
||||||
// values (load), captures once at startup so the process starts fresh, and then
|
|
||||||
// re-captures ONLY when a live request hits an anti-bot 403 (TriggerStatsigRefresh)
|
|
||||||
// — i.e. exactly when the recipe actually went stale on a grok reship. The recipe
|
|
||||||
// has no fixed clock expiry, so there is nothing useful to poll on. An optional
|
|
||||||
// safetyInterval>0 adds a slow backstop re-capture; pass 0 to disable it (default).
|
|
||||||
// Captured recipes are handed to `save` for persistence. No-op when a manual
|
|
||||||
// GROK_STATSIG_* override is set or no Chrome binary is available.
|
|
||||||
func StartStatsigAutoRefresh(ctx context.Context, safetyInterval time.Duration,
|
|
||||||
load func(context.Context) (headerHex, suffix string, trailer int, ok bool),
|
|
||||||
save func(ctx context.Context, headerHex, suffix string, trailer int)) {
|
|
||||||
|
|
||||||
statsigRefreshStarted.Do(func() {
|
|
||||||
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
|
||||||
log.Printf("grok statsig: manual GROK_STATSIG_* override set — headless auto-refresh disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if chromeExecPath() == "" {
|
|
||||||
log.Printf("grok statsig: no chrome binary — headless auto-refresh disabled, using static recipe (set GROK_STATSIG_CHROME to enable)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if load != nil {
|
|
||||||
if h, s, t, ok := load(ctx); ok {
|
|
||||||
if err := SetStatsigRecipe(h, s, t); err != nil {
|
|
||||||
log.Printf("grok statsig: persisted recipe invalid, ignoring: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("grok statsig: loaded persisted recipe header[:6]=%s", safePrefix(h, 12))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
go statsigRefreshLoop(ctx, safetyInterval, save)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func statsigRefreshLoop(ctx context.Context, safetyInterval time.Duration, save func(context.Context, string, string, int)) {
|
|
||||||
refresh := func(reason string) {
|
|
||||||
statsigLastCaptureMu.Lock()
|
|
||||||
if time.Since(statsigLastCapture) < 30*time.Second {
|
|
||||||
statsigLastCaptureMu.Unlock()
|
|
||||||
return // debounce bursts (e.g. a 403 storm across concurrent requests)
|
|
||||||
}
|
|
||||||
statsigLastCapture = time.Now()
|
|
||||||
statsigLastCaptureMu.Unlock()
|
|
||||||
|
|
||||||
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
h, s, t, err := CaptureStatsigRecipe(cctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("grok statsig: headless capture (%s) failed, keeping current recipe: %v", reason, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := SetStatsigRecipe(h, s, t); err != nil {
|
|
||||||
log.Printf("grok statsig: captured recipe invalid: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if save != nil {
|
|
||||||
save(ctx, h, s, t)
|
|
||||||
}
|
|
||||||
log.Printf("grok statsig: headless recipe refreshed (%s) header[:6]=%s suffix[:24]=%s", reason, safePrefix(h, 12), safePrefix(s, 24))
|
|
||||||
}
|
|
||||||
|
|
||||||
initial := time.NewTimer(10 * time.Second)
|
|
||||||
defer initial.Stop()
|
|
||||||
|
|
||||||
// Optional slow backstop only; disabled (nil channel blocks forever) when
|
|
||||||
// safetyInterval<=0 so the loop stays purely event-driven.
|
|
||||||
var tickC <-chan time.Time
|
|
||||||
if safetyInterval > 0 {
|
|
||||||
ticker := time.NewTicker(safetyInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
tickC = ticker.C
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-initial.C:
|
|
||||||
refresh("startup")
|
|
||||||
case <-tickC:
|
|
||||||
refresh("safety-interval")
|
|
||||||
case <-statsigRefreshTrigger:
|
|
||||||
refresh("anti-bot 403")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func safePrefix(s string, n int) string {
|
|
||||||
if len(s) < n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package grok
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ground-truth (seed, curves, F) triples. The first row is server-verified
|
|
||||||
// (captured from a real grok browser token that returned 200); the remainder
|
|
||||||
// exercise every curve group / currentTime branch and are checked against the
|
|
||||||
// reference implementation the algorithm was reverse-engineered against.
|
|
||||||
var statsigTriples = []struct {
|
|
||||||
seedHex string
|
|
||||||
wantM string
|
|
||||||
}{
|
|
||||||
{seedHex: "732c9800d181e47c9b2a2705713306f42a51a10d69c6a6c32e53b26d1599b0b75399035a9953fedf05903cb9eb70a989", wantM: "ff75470a3d70a3d70a3d80c51eb851eb8520c51eb851eb8520a3d70a3d70a3d800"},
|
|
||||||
{seedHex: "732c9800d1802530bb1d6d057133064d2aa5a1ca69c6a6c32e53b26d1599b0b75399035a9953fe1805903cb9eb70a989", wantM: "27fa10fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700"},
|
|
||||||
{seedHex: "732c9800d180237b2ed91e057133062c2a13a1de69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "079ff0d1eb851eb851e80947ae147ae14780947ae147ae14780d1eb851eb851e800"},
|
|
||||||
{seedHex: "732c9800d180197117449405713306722a3fa11f69c6a6c32e53b26d1599b0b75399035a9953fecb05903cb9eb70a989", wantM: "195fe0fae147ae147ae035c28f5c28f5c2035c28f5c28f5c20fae147ae147ae00"},
|
|
||||||
{seedHex: "732c9800d1815c3460be3105713306492ad6a13c69c6a6c32e53b26d1599b0b75399035a9953fe9d05903cb9eb70a989", wantM: "ff6ab5035c28f5c28f5c20fae147ae147ae0fae147ae147ae035c28f5c28f5c200"},
|
|
||||||
{seedHex: "732c9800d181daa0eee8b9057133061e2a20a16969c6a6c32e53b26d1599b0b75399035a9953fefe05903cb9eb70a989", wantM: "3da0d70e3d70a3d70a3d8075c28f5c28f5c4075c28f5c28f5c40e3d70a3d70a3d800"},
|
|
||||||
{seedHex: "732c9800d1812999fdafe5057133067f2a99a15c69c6a6c32e53b26d1599b0b75399035a9953fe7c05903cb9eb70a989", wantM: "1cac4c0451eb851eb8520f5c28f5c28f5c0f5c28f5c28f5c0451eb851eb85200"},
|
|
||||||
{seedHex: "732c9800d18254af4dfad705713306252a93a13c69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "b40480d70a3d70a3d70808a3d70a3d70a408a3d70a3d70a40d70a3d70a3d70800"},
|
|
||||||
{seedHex: "732c9800d182b3fee9232f05713306272a14a1a069c6a6c32e53b26d1599b0b75399035a9953feae05903cb9eb70a989", wantM: "13757a100100"},
|
|
||||||
{seedHex: "732c9800d1829ee491c5b105713306f22a8aa12169c6a6c32e53b26d1599b0b75399035a9953fe1f05903cb9eb70a989", wantM: "eb4fb9100a3d70a3d70a3d800a3d70a3d70a3d8100"},
|
|
||||||
{seedHex: "732c9800d1833bfc1e6f9305713306ec2a0ba1b569c6a6c32e53b26d1599b0b75399035a9953fe5605903cb9eb70a989", wantM: "8a56650fae147ae147ae02b851eb851eb8602b851eb851eb860fae147ae147ae00"},
|
|
||||||
{seedHex: "732c9800d183fe2955e5cd057133067e2a42a1cb69c6a6c32e53b26d1599b0b75399035a9953fec805903cb9eb70a989", wantM: "b242950deb851eb851eb807d70a3d70a3d707d70a3d70a3d70deb851eb851eb800"},
|
|
||||||
{seedHex: "732c9800d183d4b7c2764d05713306462a8ea1dc69c6a6c32e53b26d1599b0b75399035a9953fe8e05903cb9eb70a989", wantM: "1e70d8070a3d70a3d70a40e66666666666680e6666666666668070a3d70a3d70a400"}}
|
|
||||||
|
|
||||||
const statsigTestCurves = `[[{"color":[48,44,6,37,198,15],"deg":192,"bezier":[118,76,158,16]},{"color":[224,216,196,111,43,97],"deg":119,"bezier":[67,167,95,219]},{"color":[90,235,250,5,223,64],"deg":104,"bezier":[100,57,106,204]},{"color":[6,109,253,44,29,224],"deg":151,"bezier":[204,60,142,122]},{"color":[81,0,121,208,228,133],"deg":181,"bezier":[182,89,105,123]},{"color":[31,3,160,181,226,184],"deg":98,"bezier":[207,150,215,136]},{"color":[231,243,81,28,109,131],"deg":175,"bezier":[17,103,7,81]},{"color":[222,250,130,169,55,247],"deg":141,"bezier":[21,238,12,84]},{"color":[74,62,116,145,209,185],"deg":109,"bezier":[0,74,58,233]},{"color":[204,168,203,138,107,125],"deg":214,"bezier":[41,13,239,45]},{"color":[246,116,162,162,84,234],"deg":127,"bezier":[160,202,52,76]},{"color":[138,202,210,36,61,195],"deg":234,"bezier":[31,50,177,229]},{"color":[47,46,196,176,79,255],"deg":221,"bezier":[39,14,9,76]},{"color":[245,164,227,71,201,110],"deg":190,"bezier":[193,208,125,9]},{"color":[199,253,44,33,130,240],"deg":191,"bezier":[168,247,61,37]},{"color":[19,91,5,180,202,243],"deg":216,"bezier":[96,152,42,123]}],[{"color":[42,154,230,170,197,128],"deg":108,"bezier":[160,52,34,184]},{"color":[224,132,83,0,231,162],"deg":238,"bezier":[227,37,122,102]},{"color":[24,82,237,199,100,74],"deg":130,"bezier":[186,106,188,209]},{"color":[47,50,169,65,124,44],"deg":228,"bezier":[198,22,146,225]},{"color":[111,131,95,195,131,73],"deg":207,"bezier":[134,146,202,172]},{"color":[192,10,166,28,236,3],"deg":219,"bezier":[85,77,157,235]},{"color":[236,139,199,72,233,250],"deg":197,"bezier":[180,9,79,147]},{"color":[146,195,78,16,231,211],"deg":93,"bezier":[123,18,87,27]},{"color":[168,211,49,42,124,18],"deg":172,"bezier":[232,171,120,118]},{"color":[43,146,96,11,203,53],"deg":146,"bezier":[11,4,83,198]},{"color":[183,97,101,38,115,41],"deg":159,"bezier":[69,223,197,236]},{"color":[78,73,207,132,109,134],"deg":49,"bezier":[59,18,133,168]},{"color":[239,153,225,207,127,157],"deg":194,"bezier":[13,206,154,182]},{"color":[19,146,127,191,68,199],"deg":165,"bezier":[148,212,7,16]},{"color":[93,225,249,144,88,255],"deg":215,"bezier":[62,30,77,69]},{"color":[203,79,164,145,3,20],"deg":81,"bezier":[228,28,93,130]}],[{"color":[171,53,4,125,232,43],"deg":122,"bezier":[136,9,128,97]},{"color":[143,97,205,57,63,69],"deg":192,"bezier":[232,20,219,189]},{"color":[28,173,18,107,158,44],"deg":255,"bezier":[93,187,184,198]},{"color":[170,17,62,142,200,32],"deg":29,"bezier":[14,31,12,97]},{"color":[19,117,122,173,239,66],"deg":74,"bezier":[117,176,139,212]},{"color":[213,151,230,112,224,255],"deg":25,"bezier":[1,223,72,233]},{"color":[153,131,51,105,69,47],"deg":108,"bezier":[123,177,126,140]},{"color":[120,114,44,151,88,83],"deg":165,"bezier":[16,104,134,75]},{"color":[245,145,194,75,120,26],"deg":142,"bezier":[79,235,38,43]},{"color":[147,63,50,255,239,106],"deg":190,"bezier":[122,143,160,150]},{"color":[233,78,184,130,25,123],"deg":54,"bezier":[87,18,184,226]},{"color":[162,180,233,70,57,249],"deg":87,"bezier":[123,238,61,124]},{"color":[146,95,56,171,38,240],"deg":239,"bezier":[241,134,228,44]},{"color":[111,11,149,62,208,177],"deg":70,"bezier":[103,149,4,37]},{"color":[159,128,118,21,197,153],"deg":175,"bezier":[246,215,172,236]},{"color":[194,131,68,247,215,108],"deg":30,"bezier":[23,91,151,231]}],[{"color":[239,129,141,243,85,208],"deg":38,"bezier":[252,248,245,195]},{"color":[205,56,138,49,126,99],"deg":107,"bezier":[72,85,228,91]},{"color":[185,54,148,122,170,158],"deg":192,"bezier":[33,88,51,136]},{"color":[7,14,75,26,23,41],"deg":93,"bezier":[153,21,55,147]},{"color":[245,211,213,64,5,253],"deg":49,"bezier":[253,19,106,155]},{"color":[144,232,165,21,114,130],"deg":200,"bezier":[193,179,133,226]},{"color":[34,196,100,42,114,0],"deg":52,"bezier":[4,4,71,65]},{"color":[195,8,130,102,201,141],"deg":210,"bezier":[8,173,23,33]},{"color":[86,136,44,95,223,62],"deg":249,"bezier":[220,98,68,113]},{"color":[205,48,9,247,236,71],"deg":75,"bezier":[163,240,28,25]},{"color":[43,190,29,239,55,135],"deg":146,"bezier":[109,245,34,188]},{"color":[146,91,92,2,3,251],"deg":97,"bezier":[183,188,95,157]},{"color":[40,213,196,70,81,174],"deg":120,"bezier":[153,197,61,201]},{"color":[131,92,180,68,131,214],"deg":251,"bezier":[94,191,198,89]},{"color":[47,113,219,96,115,228],"deg":238,"bezier":[22,35,60,63]},{"color":[246,244,203,196,78,136],"deg":44,"bezier":[88,23,205,184]}]]`
|
|
||||||
|
|
||||||
// TestComputeStatsigTail was the offline regression for the hand-ported F
|
|
||||||
// derivation (computeStatsigTail). That algorithm is now only a last-resort
|
|
||||||
// fallback: the durable path runs grok's own signer in goja (statsig_engine.go),
|
|
||||||
// which self-heals across web reships. grok rotates the F algorithm on every
|
|
||||||
// reship, so these browser-captured (seed,F) triples go stale and CANNOT be
|
|
||||||
// regenerated offline. TestSelfHealStatsigE2E (live, via goja) is the real
|
|
||||||
// regression now. Kept skipped so the reference vectors aren't lost.
|
|
||||||
func TestComputeStatsigTail(t *testing.T) {
|
|
||||||
t.Skip("static F derivation is a deprecated fallback; see TestSelfHealStatsigE2E (goja)")
|
|
||||||
var curves [][]statsigCurve
|
|
||||||
if err := json.Unmarshal([]byte(statsigTestCurves), &curves); err != nil {
|
|
||||||
t.Fatalf("curves: %v", err)
|
|
||||||
}
|
|
||||||
for i, tc := range statsigTriples {
|
|
||||||
seed, err := hex.DecodeString(tc.seedHex)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("[%d] seed hex: %v", i, err)
|
|
||||||
}
|
|
||||||
got, err := computeStatsigTail(seed, curves)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("[%d] computeStatsigTail: %v", i, err)
|
|
||||||
}
|
|
||||||
if got != tc.wantM {
|
|
||||||
t.Errorf("[%d] group=%d\n got=%s\nwant=%s", i, int(seed[5])%len(curves), got, tc.wantM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSelfHealStatsigE2E exercises the full browser-free self-healing path:
|
|
||||||
// fetch the homepage, derive seed+F, cache the challenge, then hit the
|
|
||||||
// anti-bot-gated conversations/new endpoint. Requires a live GROK_TOK and no
|
|
||||||
// GROK_STATSIG_* env overrides.
|
|
||||||
func TestSelfHealStatsigE2E(t *testing.T) {
|
|
||||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
|
||||||
if token == "" {
|
|
||||||
t.Skip("no GROK_TOK")
|
|
||||||
}
|
|
||||||
c := NewClient("")
|
|
||||||
client, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
c.ensureChallenge(ctx, client, token)
|
|
||||||
statsigMu.Lock()
|
|
||||||
ch, ok := statsigCache[token]
|
|
||||||
statsigMu.Unlock()
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("challenge not cached (homepage fetch/derive failed)")
|
|
||||||
}
|
|
||||||
t.Logf("dynamic header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
|
|
||||||
|
|
||||||
body, err := c.postStream(ctx, client, token, "/rest/app-chat/conversations/new", map[string]any{
|
|
||||||
"temporary": true,
|
|
||||||
"modelName": "grok-3",
|
|
||||||
"message": "hi",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("conversations/new: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("OK bytes=%d head=%.80s", len(body), strings.ReplaceAll(body, "\n", " "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatsigEngineConcurrent fires many concurrent goja signs through the pool to
|
|
||||||
// catch data races / engine cross-talk (run with -race). Requires a live GROK_TOK
|
|
||||||
// so the engine is built and the challenge cached.
|
|
||||||
func TestStatsigEngineConcurrent(t *testing.T) {
|
|
||||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
|
||||||
if token == "" {
|
|
||||||
t.Skip("no GROK_TOK")
|
|
||||||
}
|
|
||||||
c := NewClient("")
|
|
||||||
client, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
c.ensureChallenge(ctx, client, token)
|
|
||||||
statsigMu.Lock()
|
|
||||||
ch, ok := statsigCache[token]
|
|
||||||
statsigMu.Unlock()
|
|
||||||
if !ok || ch.seedB64 == "" || ch.curvesJSON == "" {
|
|
||||||
t.Fatal("engine inputs not cached")
|
|
||||||
}
|
|
||||||
|
|
||||||
const n = 40
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
errs := make(chan error, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
id, err := signWithEngine(ch.seedB64, ch.curvesJSON, "/rest/app-chat/conversations/new", "POST")
|
|
||||||
if err != nil {
|
|
||||||
errs <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(id) < 40 {
|
|
||||||
errs <- errTooShort
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
close(errs)
|
|
||||||
for e := range errs {
|
|
||||||
t.Fatalf("concurrent sign: %v", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var errTooShort = errors.New("statsig id too short")
|
|
||||||
|
|
||||||
// TestGenerateVideoE2E generates a real grok video using only the dynamic
|
|
||||||
// self-healed statsig (no env overrides). Requires a live GROK_TOK.
|
|
||||||
func TestGenerateVideoE2E(t *testing.T) {
|
|
||||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
|
||||||
if token == "" {
|
|
||||||
t.Skip("no GROK_TOK")
|
|
||||||
}
|
|
||||||
c := NewClient("")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
data, meta, err := c.GenerateVideo(ctx, token, "a cat playing piano", "16:9", "720p", 6, nil, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateVideo: %v", err)
|
|
||||||
}
|
|
||||||
t.Logf("video bytes=%d meta=%v", len(data), meta)
|
|
||||||
if len(data) < 1<<20 {
|
|
||||||
t.Fatalf("video too small: %d bytes", len(data))
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data[:16]), "ftyp") {
|
|
||||||
t.Fatalf("not an mp4: % x", data[:16])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
// statsig_shim.js — browser-environment shim that lets grok.com's own obfuscated
|
|
||||||
// x-statsig-id signer run inside goja. grok's code does all the (per-build,
|
|
||||||
// rotating) byte-indexing/curve-selection; we only provide the STABLE browser
|
|
||||||
// primitives it reads from: the seed <meta>, the .r-aufz1o SVG curve DOM, and a
|
|
||||||
// standard Web-Animations getComputedStyle sampler. Inputs arrive via globals set
|
|
||||||
// by Go before each sign: __SEED (base64 str), __CURVES (JSON [[{color,deg,bezier}]]),
|
|
||||||
// __PATH, __METHOD. Go also injects __goSha256(Uint8Array)->ArrayBuffer.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
var g = globalThis;
|
|
||||||
|
|
||||||
// ---- base64 (goja has no atob/btoa) ----
|
|
||||||
var B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
||||||
g.atob = function (s) {
|
|
||||||
s = String(s).replace(/=+$/, '');
|
|
||||||
var out = '', bits = 0, val = 0;
|
|
||||||
for (var i = 0; i < s.length; i++) {
|
|
||||||
var c = B64.indexOf(s.charAt(i));
|
|
||||||
if (c < 0) continue;
|
|
||||||
val = (val << 6) | c; bits += 6;
|
|
||||||
if (bits >= 8) { bits -= 8; out += String.fromCharCode((val >> bits) & 0xff); }
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
g.btoa = function (s) {
|
|
||||||
s = String(s); var out = '';
|
|
||||||
for (var i = 0; i < s.length; i += 3) {
|
|
||||||
var b0 = s.charCodeAt(i), b1 = s.charCodeAt(i + 1), b2 = s.charCodeAt(i + 2);
|
|
||||||
var h0 = b0 >> 2, h1 = ((b0 & 3) << 4) | (b1 >> 4);
|
|
||||||
var h2 = ((b1 & 15) << 2) | (b2 >> 6), h3 = b2 & 63;
|
|
||||||
out += B64[h0] + B64[h1];
|
|
||||||
out += isNaN(b1) ? '=' : B64[h2];
|
|
||||||
out += isNaN(b2) ? '=' : B64[h3];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- TextEncoder (goja has no TextEncoder) ----
|
|
||||||
if (typeof g.TextEncoder === 'undefined') {
|
|
||||||
g.TextEncoder = function () {};
|
|
||||||
g.TextEncoder.prototype.encode = function (str) {
|
|
||||||
str = String(str);
|
|
||||||
var bytes = [];
|
|
||||||
for (var i = 0; i < str.length; i++) {
|
|
||||||
var c = str.charCodeAt(i);
|
|
||||||
if (c < 0x80) bytes.push(c);
|
|
||||||
else if (c < 0x800) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); }
|
|
||||||
else if (c >= 0xd800 && c <= 0xdbff) { // surrogate pair
|
|
||||||
var c2 = str.charCodeAt(++i);
|
|
||||||
var cp = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
|
|
||||||
bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
|
||||||
} else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); }
|
|
||||||
}
|
|
||||||
return Uint8Array.from(bytes);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- crypto.subtle.digest, backed by Go SHA-256 ----
|
|
||||||
g.crypto = g.crypto || {};
|
|
||||||
g.crypto.subtle = g.crypto.subtle || {};
|
|
||||||
g.crypto.subtle.digest = function (algo, data) {
|
|
||||||
// grok only ever asks for sha-256; g.__goSha256 returns an ArrayBuffer
|
|
||||||
var bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
||||||
return Promise.resolve(g.__goSha256(bytes));
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Web Animations getComputedStyle sampler (the only real math we own) ----
|
|
||||||
var K = 4096;
|
|
||||||
function cubicBezier(x1, y1, x2, y2, p) {
|
|
||||||
if (p <= 0) return 0; if (p >= 1) return 1;
|
|
||||||
function bez(t, a, b) { var mt = 1 - t; return 3 * a * mt * mt * t + 3 * b * mt * t * t + t * t * t; }
|
|
||||||
var lo = 0, hi = 1;
|
|
||||||
for (var i = 0; i < 100; i++) { var m = (lo + hi) / 2; if (bez(m, x1, x2) < p) lo = m; else hi = m; }
|
|
||||||
return bez((lo + hi) / 2, y1, y2);
|
|
||||||
}
|
|
||||||
function hexToRgb(h) { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
|
|
||||||
function sample(anim) {
|
|
||||||
var kf = anim.keyframes, dur = anim.duration || K;
|
|
||||||
var frac = anim.currentTime / dur; if (frac < 0) frac = 0; if (frac > 1) frac = 1;
|
|
||||||
var bm = /cubic-bezier\(([^)]+)\)/.exec(kf.easing || '');
|
|
||||||
var eased = frac;
|
|
||||||
if (bm) { var p = bm[1].split(',').map(Number); eased = cubicBezier(p[0], p[1], p[2], p[3], frac); }
|
|
||||||
var c0 = hexToRgb(kf.color[0]), c1 = hexToRgb(kf.color[1]);
|
|
||||||
var col = [0, 1, 2].map(function (i) { return Math.round(c0[i] + (c1[i] - c0[i]) * eased); });
|
|
||||||
var d0 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[0])[1]);
|
|
||||||
var d1 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[1])[1]);
|
|
||||||
var ang = (d0 + (d1 - d0) * eased) * Math.PI / 180;
|
|
||||||
var cos = Math.cos(ang), sin = Math.sin(ang);
|
|
||||||
return { color: 'rgb(' + col[0] + ', ' + col[1] + ', ' + col[2] + ')',
|
|
||||||
transform: 'matrix(' + cos + ', ' + sin + ', ' + (-sin) + ', ' + cos + ', 0, 0)' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- minimal DOM ----
|
|
||||||
function makeEl(props) {
|
|
||||||
var e = {
|
|
||||||
nodeName: 'DIV', style: {}, childNodes: [], attrs: {}, _anim: null, _parent: null,
|
|
||||||
setAttribute: function (k, v) { e.attrs[k] = v; },
|
|
||||||
getAttribute: function (k) { return (k in e.attrs) ? e.attrs[k] : (props && props.attrs && k in props.attrs ? props.attrs[k] : null); },
|
|
||||||
appendChild: function (c) { e.childNodes.push(c); return c; },
|
|
||||||
append: function (c) { e.childNodes.push(c); return c; },
|
|
||||||
removeChild: function (c) { return c; },
|
|
||||||
remove: function () {},
|
|
||||||
animate: function (keyframes, opts) {
|
|
||||||
var anim = {
|
|
||||||
keyframes: keyframes,
|
|
||||||
duration: (opts && opts.duration) || (typeof opts === 'number' ? opts : K),
|
|
||||||
currentTime: 0, pause: function () {}, play: function () {}, cancel: function () {},
|
|
||||||
effect: { getKeyframes: function () { return Array.isArray(keyframes) ? keyframes : [keyframes]; } },
|
|
||||||
};
|
|
||||||
e._anim = anim; return anim;
|
|
||||||
},
|
|
||||||
getAnimations: function () { return e._anim ? [e._anim] : []; },
|
|
||||||
};
|
|
||||||
if (props) for (var k in props) if (k !== 'attrs') e[k] = props[k];
|
|
||||||
Object.defineProperty(e, 'parentElement', { get: function () { return e._parent; } });
|
|
||||||
Object.defineProperty(e, 'innerHTML', { set: function (v) { e._html = v; }, get: function () { return e._html; } });
|
|
||||||
Object.defineProperty(e, 'textContent', { set: function (v) { e._text = v; }, get: function () { return e._text; } });
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// .r-aufz1o group: g.childNodes[0].childNodes[1].getAttribute('d') = svg path whose
|
|
||||||
// numbers (after substring(9), split('C')) decode back to each curve [c0..c5,deg,b0..b3].
|
|
||||||
function groupEl(flatCurves) {
|
|
||||||
var d = '_________' + flatCurves.map(function (c) { return c.join(' '); }).join('C');
|
|
||||||
var path = makeEl({ attrs: { d: d } });
|
|
||||||
var inner = makeEl(); inner.childNodes = [makeEl(), path];
|
|
||||||
var outer = makeEl(); outer.childNodes = [inner]; outer._parent = makeEl();
|
|
||||||
return outer;
|
|
||||||
}
|
|
||||||
|
|
||||||
var docBody = makeEl();
|
|
||||||
g.document = {
|
|
||||||
currentScript: null, body: docBody, head: makeEl(),
|
|
||||||
createElement: function (tag) { return makeEl({ nodeName: String(tag || 'div').toUpperCase() }); },
|
|
||||||
querySelectorAll: function (sel) {
|
|
||||||
sel = String(sel);
|
|
||||||
// The seed <meta> is selected by a name/verification attribute selector
|
|
||||||
// (e.g. [name^=gr] or [name*=verification]); the curve group container is
|
|
||||||
// selected by a per-build hashed CLASS selector (e.g. .r-aufz1o, .r-3nqkqc)
|
|
||||||
// which rotates on every reship — so match by shape, not literal class.
|
|
||||||
if (/verification|name/i.test(sel)) {
|
|
||||||
var seed = g.__SEED;
|
|
||||||
return [{ nodeName: 'META', getAttribute: function (a) { return a === 'content' ? seed : null; },
|
|
||||||
get content() { return seed; } }];
|
|
||||||
}
|
|
||||||
if (/(^|\s|,)\./.test(sel)) {
|
|
||||||
var curves = JSON.parse(g.__CURVES);
|
|
||||||
return curves.map(function (grp) {
|
|
||||||
return groupEl(grp.map(function (cv) { return cv.color.concat([cv.deg], cv.bezier); }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
querySelector: function (sel) { var r = this.querySelectorAll(sel); return r[0] || null; },
|
|
||||||
};
|
|
||||||
g.window = g;
|
|
||||||
g.self = g;
|
|
||||||
g.getComputedStyle = function (el) { return el && el._anim ? sample(el._anim) : { color: 'rgb(0, 0, 0)', transform: 'none' }; };
|
|
||||||
g.navigator = g.navigator || { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' };
|
|
||||||
g.location = g.location || { href: 'https://grok.com/', origin: 'https://grok.com', pathname: '/' };
|
|
||||||
|
|
||||||
// ---- Turbopack capture + bootstrap ----
|
|
||||||
var TP = []; TP.push = function (entry) { TP._entry = entry; return 0; };
|
|
||||||
g.TURBOPACK = TP;
|
|
||||||
|
|
||||||
// Called by Go AFTER the signer chunk is eval'd: run the module factory, grab default.
|
|
||||||
g.__grokBootstrap = function () {
|
|
||||||
var entry = TP._entry;
|
|
||||||
if (!entry) throw new Error('turbopack entry not registered');
|
|
||||||
var factory = entry[2];
|
|
||||||
var exports = {};
|
|
||||||
var ctx = {
|
|
||||||
s: function () {
|
|
||||||
var flat = Array.prototype.slice.call(arguments).flat(Infinity);
|
|
||||||
var name = null;
|
|
||||||
for (var i = 0; i < flat.length; i++) {
|
|
||||||
var x = flat[i];
|
|
||||||
if (typeof x === 'string') name = x;
|
|
||||||
else if (typeof x === 'function' && name != null) {
|
|
||||||
(function (nm, getter) { Object.defineProperty(exports, nm, { get: getter, configurable: true, enumerable: true }); })(name, x);
|
|
||||||
name = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
factory(ctx);
|
|
||||||
// exports.default is a GETTER that invokes the module factory on every read,
|
|
||||||
// returning a FRESH async signer (path,method)=>Promise<id> with a fresh internal
|
|
||||||
// curve cache. Keep the exports object; read .default fresh per sign so different
|
|
||||||
// sessions' curves never leak through the closure cache.
|
|
||||||
g.__grokExports = exports;
|
|
||||||
if (typeof exports.default !== 'function') throw new Error('no default export');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Called by Go per sign. Fresh signer each time via the re-invoking getter.
|
|
||||||
g.__grokSign = function () {
|
|
||||||
var signer = g.__grokExports.default; // fresh async signer
|
|
||||||
return signer(g.__PATH, g.__METHOD); // returns Promise<string>
|
|
||||||
};
|
|
||||||
|
|
||||||
// Synchronous bridge: Go calls this via RunString (which drains goja's job queue),
|
|
||||||
// then reads __grokResult / __grokErr. Works because crypto.subtle.digest resolves
|
|
||||||
// synchronously (Promise.resolve over a Go SHA-256), so the whole await chain settles
|
|
||||||
// within the microtask drain.
|
|
||||||
g.__grokResult = null;
|
|
||||||
g.__grokErr = null;
|
|
||||||
g.__grokSignInto = function () {
|
|
||||||
g.__grokResult = null; g.__grokErr = null;
|
|
||||||
try {
|
|
||||||
g.__grokSign().then(
|
|
||||||
function (r) { g.__grokResult = r; },
|
|
||||||
function (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
|
|
||||||
);
|
|
||||||
} catch (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
package grok
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/textproto"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// assetBase is where generated media artifacts live (the stream returns a
|
|
||||||
// path like "users/<uid>/generated/<id>/generated_video.mp4").
|
|
||||||
const assetBase = "https://assets.grok.com/"
|
|
||||||
|
|
||||||
var videoURLRe = regexp.MustCompile(`"videoUrl":"([^"]+)"`)
|
|
||||||
|
|
||||||
// GenerateVideo runs grok's imagine video pipeline:
|
|
||||||
// 1. POST /rest/media/post/create -> a media post id (parentPostId)
|
|
||||||
// 2. POST /rest/app-chat/conversations/new with modelName "imagine-video-gen"
|
|
||||||
// and a videoGenModelConfig referencing that post id; the streaming response
|
|
||||||
// reports progress and, at completion, the artifact videoUrl.
|
|
||||||
//
|
|
||||||
// frames (optional, up to the model's max) enable image-to-video: each image is
|
|
||||||
// uploaded to grok and referenced as an imageReference. aspectRatio is passed
|
|
||||||
// through ("9:16" etc.); resolution is the tier ("720p"); seconds is the clip
|
|
||||||
// length (6 or 10). When downloadResult is false, returns nil bytes and the
|
|
||||||
// artifact URL in meta["video_url"]; otherwise downloads the mp4.
|
|
||||||
func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(prompt) == "" {
|
|
||||||
return nil, nil, fmt.Errorf("grok: prompt required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(aspectRatio) == "" {
|
|
||||||
aspectRatio = "16:9"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(resolution) == "" {
|
|
||||||
resolution = "720p"
|
|
||||||
}
|
|
||||||
if seconds != 6 && seconds != 10 && seconds != 15 {
|
|
||||||
seconds = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the conversations/new submit egresses via the proxy; reference-frame
|
|
||||||
// upload and the mp4 download run on the local IP.
|
|
||||||
submitClient, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
directClient, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-heal the x-statsig-id anti-bot challenge for THIS session before the
|
|
||||||
// gated submit. Without it the header falls back to the static defaults,
|
|
||||||
// which go stale on a new grok web build and make conversations/new answer
|
|
||||||
// 403 (anti-bot). Fetch on the local IP (the proxy is rotating, so pinning
|
|
||||||
// the seed to a proxy egress IP is pointless). Failure is non-fatal
|
|
||||||
// (statsigID then uses defaults).
|
|
||||||
c.ensureChallenge(ctx, directClient, token)
|
|
||||||
|
|
||||||
// Image-to-video: upload each reference frame and collect its asset URL.
|
|
||||||
var imageRefs []string
|
|
||||||
for _, f := range frames {
|
|
||||||
if len(f) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
url, upErr := c.uploadImage(ctx, directClient, token, f)
|
|
||||||
if upErr != nil {
|
|
||||||
return nil, nil, upErr
|
|
||||||
}
|
|
||||||
imageRefs = append(imageRefs, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// grok streams progress inline, but the stream is fragile: it regularly dies
|
|
||||||
// mid-body ("unexpected EOF") after only the conversation object / an early
|
|
||||||
// progress event — while grok keeps rendering the clip server-side. The
|
|
||||||
// rendered clip always lands at the deterministic URL
|
|
||||||
// assets.grok.com/users/{userId}/generated/{postId}/generated_video.mp4, so a
|
|
||||||
// dead stream is NOT a failed generation: poll that URL until the clip is
|
|
||||||
// ready instead of failing the job (which would also fail over and burn
|
|
||||||
// another account's credits for a video that already exists).
|
|
||||||
postID, userID, err := c.createPost(ctx, submitClient, token, prompt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
videoCfg := map[string]any{
|
|
||||||
"parentPostId": postID,
|
|
||||||
"aspectRatio": aspectRatio,
|
|
||||||
"videoLength": seconds,
|
|
||||||
"resolutionName": resolution,
|
|
||||||
"isReferenceToVideo": len(imageRefs) > 0,
|
|
||||||
}
|
|
||||||
if len(imageRefs) > 0 {
|
|
||||||
videoCfg["imageReferences"] = imageRefs
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
"temporary": true,
|
|
||||||
"modelName": "imagine-video-gen",
|
|
||||||
"message": prompt + " --mode=custom",
|
|
||||||
"enableSideBySide": true,
|
|
||||||
"responseMetadata": map[string]any{
|
|
||||||
"modelConfigOverride": map[string]any{
|
|
||||||
"modelMap": map[string]any{"videoGenModelConfig": videoCfg},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// body may be partial when psErr != nil — still worth scanning.
|
|
||||||
body, psErr := c.postStream(ctx, submitClient, token, "/rest/app-chat/conversations/new", payload)
|
|
||||||
if dir := strings.TrimSpace(os.Getenv("GROK_DUMP")); dir != "" {
|
|
||||||
_ = os.WriteFile(dir+"/grok_body_"+postID+".txt", []byte(body), 0o644)
|
|
||||||
}
|
|
||||||
// Out-of-credits surfaces as a stream error (HTTP is still 200) — not retryable.
|
|
||||||
if strings.Contains(body, "usagePoolExhausted") || strings.Contains(body, "media generation credits") {
|
|
||||||
return nil, nil, fmt.Errorf("%w: media generation credits exhausted", ErrQuotaExhausted)
|
|
||||||
}
|
|
||||||
if psErr != nil && (errors.Is(psErr, ErrAuth) || errors.Is(psErr, ErrQuotaExhausted)) {
|
|
||||||
return nil, nil, psErr
|
|
||||||
}
|
|
||||||
// A fatal stream error means grok definitively rejected this generation
|
|
||||||
// (content moderation, an unsupported parameter, etc.). Retrying — on this
|
|
||||||
// or any other account — fails identically and only burns the pool, so fail
|
|
||||||
// fast with a non-temporary error the pool won't fail over on.
|
|
||||||
if strings.Contains(body, "STREAM_ERROR_SEVERITY_FATAL") {
|
|
||||||
return nil, nil, fmt.Errorf("grok: video generation rejected by upstream (fatal stream error): %s", clip([]byte(body), 200))
|
|
||||||
}
|
|
||||||
// Fast path: the artifact appears as "videoUrl":"users/.../generated_video.mp4".
|
|
||||||
artifact := ""
|
|
||||||
for _, m := range videoURLRe.FindAllStringSubmatch(body, -1) {
|
|
||||||
if v := strings.TrimSpace(m[1]); v != "" {
|
|
||||||
artifact = v // keep the last (progress=100) one
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if artifact == "" && userID != "" {
|
|
||||||
// Stream ended without the artifact (dropped connection or early close):
|
|
||||||
// the render continues upstream — wait for the deterministic asset URL.
|
|
||||||
url := fmt.Sprintf("%susers/%s/generated/%s/generated_video.mp4", assetBase, userID, postID)
|
|
||||||
if werr := c.waitForAsset(ctx, directClient, token, url); werr == nil {
|
|
||||||
artifact = url
|
|
||||||
} else if errors.Is(werr, ErrAuth) || errors.Is(werr, context.Canceled) || errors.Is(werr, context.DeadlineExceeded) {
|
|
||||||
return nil, nil, werr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if artifact == "" {
|
|
||||||
if psErr != nil {
|
|
||||||
return nil, nil, psErr
|
|
||||||
}
|
|
||||||
return nil, nil, fmt.Errorf("%w: no video artifact in response: %s", ErrTemporaryUpstream, clip([]byte(body), 200))
|
|
||||||
}
|
|
||||||
fullURL := artifact
|
|
||||||
if !strings.HasPrefix(fullURL, "http") {
|
|
||||||
fullURL = assetBase + strings.TrimPrefix(artifact, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := map[string]any{
|
|
||||||
"provider": "grok",
|
|
||||||
"post_id": postID,
|
|
||||||
"video_url": fullURL,
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, meta, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, directClient, token, fullURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadImage uploads one reference frame via /http/upload-file-v2/direct and
|
|
||||||
// returns its asset content URL for imageReferences. Cloudflare's bot score is
|
|
||||||
// per-request, so retry transient failures with backoff instead of failing the
|
|
||||||
// whole task.
|
|
||||||
func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) {
|
|
||||||
var res map[string]any
|
|
||||||
var err error
|
|
||||||
backoffs := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second}
|
|
||||||
for _, wait := range backoffs {
|
|
||||||
if wait > 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-time.After(wait):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res, err = c.uploadFileV2(ctx, client, token, img)
|
|
||||||
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
meta, _ := res["fileMetadata"].(map[string]any)
|
|
||||||
fileURI := ""
|
|
||||||
if meta != nil {
|
|
||||||
fileURI = strings.TrimSpace(stringValue(meta["fileUri"]))
|
|
||||||
}
|
|
||||||
if fileURI == "" {
|
|
||||||
return "", fmt.Errorf("%w: upload missing fileUri", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(fileURI, "http") {
|
|
||||||
return fileURI, nil
|
|
||||||
}
|
|
||||||
return assetBase + strings.TrimPrefix(fileURI, "/"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) uploadFileV2(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (map[string]any, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
mw := multipart.NewWriter(&buf)
|
|
||||||
partHeader := textproto.MIMEHeader{}
|
|
||||||
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, uuid.NewString()+".png"))
|
|
||||||
partHeader.Set("Content-Type", "image/png")
|
|
||||||
part, err := mw.CreatePart(partHeader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := part.Write(img); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := mw.WriteField("file_source", "IMAGINE_SELF_UPLOAD_FILE_SOURCE"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := mw.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/http/upload-file-v2/direct", bytes.NewReader(buf.Bytes()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
c.applyHeaders(req, token, map[string]string{"content-type": mw.FormDataContentType()})
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
if e := mapStatus("/http/upload-file-v2/direct", resp.StatusCode, raw); e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: upload non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createPost registers a video media post and returns its id (parentPostId).
|
|
||||||
// createPost creates the parent media post. grok uses its id as the videoId,
|
|
||||||
// and the rendered clip is stored under users/{userId}/generated/{postId}/.
|
|
||||||
func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, token, prompt string) (postID, userID string, err error) {
|
|
||||||
res, err := c.postJSON(ctx, client, token, "/rest/media/post/create", map[string]any{
|
|
||||||
"mediaType": "MEDIA_POST_TYPE_VIDEO",
|
|
||||||
"prompt": prompt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
post, _ := res["post"].(map[string]any)
|
|
||||||
postID = strings.TrimSpace(stringValue(post["id"]))
|
|
||||||
userID = strings.TrimSpace(stringValue(post["userId"]))
|
|
||||||
if postID == "" {
|
|
||||||
return "", "", fmt.Errorf("%w: media post missing id", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return postID, userID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForAsset polls a generated-asset URL (ranged GET on the local IP —
|
|
||||||
// assets.grok.com is not anti-bot gated) until grok finishes rendering it.
|
|
||||||
// 404 means "still rendering"; auth failures abort.
|
|
||||||
func (c *Client) waitForAsset(ctx context.Context, client tlsclient.HttpClient, token, url string) error {
|
|
||||||
deadline := time.Now().Add(30 * time.Minute)
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
|
||||||
"range": {"bytes=0-0"},
|
|
||||||
}
|
|
||||||
if resp, derr := client.Do(req); derr == nil {
|
|
||||||
code := resp.StatusCode
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
switch {
|
|
||||||
case code == http.StatusOK || code == http.StatusPartialContent:
|
|
||||||
return nil
|
|
||||||
case code == http.StatusUnauthorized || code == http.StatusForbidden:
|
|
||||||
return fmt.Errorf("%w: asset %d", ErrAuth, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(4 * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// postJSON does an authed JSON POST and parses a single JSON object response.
|
|
||||||
func (c *Client) postJSON(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (map[string]any, error) {
|
|
||||||
raw, status, err := c.doPost(ctx, client, token, path, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if e := mapStatus(path, status, raw); e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return map[string]any{}, nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// postStream does an authed JSON POST and returns the full (streamed) text body.
|
|
||||||
func (c *Client) postStream(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (string, error) {
|
|
||||||
raw, status, err := c.doPost(ctx, client, token, path, body)
|
|
||||||
if err != nil {
|
|
||||||
return string(raw), err
|
|
||||||
}
|
|
||||||
if e := mapStatus(path, status, raw); e != nil {
|
|
||||||
return string(raw), e
|
|
||||||
}
|
|
||||||
return string(raw), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) doPost(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) ([]byte, int, error) {
|
|
||||||
var reader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
b, _ := json.Marshal(body)
|
|
||||||
reader = strings.NewReader(string(b))
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, apiBase+path, reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
c.applyHeaders(req, token, nil)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
// Mid-body stream drops ("unexpected EOF", HTTP/2 resets) are common on
|
|
||||||
// long renders — return what was received so callers can still use it.
|
|
||||||
return raw, resp.StatusCode, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
return raw, resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAsset streams a grok asset (e.g. a generated video) authenticated with the
|
|
||||||
// account token — used by the async /v1/videos /content proxy. The caller MUST
|
|
||||||
// close the returned ReadCloser.
|
|
||||||
func (c *Client) OpenAsset(ctx context.Context, token, url string) (io.ReadCloser, string, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return nil, "", ErrAuth
|
|
||||||
}
|
|
||||||
// Asset streaming is pure bandwidth (no anti-bot) — egress on the local IP.
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
||||||
return nil, "", fmt.Errorf("%w: asset %d", ErrAuth, resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("%w: asset %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
ct := resp.Header.Get("Content-Type")
|
|
||||||
if ct == "" {
|
|
||||||
ct = "video/mp4"
|
|
||||||
}
|
|
||||||
return resp.Body, ct, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// download fetches the rendered artifact. The clip is already generated at this
|
|
||||||
// point, so a transient failure here (HTTP/2 stream reset, CF hiccup) must not
|
|
||||||
// fail the whole task — retry with backoff.
|
|
||||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
|
|
||||||
var data []byte
|
|
||||||
var err error
|
|
||||||
for _, wait := range []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second} {
|
|
||||||
if wait > 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case <-time.After(wait):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, err = c.downloadOnce(ctx, client, token, url)
|
|
||||||
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) downloadOnce(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil, fmt.Errorf("%w: empty video download", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapStatus maps an HTTP status to the shared provider error sentinels.
|
|
||||||
func mapStatus(path string, status int, raw []byte) error {
|
|
||||||
switch {
|
|
||||||
case status == 200:
|
|
||||||
return nil
|
|
||||||
case status == 403 && isBotChallenge(string(raw)):
|
|
||||||
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
|
|
||||||
// NOT a dead token — transient, so a good account isn't killed by an
|
|
||||||
// IP/anti-bot hiccup. A statsig anti-bot rejection means the recipe likely
|
|
||||||
// went stale on a reship; kick the headless refresher to re-capture.
|
|
||||||
TriggerStatsigRefresh()
|
|
||||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
|
||||||
case status == 401 || status == 403:
|
|
||||||
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
|
||||||
case status == 429:
|
|
||||||
// 429 is grok RATE-LIMITING ("Too many requests") — a transient error that
|
|
||||||
// must NOT kill the account. Only a body that names a credit/usage-pool
|
|
||||||
// exhaustion is a real quota wall.
|
|
||||||
if isCreditError(string(raw)) {
|
|
||||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
|
||||||
case status >= 500:
|
|
||||||
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
|
|
||||||
default:
|
|
||||||
if isCreditError(string(raw)) {
|
|
||||||
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("grok: %s %d %s", path, status, clip(raw, 160))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isBotChallenge reports whether a 403 body is an anti-bot interstitial rather
|
|
||||||
// than a real auth rejection: grok's own "anti-bot" marker or a Cloudflare
|
|
||||||
// challenge page ("Just a moment…" / cf-chl / challenge-platform).
|
|
||||||
func isBotChallenge(s string) bool {
|
|
||||||
s = strings.ToLower(s)
|
|
||||||
return strings.Contains(s, "anti-bot") ||
|
|
||||||
strings.Contains(s, "just a moment") ||
|
|
||||||
strings.Contains(s, "cf-chl") ||
|
|
||||||
strings.Contains(s, "challenge-platform") ||
|
|
||||||
strings.Contains(s, "cf_chl")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCreditError(s string) bool {
|
|
||||||
s = strings.ToLower(s)
|
|
||||||
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package grok
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha1"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var innerChunkRe = regexp.MustCompile(`static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
|
||||||
|
|
||||||
func hnew(ctx context.Context, url, token string) (*http.Request, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
|
||||||
http.HeaderOrderKey: {"accept", "accept-language", "user-agent", "cookie"},
|
|
||||||
}
|
|
||||||
return req, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGrokCacheChunks(t *testing.T) {
|
|
||||||
token := os.Getenv("DIAG_SSO")
|
|
||||||
if token == "" {
|
|
||||||
t.Skip("no DIAG_SSO")
|
|
||||||
}
|
|
||||||
dir := os.Getenv("DIAG_CACHE")
|
|
||||||
if dir == "" {
|
|
||||||
dir = `f:\ai-gateway\vivid-ai\backend\_chunks`
|
|
||||||
}
|
|
||||||
os.MkdirAll(dir, 0o755)
|
|
||||||
logf, _ := os.Create(filepath.Join(dir, "_log.txt"))
|
|
||||||
defer logf.Close()
|
|
||||||
lg := func(f string, a ...any) {
|
|
||||||
fmt.Fprintf(logf, f+"\n", a...)
|
|
||||||
logf.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
c := NewClient(os.Getenv("DIAG_PROXY"))
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3000*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
client, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hreq, _ := hnew(ctx, apiBase+"/", token)
|
|
||||||
hresp, err := client.Do(hreq)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(hresp.Body)
|
|
||||||
hresp.Body.Close()
|
|
||||||
html := string(body)
|
|
||||||
os.WriteFile(filepath.Join(dir, "_home.html"), body, 0o644)
|
|
||||||
|
|
||||||
seen := map[string]bool{}
|
|
||||||
queue := dedupe(chunkPathRe.FindAllString(html, -1))
|
|
||||||
lg("home chunk refs: %d", len(queue))
|
|
||||||
|
|
||||||
saved := 0
|
|
||||||
for len(queue) > 0 && saved < 3000 {
|
|
||||||
p := queue[0]
|
|
||||||
queue = queue[1:]
|
|
||||||
norm := p
|
|
||||||
if !strings.HasPrefix(norm, "/_next/") {
|
|
||||||
norm = "/_next/" + strings.TrimPrefix(norm, "/")
|
|
||||||
}
|
|
||||||
if seen[norm] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[norm] = true
|
|
||||||
src, err := fetchChunk(ctx, client, norm)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
saved++
|
|
||||||
base := norm[strings.LastIndex(norm, "/")+1:]
|
|
||||||
fn := fmt.Sprintf("%x_%s", sha1.Sum([]byte(norm)), base)
|
|
||||||
if len(fn) > 120 {
|
|
||||||
fn = fn[:120]
|
|
||||||
}
|
|
||||||
os.WriteFile(filepath.Join(dir, fn), []byte(src), 0o644)
|
|
||||||
for _, m := range innerChunkRe.FindAllString(src, -1) {
|
|
||||||
nn := "/_next/" + m
|
|
||||||
if !seen[nn] {
|
|
||||||
queue = append(queue, nn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if saved%50 == 0 {
|
|
||||||
lg("saved=%d queue=%d", saved, len(queue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lg("CRAWL DONE saved=%d queue_left=%d", saved, len(queue))
|
|
||||||
fmt.Printf("CRAWL DONE saved=%d\n", saved)
|
|
||||||
}
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
// 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.newDirectTLSClient()
|
|
||||||
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.apiGetP(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID, false)
|
|
||||||
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) {
|
|
||||||
return c.apiGetP(ctx, token, url, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiGetP picks the egress: polling runs direct (local IP).
|
|
||||||
func (c *Client) apiGetP(ctx context.Context, token, url string, useProxy bool) ([]byte, int, error) {
|
|
||||||
client, err := c.newTLSClientP(useProxy)
|
|
||||||
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) { return c.newTLSClientP(true) }
|
|
||||||
|
|
||||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
|
||||||
// polling and result download.
|
|
||||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
|
||||||
|
|
||||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
|
||||||
options := []tlsclient.HttpClientOption{
|
|
||||||
tlsclient.WithTimeoutSeconds(60),
|
|
||||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
|
||||||
}
|
|
||||||
if useProxy && 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
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package imagine
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GenerateImage runs the full Imagine.art pipeline: submit the txt2img job, poll
|
|
||||||
// the org objects feed until the batch finishes, then download the produced
|
|
||||||
// image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K).
|
|
||||||
// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs).
|
|
||||||
func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
cr, ok := parseCred(cred)
|
|
||||||
if !ok {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
userID := userIDFromToken(cr.Token)
|
|
||||||
|
|
||||||
metadata, _ := json.Marshal(map[string]any{
|
|
||||||
"placeholderUuid": uuid4(),
|
|
||||||
"promptWithoutManipulation": prompt,
|
|
||||||
"modeId": 0,
|
|
||||||
})
|
|
||||||
// parent_id MUST be a canvas node this account owns — the server rejects a
|
|
||||||
// foreign id ("user does not have access to parent asset") and silently
|
|
||||||
// orphans a parent-less generation (charged but never produced). It's supplied
|
|
||||||
// at import (credential.parentId).
|
|
||||||
fields := map[string]string{
|
|
||||||
"style_id": strconv.Itoa(styleID),
|
|
||||||
"aspect_ratio": aspectRatio,
|
|
||||||
"resolution": resolution,
|
|
||||||
"variation": "txt2img",
|
|
||||||
"prompt": prompt,
|
|
||||||
"is_enhance": "0",
|
|
||||||
"count": "1",
|
|
||||||
"clientVersion": "1",
|
|
||||||
"org_id": userID,
|
|
||||||
"use_plugin": "false",
|
|
||||||
"metadata": string(metadata),
|
|
||||||
}
|
|
||||||
if pid := strings.TrimSpace(cr.ParentID); pid != "" {
|
|
||||||
fields["parent_id"] = pid
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
w := multipart.NewWriter(&buf)
|
|
||||||
for k, v := range fields {
|
|
||||||
_ = w.WriteField(k, v)
|
|
||||||
}
|
|
||||||
_ = w.Close()
|
|
||||||
|
|
||||||
body, status, err := c.apiPost(ctx, cr.Token, apiBase+"/v1/image/generations/upload", w.FormDataContentType(), buf.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
|
|
||||||
}
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
if status == 402 {
|
|
||||||
return nil, nil, ErrQuotaExhausted
|
|
||||||
}
|
|
||||||
if status != 200 && status != 201 {
|
|
||||||
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
|
||||||
}
|
|
||||||
var jobs []struct {
|
|
||||||
BatchID string `json:"batchId"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].BatchID == "" {
|
|
||||||
return nil, nil, fmt.Errorf("%w: no batch id: %s", ErrTemporaryUpstream, clip(body, 200))
|
|
||||||
}
|
|
||||||
batchID := jobs[0].BatchID
|
|
||||||
|
|
||||||
imageURL, err := c.pollImage(ctx, cr.Token, userID, batchID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
meta := map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, meta, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, imageURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollImage polls the org objects feed until the entry for our batch finishes,
|
|
||||||
// then extracts its asset URL (image_url is a JSON-encoded array string).
|
|
||||||
func (c *Client) pollImage(ctx context.Context, token, userID, batchID string) (string, error) {
|
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
|
||||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
|
||||||
deadline := time.Now().Add(4 * time.Minute)
|
|
||||||
if dl, ok := ctx.Deadline(); ok {
|
|
||||||
deadline = dl.Add(-60 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := teamsBase + "/v1/org/" + userID + "/objects?batch=true&limit=50&service=image,chat-image"
|
|
||||||
for {
|
|
||||||
body, status, err := c.apiGetP(ctx, token, url, false)
|
|
||||||
if err == nil && status == 200 {
|
|
||||||
var resp struct {
|
|
||||||
Data []struct {
|
|
||||||
BatchID string `json:"batch_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Code int `json:"code"`
|
|
||||||
// Current shape: the produced asset lives at url.generation[0].
|
|
||||||
URL struct {
|
|
||||||
Generation []string `json:"generation"`
|
|
||||||
} `json:"url"`
|
|
||||||
ImageURL string `json:"image_url"` // legacy fallback
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &resp) == nil {
|
|
||||||
for _, o := range resp.Data {
|
|
||||||
if o.BatchID != batchID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
st := strings.ToLower(strings.TrimSpace(o.Status))
|
|
||||||
switch {
|
|
||||||
case st == "finished" || o.Code == 2:
|
|
||||||
if u := firstNonEmpty(o.URL.Generation); u != "" {
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
if u := firstImageURL(o.ImageURL); u != "" {
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
case st == "failed" || st == "error":
|
|
||||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if status == 401 || status == 403 {
|
|
||||||
return "", ErrAuth
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstNonEmpty returns the first non-blank string in a slice.
|
|
||||||
func firstNonEmpty(ss []string) string {
|
|
||||||
for _, s := range ss {
|
|
||||||
if strings.TrimSpace(s) != "" {
|
|
||||||
return strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstImageURL parses the image_url field — a JSON-encoded array of URLs — and
|
|
||||||
// returns the first one. Tolerates a bare string too.
|
|
||||||
func firstImageURL(raw string) string {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var urls []string
|
|
||||||
if json.Unmarshal([]byte(raw), &urls) == nil {
|
|
||||||
for _, u := range urls {
|
|
||||||
if strings.TrimSpace(u) != "" {
|
|
||||||
return strings.TrimSpace(u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(raw, "http") {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"referer": {webOrigin + "/"},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiPost issues a POST with a raw body + content-type, carrying the bearer token.
|
|
||||||
func (c *Client) apiPost(ctx context.Context, token, url, contentType string, body []byte) ([]byte, int, error) {
|
|
||||||
client, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"application/json, text/plain, */*"},
|
|
||||||
"authorization": {"Bearer " + token},
|
|
||||||
"content-type": {contentType},
|
|
||||||
"origin": {webOrigin},
|
|
||||||
"referer": {webOrigin + "/"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"accept", "authorization", "content-type", "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
|
|
||||||
}
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
// 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.newDirectTLSClient()
|
|
||||||
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.apiGetP(probeCtx, cookie, "/api/billing-data", false)
|
|
||||||
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.apiGetP(actCtx, cookie, "/app", false)
|
|
||||||
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) {
|
|
||||||
return c.apiGetP(ctx, cookie, path, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiGetP picks the egress: polling / asset resolution run direct (local IP).
|
|
||||||
func (c *Client) apiGetP(ctx context.Context, cookie, path string, useProxy bool) ([]byte, int, error) {
|
|
||||||
client, err := c.newTLSClientP(useProxy)
|
|
||||||
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) { return c.newTLSClientP(true) }
|
|
||||||
|
|
||||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
|
||||||
// reference-image upload, polling and result download.
|
|
||||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
|
||||||
|
|
||||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
|
||||||
options := []tlsclient.HttpClientOption{
|
|
||||||
tlsclient.WithTimeoutSeconds(60),
|
|
||||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
|
||||||
}
|
|
||||||
if useProxy && 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
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
package krea
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
genModel = "flux2-klein4b" // the single exposed Krea model
|
|
||||||
genEndpoint = "/api/jobs/v2/new/fluxKlein4b"
|
|
||||||
refStrength = 0.4
|
|
||||||
)
|
|
||||||
|
|
||||||
// ensureProject returns a flux project id for the account: the first existing
|
|
||||||
// project, or a freshly created one. Generation requires a project.
|
|
||||||
func (c *Client) ensureProject(ctx context.Context, cookie string) (string, error) {
|
|
||||||
body, status, err := c.apiGetP(ctx, cookie, "/api/flux-projects", false)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: list projects: %s", ErrTemporaryUpstream, err.Error())
|
|
||||||
}
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return "", ErrAuth
|
|
||||||
}
|
|
||||||
if status == 200 {
|
|
||||||
var projs []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &projs) == nil {
|
|
||||||
for _, p := range projs {
|
|
||||||
if strings.TrimSpace(p.ID) != "" {
|
|
||||||
return p.ID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No project on this account (e.g. brand-new). Try to create one; if that
|
|
||||||
// fails, fall back to generating WITHOUT a project (Krea assigns a default).
|
|
||||||
cb, cs, cerr := c.apiPostJSON(ctx, cookie, "/api/flux-projects", map[string]any{"title": "vivid"})
|
|
||||||
if cerr == nil && (cs == 200 || cs == 201) {
|
|
||||||
var pr struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(cb, &pr) == nil && pr.ID != "" {
|
|
||||||
return pr.ID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", nil // generate without an explicit project
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadImage uploads a reference image (i2i) and returns its app-uploads URL.
|
|
||||||
func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (string, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
w := multipart.NewWriter(&buf)
|
|
||||||
fw, err := w.CreateFormFile("file", "image.png")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if _, err := fw.Write(img); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_ = w.Close()
|
|
||||||
body, status, err := c.apiPostP(ctx, cookie, "/api/upload?", w.FormDataContentType(), buf.Bytes(), false)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: upload: %s", ErrTemporaryUpstream, err.Error())
|
|
||||||
}
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return "", ErrAuth
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return "", fmt.Errorf("%w: upload http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
|
||||||
}
|
|
||||||
var ur struct {
|
|
||||||
ImageURL string `json:"imageUrl"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &ur) != nil || ur.ImageURL == "" {
|
|
||||||
return "", fmt.Errorf("%w: no imageUrl", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return ur.ImageURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
|
|
||||||
// upload reference images, submit the job, poll until done, then resolve and
|
|
||||||
// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted.
|
|
||||||
func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
// Ensure the daily free balance is granted (load /app) before generating, so a
|
|
||||||
// not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and
|
|
||||||
// once-per-daily-reset — concurrent gens wait for the first activation, already
|
|
||||||
// activated ones skip straight through.
|
|
||||||
c.Activate(ctx, cookie)
|
|
||||||
|
|
||||||
projectID, err := c.ensureProject(ctx, cookie)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var styleImages []map[string]any
|
|
||||||
for _, img := range refImages {
|
|
||||||
if len(img) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
url, upErr := c.uploadImage(ctx, cookie, img)
|
|
||||||
if upErr != nil {
|
|
||||||
return nil, nil, upErr
|
|
||||||
}
|
|
||||||
styleImages = append(styleImages, map[string]any{"url": url, "strength": refStrength, "source": "upload"})
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]any{
|
|
||||||
"provider": genModel,
|
|
||||||
"prompt": prompt,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"strength": 1,
|
|
||||||
"steps": 28,
|
|
||||||
"guidance_scale_flux": 3.5,
|
|
||||||
"presetStyles": []any{},
|
|
||||||
"batchSize": 2,
|
|
||||||
"guidance": 3.5,
|
|
||||||
}
|
|
||||||
if projectID != "" {
|
|
||||||
payload["project"] = projectID
|
|
||||||
}
|
|
||||||
if len(styleImages) > 0 {
|
|
||||||
payload["styleImages"] = styleImages
|
|
||||||
}
|
|
||||||
payloadJSON, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
// Submit (multipart with a single "payload" field).
|
|
||||||
var buf bytes.Buffer
|
|
||||||
w := multipart.NewWriter(&buf)
|
|
||||||
_ = w.WriteField("payload", string(payloadJSON))
|
|
||||||
_ = w.Close()
|
|
||||||
body, status, err := c.apiPost(ctx, cookie, genEndpoint, w.FormDataContentType(), buf.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
|
|
||||||
}
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
if status == 402 || strings.Contains(string(body), "INSUFFICIENT_BALANCE") {
|
|
||||||
return nil, nil, ErrQuotaExhausted
|
|
||||||
}
|
|
||||||
if status != 200 && status != 201 {
|
|
||||||
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
|
||||||
}
|
|
||||||
var jobs []struct {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].JobID == "" {
|
|
||||||
return nil, nil, fmt.Errorf("%w: no job id: %s", ErrTemporaryUpstream, clip(body, 200))
|
|
||||||
}
|
|
||||||
// batchSize=2 returns two jobs; keep the SECOND image and discard the first
|
|
||||||
// (per spec). Fall back to the first if only one came back.
|
|
||||||
jobID := jobs[0].JobID
|
|
||||||
if len(jobs) >= 2 && jobs[1].JobID != "" {
|
|
||||||
jobID = jobs[1].JobID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll until terminal, then resolve the produced image.
|
|
||||||
imageURL, err := c.pollImage(ctx, cookie, jobID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
meta := map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, meta, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, imageURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollImage polls job-status until the job leaves the queue, then matches the
|
|
||||||
// produced asset by generation_job_id and returns its image URL.
|
|
||||||
func (c *Client) pollImage(ctx context.Context, cookie, jobID string) (string, error) {
|
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
|
||||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
|
||||||
deadline := time.Now().Add(4 * time.Minute)
|
|
||||||
if dl, ok := ctx.Deadline(); ok {
|
|
||||||
deadline = dl.Add(-60 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
body, status, err := c.apiGetP(ctx, cookie, "/api/job-status?id="+jobID, false)
|
|
||||||
if err == nil && status == 200 {
|
|
||||||
var js struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &js) == nil {
|
|
||||||
switch strings.ToLower(js.Status) {
|
|
||||||
case "complete", "completed", "succeeded", "success", "done", "finished":
|
|
||||||
if url, e := c.assetForJob(ctx, cookie, jobID); e == nil && url != "" {
|
|
||||||
return url, nil
|
|
||||||
}
|
|
||||||
case "failed", "error", "cancelled", "canceled":
|
|
||||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, js.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if status == 401 || status == 403 {
|
|
||||||
return "", ErrAuth
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assetForJob finds the generated asset produced by a job and returns its URL.
|
|
||||||
func (c *Client) assetForJob(ctx context.Context, cookie, jobID string) (string, error) {
|
|
||||||
body, status, err := c.apiGetP(ctx, cookie, "/api/assets?filter=generated&offset=0", false)
|
|
||||||
if err != nil || status != 200 {
|
|
||||||
return "", fmt.Errorf("assets http %d", status)
|
|
||||||
}
|
|
||||||
var assets []struct {
|
|
||||||
ImageURL string `json:"image_url"`
|
|
||||||
Metadata struct {
|
|
||||||
GenerationJobID string `json:"generation_job_id"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &assets) != nil {
|
|
||||||
return "", fmt.Errorf("assets non-json")
|
|
||||||
}
|
|
||||||
for _, a := range assets {
|
|
||||||
if a.Metadata.GenerationJobID == jobID && strings.TrimSpace(a.ImageURL) != "" {
|
|
||||||
return a.ImageURL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("asset not found yet")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"referer": {apiBase + "/"},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiPost issues a POST with a raw body + content-type, carrying the cookie.
|
|
||||||
func (c *Client) apiPost(ctx context.Context, cookie, path, contentType string, body []byte) ([]byte, int, error) {
|
|
||||||
return c.apiPostP(ctx, cookie, path, contentType, body, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiPostP picks the egress: reference-image upload runs direct (local IP), the
|
|
||||||
// generate submit uses the proxy.
|
|
||||||
func (c *Client) apiPostP(ctx context.Context, cookie, path, contentType string, body []byte, useProxy bool) ([]byte, int, error) {
|
|
||||||
client, err := c.newTLSClientP(useProxy)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, apiBase+path, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"content-type": {contentType},
|
|
||||||
"cookie": {cookie},
|
|
||||||
"origin": {apiBase},
|
|
||||||
"referer": {apiBase + "/"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"same-origin"},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"accept", "accept-language", "content-type", "cookie", "origin",
|
|
||||||
"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) apiPostJSON(ctx context.Context, cookie, path string, payload any) ([]byte, int, error) {
|
|
||||||
// project bootstrap runs on the local IP; only the generate submit uses the proxy.
|
|
||||||
b, _ := json.Marshal(payload)
|
|
||||||
return c.apiPostP(ctx, cookie, path, "application/json", b, false)
|
|
||||||
}
|
|
||||||
@@ -1,732 +0,0 @@
|
|||||||
// Package leonardo implements the Leonardo.ai (app.leonardo.ai) provider client.
|
|
||||||
// Unlike chatgpt/runway (whose JWT IS the stored credential), Leonardo's durable
|
|
||||||
// credential is the browser COOKIE (better-auth session): the bearer access token
|
|
||||||
// it mints lives only ~1h. So every call here takes the cookie and derives a
|
|
||||||
// fresh JWT on the fly via /api/auth/get-session — there is no long-lived token to
|
|
||||||
// store or a separate refresh profile to maintain. tls-client gives a Chrome
|
|
||||||
// JA3/JA4 fingerprint so the requests aren't flagged.
|
|
||||||
package leonardo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
"github.com/bogdanfinn/tls-client/profiles"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
appBase = "https://app.leonardo.ai"
|
|
||||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
|
||||||
schemaVersion = "1.255.2"
|
|
||||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
|
||||||
// sec-ch-ua must agree with userAgent's major version — a mismatch is itself a
|
|
||||||
// bot signal.
|
|
||||||
secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`
|
|
||||||
// get-session only answers with a token once better-auth's cookie cache has
|
|
||||||
// been refreshed by a cross-origin-cookie call; called cold it returns 200
|
|
||||||
// null, which looks exactly like a dead cookie. It also sits behind Vercel's
|
|
||||||
// checkpoint, which 429s machine requests from some exit IPs — so a failed
|
|
||||||
// attempt is retried, falling back to the proxy for a different exit IP.
|
|
||||||
getSessionAttempts = 10
|
|
||||||
getSessionRetryDelay = 2 * time.Second
|
|
||||||
// A warmed 200 null usually means the session is gone server-side, but the
|
|
||||||
// same answer also comes back when the checkpoint silently serves a session-
|
|
||||||
// less page to an exit IP — so retry it a few times (later attempts through
|
|
||||||
// the proxy) before calling the account dead. Fewer attempts than the 429
|
|
||||||
// budget: each one costs two requests and a truly dead cookie never recovers.
|
|
||||||
getSessionNullAttempts = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrAuth = errors.New("leonardo auth failed")
|
|
||||||
ErrQuotaExhausted = errors.New("leonardo quota exhausted")
|
|
||||||
ErrTemporaryUpstream = errors.New("leonardo upstream temporary error")
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
proxy string
|
|
||||||
// sessions caches the short-lived access token per cookie so we don't hit
|
|
||||||
// /api/auth/get-session on every call — Leonardo rate-limits that endpoint
|
|
||||||
// (429) hard, so re-using the ~1h JWT is essential.
|
|
||||||
mu sync.Mutex
|
|
||||||
sessions map[string]*Session
|
|
||||||
// rotated maps a stored cookie to the freshest value Leonardo handed back via
|
|
||||||
// Set-Cookie (better-auth rotates its session_data cookie cache). The service
|
|
||||||
// persists it; keeping it here means an unpersisted rotation still works for
|
|
||||||
// the rest of the process's life.
|
|
||||||
rotated map[string]string
|
|
||||||
// refreshing serialises get-session per cookie. Two concurrent refreshes hand
|
|
||||||
// Cognito the same refresh token twice and its reuse detection revokes the
|
|
||||||
// whole session — the account then answers 401 forever.
|
|
||||||
refreshing map[string]*sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(proxy string) *Client {
|
|
||||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}, refreshing: map[string]*sync.Mutex{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SetProxy(proxy string) {
|
|
||||||
c.proxy = strings.TrimSpace(proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLeonardoCookie reports whether a pasted credential is a Leonardo cookie: it
|
|
||||||
// carries the better-auth session cookie name. This is what disambiguates it from
|
|
||||||
// an Adobe cookie at import time.
|
|
||||||
func IsLeonardoCookie(value string) bool {
|
|
||||||
return strings.Contains(value, "__Secure-better-auth.session_token") ||
|
|
||||||
strings.Contains(value, "better-auth.session_data")
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasSessionData reports whether the cookie carries better-auth's session_data
|
|
||||||
// cache. Leonardo authenticates get-session off THAT cookie: session_token alone
|
|
||||||
// answers 200 null (no bearer), which looks exactly like a dead account — so a
|
|
||||||
// cookie without it must be rejected at import instead of dying later.
|
|
||||||
func HasSessionData(value string) bool {
|
|
||||||
return strings.Contains(value, "better-auth.session_data")
|
|
||||||
}
|
|
||||||
|
|
||||||
// RotatedCookie returns the freshest value for a stored cookie when Leonardo
|
|
||||||
// rotated its session_data cache, so the caller can persist it.
|
|
||||||
func (c *Client) RotatedCookie(cookie string) (string, bool) {
|
|
||||||
key := strings.TrimSpace(cookie)
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
fresh, ok := c.rotated[key]
|
|
||||||
return fresh, ok && fresh != key
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeCookies applies a response's Set-Cookie pairs onto a request cookie
|
|
||||||
// string, keeping the original order and appending new names.
|
|
||||||
func mergeCookies(cookie string, setCookies []string) string {
|
|
||||||
if len(setCookies) == 0 {
|
|
||||||
return cookie
|
|
||||||
}
|
|
||||||
updates := map[string]string{}
|
|
||||||
order := []string{}
|
|
||||||
for _, sc := range setCookies {
|
|
||||||
pair := strings.TrimSpace(strings.Split(sc, ";")[0])
|
|
||||||
name, value, ok := strings.Cut(pair, "=")
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if !ok || name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, seen := updates[name]; !seen {
|
|
||||||
order = append(order, name)
|
|
||||||
}
|
|
||||||
updates[name] = value
|
|
||||||
}
|
|
||||||
if len(updates) == 0 {
|
|
||||||
return cookie
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
used := map[string]bool{}
|
|
||||||
for _, part := range strings.Split(cookie, ";") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name, _, _ := strings.Cut(part, "=")
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if v, ok := updates[name]; ok {
|
|
||||||
used[name] = true
|
|
||||||
if v == "" { // a cleared cookie drops out
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, name+"="+v)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, part)
|
|
||||||
}
|
|
||||||
for _, name := range order {
|
|
||||||
if used[name] || updates[name] == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, name+"="+updates[name])
|
|
||||||
}
|
|
||||||
return strings.Join(out, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// keepsSession reports whether a merged cookie still carries BOTH components
|
|
||||||
// get-session needs: the session token and better-auth's session_data cache.
|
|
||||||
// A merge that loses either one (a Set-Cookie clearing a cache chunk) must be
|
|
||||||
// discarded — sending it would answer 200 null, i.e. look like a dead account.
|
|
||||||
func keepsSession(cookie string) bool {
|
|
||||||
return strings.Contains(cookie, "__Secure-better-auth.session_token") && HasSessionData(cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session is the result of /api/auth/get-session: the short-lived bearer plus the
|
|
||||||
// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed
|
|
||||||
// and the CDN image path) and the human-facing account fields.
|
|
||||||
type Session struct {
|
|
||||||
AccessToken string
|
|
||||||
CognitoSub string
|
|
||||||
UserID string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
ExpiresAt int64
|
|
||||||
// Cookie is the cookie that produced this session, with any Set-Cookie
|
|
||||||
// rotation applied — persist it so the account keeps authenticating.
|
|
||||||
Cookie string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSession exchanges the cookie for a fresh access token + account ids. Only a
|
|
||||||
// 401 or a 200 carrying no access token means the session is dead → ErrAuth;
|
|
||||||
// everything else (notably the 403/429 人机校验 page) is a temporary error.
|
|
||||||
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
|
|
||||||
cookie = strings.TrimSpace(cookie)
|
|
||||||
if cookie == "" {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
// Re-use a cached, still-valid access token (keep a 60s safety margin) instead
|
|
||||||
// of hitting the heavily rate-limited get-session endpoint again.
|
|
||||||
c.mu.Lock()
|
|
||||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return cs, nil
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
// Only one refresh per cookie at a time; the others wait and then re-use the
|
|
||||||
// token it minted.
|
|
||||||
gate := c.refreshGate(cookie)
|
|
||||||
gate.Lock()
|
|
||||||
defer gate.Unlock()
|
|
||||||
c.mu.Lock()
|
|
||||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
|
||||||
c.mu.Unlock()
|
|
||||||
return cs, nil
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
// Use the freshest known value (an earlier response may have rotated the
|
|
||||||
// better-auth cookie cache) rather than the possibly stale stored cookie.
|
|
||||||
send := cookie
|
|
||||||
c.mu.Lock()
|
|
||||||
if fresh, ok := c.rotated[cookie]; ok && fresh != "" {
|
|
||||||
send = fresh
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
var body []byte
|
|
||||||
var status int
|
|
||||||
var setCookies []string
|
|
||||||
var err error
|
|
||||||
var warmed bool
|
|
||||||
// 401 from cross-origin-cookie means Leonardo已经作废了这个 session(不是人机校验),
|
|
||||||
// 记下来好把日志写成"会话被吊销"而不是含糊的 get-session null。
|
|
||||||
warmStatus := 0
|
|
||||||
for attempt := 0; attempt < getSessionAttempts; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case <-time.After(getSessionRetryDelay):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The local IP normally passes the checkpoint; only once it doesn't is the
|
|
||||||
// proxy worth its latency (and its own share of 429s).
|
|
||||||
useProxy := attempt > 0 && c.proxy != ""
|
|
||||||
client, cerr := c.newTLSClientP(useProxy)
|
|
||||||
if cerr != nil {
|
|
||||||
err = cerr
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Warm up on the same connection: cross-origin-cookie hands back a fresh
|
|
||||||
// better-auth cookie cache (and CF_Access_Token). Without it get-session
|
|
||||||
// answers 200 null even for a perfectly healthy cookie.
|
|
||||||
warmed = false
|
|
||||||
warmCookies, wstatus, werr := c.warmSession(ctx, client, send)
|
|
||||||
warmStatus = wstatus
|
|
||||||
if werr == nil {
|
|
||||||
warmed = true
|
|
||||||
if merged := mergeCookies(send, warmCookies); merged != send && keepsSession(merged) {
|
|
||||||
send = merged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status, body, setCookies, err = c.fetchSession(ctx, client, send)
|
|
||||||
if err == nil && warmed && status != 429 && status != 403 {
|
|
||||||
if status == 200 && sessionAccessToken(body) == "" && attempt < getSessionNullAttempts-1 {
|
|
||||||
continue // retry a null session on another exit IP before giving up
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
|
||||||
}
|
|
||||||
// Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页
|
|
||||||
// also sends Set-Cookie (often CLEARING better-auth cookies), and persisting
|
|
||||||
// that would strip the session_data cache — after which get-session answers
|
|
||||||
// 200 null and a perfectly healthy account looks dead.
|
|
||||||
if status == 200 {
|
|
||||||
if merged := mergeCookies(send, setCookies); keepsSession(merged) {
|
|
||||||
send = merged
|
|
||||||
}
|
|
||||||
if send != cookie {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.rotated[cookie] = send
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if status == 401 {
|
|
||||||
return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160))
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
|
||||||
// 当成临时错误,否则健康的号会被误判死。
|
|
||||||
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
|
||||||
}
|
|
||||||
var raw struct {
|
|
||||||
Session struct {
|
|
||||||
AccessToken string `json:"accessToken"`
|
|
||||||
CognitoSub string `json:"cognitoSub"`
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
HasuraUserID string `json:"hasuraUserId"`
|
|
||||||
TokenExpiry int64 `json:"accessTokenExpiry"`
|
|
||||||
} `json:"session"`
|
|
||||||
User struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"user"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &raw); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(raw.Session.AccessToken) == "" {
|
|
||||||
if warmStatus == 401 {
|
|
||||||
// Leonardo 明确拒了这份 session_token:服务端已把会话吊销,重新导入 cookie 才能恢复。
|
|
||||||
return nil, fmt.Errorf("%w: session revoked (cross-origin-cookie 401)", ErrAuth)
|
|
||||||
}
|
|
||||||
if !warmed {
|
|
||||||
// A cold get-session (the cookie cache was never refreshed) answers null
|
|
||||||
// for healthy accounts too — temporary, never a reason to kill the account.
|
|
||||||
return nil, fmt.Errorf("%w: get-session 200 without accessToken (no warmup): %s", ErrTemporaryUpstream, clip(body, 160))
|
|
||||||
}
|
|
||||||
// No bearer despite 200 → the cookie no longer authenticates. Carry the body
|
|
||||||
// so the log says WHICH shape it was (null session vs a session without a
|
|
||||||
// token) instead of a bare "auth failed".
|
|
||||||
return nil, fmt.Errorf("%w: get-session 200 without accessToken: %s", ErrAuth, clip(body, 160))
|
|
||||||
}
|
|
||||||
uid := raw.Session.UserID
|
|
||||||
if uid == "" {
|
|
||||||
uid = raw.Session.HasuraUserID
|
|
||||||
}
|
|
||||||
if uid == "" {
|
|
||||||
uid = raw.User.ID
|
|
||||||
}
|
|
||||||
sess := &Session{
|
|
||||||
AccessToken: raw.Session.AccessToken,
|
|
||||||
CognitoSub: raw.Session.CognitoSub,
|
|
||||||
UserID: uid,
|
|
||||||
Email: strings.TrimSpace(raw.User.Email),
|
|
||||||
Name: strings.TrimSpace(raw.User.Name),
|
|
||||||
ExpiresAt: raw.Session.TokenExpiry,
|
|
||||||
Cookie: send,
|
|
||||||
}
|
|
||||||
if sess.ExpiresAt > time.Now().Unix() {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.sessions[cookie] = sess
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
return sess, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshGate returns the per-cookie lock that serialises get-session refreshes.
|
|
||||||
func (c *Client) refreshGate(cookie string) *sync.Mutex {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
gate, ok := c.refreshing[cookie]
|
|
||||||
if !ok {
|
|
||||||
gate = &sync.Mutex{}
|
|
||||||
c.refreshing[cookie] = gate
|
|
||||||
}
|
|
||||||
return gate
|
|
||||||
}
|
|
||||||
|
|
||||||
// warmSession calls cross-origin-cookie, whose response refreshes better-auth's
|
|
||||||
// cookie cache and CF_Access_Token. It returns the Set-Cookie headers; a
|
|
||||||
// checkpoint answer (403/429) is an error, since get-session would then be cold.
|
|
||||||
func (c *Client) warmSession(ctx context.Context, client tlsclient.HttpClient, cookie string) ([]string, int, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/cross-origin-cookie", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = sessionHeader(cookie)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 429 {
|
|
||||||
return nil, resp.StatusCode, fmt.Errorf("cross-origin-cookie http %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return resp.Header["Set-Cookie"], resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchSession performs one get-session call and returns its status, body and
|
|
||||||
// Set-Cookie headers.
|
|
||||||
func (c *Client) fetchSession(ctx context.Context, client tlsclient.HttpClient, cookie string) (int, []byte, []string, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = sessionHeader(cookie)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return resp.StatusCode, body, resp.Header["Set-Cookie"], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionAccessToken pulls the bearer out of a get-session body; "" means the
|
|
||||||
// answer carried none (a null session, or a session without a token).
|
|
||||||
func sessionAccessToken(body []byte) string {
|
|
||||||
var raw struct {
|
|
||||||
Session struct {
|
|
||||||
AccessToken string `json:"accessToken"`
|
|
||||||
} `json:"session"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &raw); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(raw.Session.AccessToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionHeader is the auth endpoints' request shape, copied from a real
|
|
||||||
// browser's call (HAR): a same-origin GET carries NO origin header and DOES
|
|
||||||
// carry the ua client hints + priority — sending origin while omitting the
|
|
||||||
// hints is exactly the shape Vercel's checkpoint 429s.
|
|
||||||
func sessionHeader(cookie string) http.Header {
|
|
||||||
return http.Header{
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"cache-control": {"no-cache"},
|
|
||||||
"cookie": {cookie},
|
|
||||||
"pragma": {"no-cache"},
|
|
||||||
"priority": {"u=1, i"},
|
|
||||||
"referer": {appBase + "/"},
|
|
||||||
"sec-ch-ua": {secChUA},
|
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
|
||||||
"sec-ch-ua-platform": {`"Windows"`},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"same-origin"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"accept", "accept-language", "cache-control", "cookie", "pragma",
|
|
||||||
"priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile",
|
|
||||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode",
|
|
||||||
"sec-fetch-site", "user-agent",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// session returns the cookie's access token, optionally forcing a fresh mint
|
|
||||||
// (dropping the cache) — used when the upstream rejected the current bearer.
|
|
||||||
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
|
|
||||||
if force {
|
|
||||||
c.mu.Lock()
|
|
||||||
delete(c.sessions, strings.TrimSpace(cookie))
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
return c.GetSession(ctx, cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProbeSession force-mints a session from the cookie, bypassing the cached
|
|
||||||
// bearer. Callers use it to double-check an auth failure before killing an
|
|
||||||
// account: a rejected bearer (rotation race / expired token) still yields a
|
|
||||||
// working cookie here, only a genuinely dead cookie returns ErrAuth.
|
|
||||||
func (c *Client) ProbeSession(ctx context.Context, cookie string) (*Session, error) {
|
|
||||||
return c.session(ctx, cookie, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives
|
|
||||||
// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted
|
|
||||||
// from the cookie and the call retried once. Only a cookie that itself stops
|
|
||||||
// authenticating yields ErrAuth — an upstream bearer rejection stays temporary so
|
|
||||||
// the account is never killed for it.
|
|
||||||
func (c *Client) callGraphQL(ctx context.Context, cookie string, payload []byte, useProxy bool, label string) ([]byte, error) {
|
|
||||||
var lastStatus int
|
|
||||||
var lastBody []byte
|
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
|
||||||
sess, err := c.session(ctx, cookie, attempt > 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, useProxy)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s: %s", ErrTemporaryUpstream, label, err.Error())
|
|
||||||
}
|
|
||||||
lastStatus, lastBody = status, body
|
|
||||||
stale := status == 401 || status == 403
|
|
||||||
var gqlErr error
|
|
||||||
if !stale && status == 200 {
|
|
||||||
gqlErr = graphqlError(body)
|
|
||||||
stale = errors.Is(gqlErr, ErrAuth)
|
|
||||||
}
|
|
||||||
if stale {
|
|
||||||
if attempt == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return nil, fmt.Errorf("%w: %s http %d: %s", ErrTemporaryUpstream, label, status, clip(body, 160))
|
|
||||||
}
|
|
||||||
if gqlErr != nil {
|
|
||||||
return nil, gqlErr
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("%w: %s rejected a freshly minted bearer (http %d): %s",
|
|
||||||
ErrTemporaryUpstream, label, lastStatus, clip(lastBody, 160))
|
|
||||||
}
|
|
||||||
|
|
||||||
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
|
|
||||||
user_details(where: {cognitoId: {_eq: $sub}}) {
|
|
||||||
id
|
|
||||||
plan
|
|
||||||
subscriptionTokens
|
|
||||||
paidTokens
|
|
||||||
rolloverTokens
|
|
||||||
tokenRenewalDate
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// FetchCreditsBalance derives a JWT from the cookie then reads the account's image
|
|
||||||
// token balance. Returns a normalized map mirroring the other providers so the
|
|
||||||
// TokenService quota plumbing is uniform. remaining = subscription+paid+rollover
|
|
||||||
// (the spendable image tokens); available_until carries the daily renewal time so
|
|
||||||
// the maintenance sweep can auto-recover a 限额 account.
|
|
||||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
|
||||||
sess, err := c.GetSession(ctx, cookie)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, ErrAuth) {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
return unknownBalance(err.Error()), nil
|
|
||||||
}
|
|
||||||
if sess.CognitoSub == "" {
|
|
||||||
return unknownBalance("no cognitoSub"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, _ := json.Marshal(map[string]any{
|
|
||||||
"operationName": "GetUserTokensFromSub",
|
|
||||||
"variables": map[string]any{"sub": sess.CognitoSub},
|
|
||||||
"query": qGetTokens,
|
|
||||||
})
|
|
||||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "credits")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, ErrAuth) {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
return unknownBalance(err.Error()), nil
|
|
||||||
}
|
|
||||||
var result struct {
|
|
||||||
Data struct {
|
|
||||||
UserDetails []struct {
|
|
||||||
Plan string `json:"plan"`
|
|
||||||
SubscriptionTokens int `json:"subscriptionTokens"`
|
|
||||||
PaidTokens int `json:"paidTokens"`
|
|
||||||
RolloverTokens int `json:"rolloverTokens"`
|
|
||||||
TokenRenewalDate string `json:"tokenRenewalDate"`
|
|
||||||
} `json:"user_details"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return unknownBalance("non-json"), nil
|
|
||||||
}
|
|
||||||
if len(result.Data.UserDetails) == 0 {
|
|
||||||
return unknownBalance("no user_details"), nil
|
|
||||||
}
|
|
||||||
ud := result.Data.UserDetails[0]
|
|
||||||
remaining := ud.SubscriptionTokens + ud.PaidTokens + ud.RolloverTokens
|
|
||||||
return map[string]any{
|
|
||||||
"remaining": remaining,
|
|
||||||
"used": nil,
|
|
||||||
"total": nil,
|
|
||||||
"unknown": false,
|
|
||||||
"error": nil,
|
|
||||||
"plan": ud.Plan,
|
|
||||||
"available_until": strings.TrimSpace(ud.TokenRenewalDate),
|
|
||||||
"email": emptyStringNil(sess.Email),
|
|
||||||
"display_name": emptyStringNil(sess.Name),
|
|
||||||
"user_id": emptyStringNil(sess.UserID),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit
|
|
||||||
// uses the proxy; reference-image upload and polling run direct (local IP).
|
|
||||||
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
|
|
||||||
client, err := c.newTLSClientP(useProxy)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, graphqlURL, bytes.NewReader(payload))
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"content-type": {"application/json"},
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"accept-language": {"en-US,en;q=0.9"},
|
|
||||||
"origin": {appBase},
|
|
||||||
"priority": {"u=1, i"},
|
|
||||||
"referer": {appBase + "/"},
|
|
||||||
"sec-ch-ua": {secChUA},
|
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
|
||||||
"sec-ch-ua-platform": {`"Windows"`},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"authorization": {"Bearer " + accessToken},
|
|
||||||
"x-leo-schema-version": {schemaVersion},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"same-site"},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"content-type", "accept", "accept-language", "origin", "priority",
|
|
||||||
"referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
|
||||||
"user-agent", "authorization", "x-leo-schema-version",
|
|
||||||
"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()
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, resp.StatusCode, err
|
|
||||||
}
|
|
||||||
return body, resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func unknownBalance(reason string) map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"remaining": nil,
|
|
||||||
"used": nil,
|
|
||||||
"total": nil,
|
|
||||||
"unknown": true,
|
|
||||||
"error": reason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
|
||||||
|
|
||||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
|
||||||
// reference-image upload, polling and result download.
|
|
||||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
|
||||||
|
|
||||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
|
||||||
// Match the fingerprint proven to work against Leonardo's Cloudflare edge:
|
|
||||||
// Chrome_120, fixed extension order. A randomized JA3 (Chrome_133 +
|
|
||||||
// WithRandomTLSExtensionOrder) gets flagged and 429'd at get-session.
|
|
||||||
options := []tlsclient.HttpClientOption{
|
|
||||||
tlsclient.WithTimeoutSeconds(60),
|
|
||||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
|
||||||
}
|
|
||||||
if useProxy && c.proxy != "" {
|
|
||||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
|
||||||
}
|
|
||||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadImage fetches a generated image (cdn.leonardo.ai) and returns the bytes.
|
|
||||||
func (c *Client) downloadImage(ctx context.Context, imageURL string) ([]byte, error) {
|
|
||||||
if _, err := url.Parse(imageURL); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, imageURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"referer": {appBase + "/"},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 int64:
|
|
||||||
return int(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 {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if 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
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
package leonardo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"mime/multipart"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// defaultStyleID is the "Dynamic" style applied when the caller doesn't specify
|
|
||||||
// one — Leonardo's Generate mutation expects a style_ids entry.
|
|
||||||
const defaultStyleID = "111dc692-d470-4eec-b791-3475abac4c46"
|
|
||||||
|
|
||||||
const mGenerate = `mutation Generate($request: CreateGenerationRequest!) {
|
|
||||||
generate(request: $request) {
|
|
||||||
apiCreditCost
|
|
||||||
generationId
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// qGenerationImages polls one generation's status AND its produced images in a
|
|
||||||
// single round-trip (where: id _in [genId]).
|
|
||||||
const qGenerationImages = `query GenerationImages($where: generations_bool_exp = {}) {
|
|
||||||
generations(where: $where) {
|
|
||||||
id
|
|
||||||
status
|
|
||||||
generated_images {
|
|
||||||
id
|
|
||||||
url
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!) {
|
|
||||||
uploadImage(arg1: $uploadImageInput) {
|
|
||||||
uploadId
|
|
||||||
url
|
|
||||||
fields
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
const mUploadInitImage = `mutation UploadInitImage($arg1: InitImageUploadInput!) {
|
|
||||||
uploadInitImage(arg1: $arg1) {
|
|
||||||
id
|
|
||||||
url
|
|
||||||
fields
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// initImageExtension narrows a sniffed extension to what uploadInitImage accepts
|
|
||||||
// (png / jpg / jpeg / webp); anything else is sent as png.
|
|
||||||
func initImageExtension(extension string) string {
|
|
||||||
switch strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".") {
|
|
||||||
case "jpg":
|
|
||||||
return "jpg"
|
|
||||||
case "jpeg":
|
|
||||||
return "jpeg"
|
|
||||||
case "webp":
|
|
||||||
return "webp"
|
|
||||||
default:
|
|
||||||
return "png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadInitImage uploads a reference image and returns the init image id to put
|
|
||||||
// in a Generate request's image_reference guidance. It has to go through
|
|
||||||
// uploadInitImage (permanent init-image bucket): the uploadImage mutation only
|
|
||||||
// hands out temporary-bucket ids, which the generation service can't resolve.
|
|
||||||
func (c *Client) uploadInitImage(ctx context.Context, cookie, extension string, img []byte) (string, error) {
|
|
||||||
extension = initImageExtension(extension)
|
|
||||||
payload, _ := json.Marshal(map[string]any{
|
|
||||||
"operationName": "UploadInitImage",
|
|
||||||
"query": mUploadInitImage,
|
|
||||||
"variables": map[string]any{"arg1": map[string]any{"extension": extension}},
|
|
||||||
})
|
|
||||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init-image")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var ur struct {
|
|
||||||
Data struct {
|
|
||||||
UploadInitImage struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Fields string `json:"fields"`
|
|
||||||
} `json:"uploadInitImage"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &ur); err != nil {
|
|
||||||
return "", fmt.Errorf("%w: upload-init-image non-json", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
up := ur.Data.UploadInitImage
|
|
||||||
if up.ID == "" || up.URL == "" {
|
|
||||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, img); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return up.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// putPresigned performs the presigned S3 POST: all policy fields first, the file
|
|
||||||
// part LAST.
|
|
||||||
func (c *Client) putPresigned(ctx context.Context, url, fieldsJSON, filename string, asset []byte) error {
|
|
||||||
var fields map[string]string
|
|
||||||
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
|
|
||||||
return fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
w := multipart.NewWriter(&buf)
|
|
||||||
for k, v := range fields {
|
|
||||||
_ = w.WriteField(k, v)
|
|
||||||
}
|
|
||||||
fw, err := w.CreateFormFile("file", filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := fw.Write(asset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = w.Close()
|
|
||||||
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, &buf)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = http.Header{
|
|
||||||
"content-type": {w.FormDataContentType()},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
"origin": {appBase},
|
|
||||||
"referer": {appBase + "/"},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error())
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 {
|
|
||||||
return fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadAsset uploads one reference asset (extension png / mp3 / mp4 …) through
|
|
||||||
// the same UploadImage presigned-S3 flow images use, and returns its upload id.
|
|
||||||
func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asset []byte) (string, error) {
|
|
||||||
extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
|
||||||
if extension == "" {
|
|
||||||
extension = "png"
|
|
||||||
}
|
|
||||||
payload, _ := json.Marshal(map[string]any{
|
|
||||||
"operationName": "UploadImage",
|
|
||||||
"query": mUploadImage,
|
|
||||||
// originalFilename is mandatory for audio uploads and harmless otherwise.
|
|
||||||
"variables": map[string]any{"uploadImageInput": map[string]any{
|
|
||||||
"uploadType": "INIT",
|
|
||||||
"extension": extension,
|
|
||||||
"originalFilename": "asset." + extension,
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var ur struct {
|
|
||||||
Data struct {
|
|
||||||
UploadImage struct {
|
|
||||||
UploadID string `json:"uploadId"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Fields string `json:"fields"`
|
|
||||||
} `json:"uploadImage"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &ur); err != nil {
|
|
||||||
return "", fmt.Errorf("%w: upload-init non-json", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
up := ur.Data.UploadImage
|
|
||||||
if up.UploadID == "" || up.URL == "" {
|
|
||||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, asset); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return up.UploadID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateImage runs the full Leonardo image pipeline against one account cookie:
|
|
||||||
// mint a JWT, (for image-to-image) upload each reference image, submit the
|
|
||||||
// Generate mutation, poll until COMPLETE, then download the first produced image.
|
|
||||||
// Returns the image bytes, an info map, and a classified error.
|
|
||||||
func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
sess, err := c.GetSession(ctx, cookie)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if len(styleIDs) == 0 {
|
|
||||||
styleIDs = []string{defaultStyleID}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model) == "" {
|
|
||||||
model = "seedream-4.5"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image-to-image: upload each reference and collect its guidance entry.
|
|
||||||
var imageRefs []map[string]any
|
|
||||||
for _, img := range refImages {
|
|
||||||
if len(img) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
|
||||||
if upErr != nil {
|
|
||||||
return nil, nil, upErr
|
|
||||||
}
|
|
||||||
imageRefs = append(imageRefs, map[string]any{
|
|
||||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
|
||||||
"strength": "MID",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
promptEnhance := "AUTO"
|
|
||||||
parameters := map[string]any{
|
|
||||||
"height": height,
|
|
||||||
"width": width,
|
|
||||||
"prompt_enhance": promptEnhance,
|
|
||||||
"quantity": 1,
|
|
||||||
"style_ids": styleIDs,
|
|
||||||
"prompt": prompt,
|
|
||||||
}
|
|
||||||
if len(imageRefs) > 0 {
|
|
||||||
// Preserve the reference when image-guided (matches the web app).
|
|
||||||
parameters["prompt_enhance"] = "OFF"
|
|
||||||
parameters["guidances"] = map[string]any{"image_reference": imageRefs}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. submit
|
|
||||||
genReq := map[string]any{
|
|
||||||
"operationName": "Generate",
|
|
||||||
"query": mGenerate,
|
|
||||||
"variables": map[string]any{
|
|
||||||
"request": map[string]any{
|
|
||||||
"model": model,
|
|
||||||
"public": true,
|
|
||||||
"parameters": parameters,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
payload, _ := json.Marshal(genReq)
|
|
||||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate")
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
var genResp struct {
|
|
||||||
Data struct {
|
|
||||||
Generate struct {
|
|
||||||
GenerationID string `json:"generationId"`
|
|
||||||
} `json:"generate"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &genResp); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
|
|
||||||
if genID == "" {
|
|
||||||
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. poll until COMPLETE, then read the image url.
|
|
||||||
imageURL, err := c.pollImage(ctx, cookie, genID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info := map[string]any{
|
|
||||||
"generation_id": genID,
|
|
||||||
"image_url": imageURL,
|
|
||||||
"user_id": sess.UserID,
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, info, nil
|
|
||||||
}
|
|
||||||
// 3. download bytes
|
|
||||||
data, err := c.downloadImage(ctx, imageURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollImage polls one generation until it reports COMPLETE (returning the first
|
|
||||||
// image url) or FAILED (error). Honors ctx cancellation / deadline.
|
|
||||||
func (c *Client) pollImage(ctx context.Context, cookie, genID string) (string, error) {
|
|
||||||
payload, _ := json.Marshal(map[string]any{
|
|
||||||
"operationName": "GenerationImages",
|
|
||||||
"query": qGenerationImages,
|
|
||||||
"variables": map[string]any{
|
|
||||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
|
||||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
|
||||||
// ctx already bounds the wait, so a stuck job still can't hang indefinitely.
|
|
||||||
deadline := time.Now().Add(5 * time.Minute)
|
|
||||||
if dl, ok := ctx.Deadline(); ok {
|
|
||||||
deadline = dl.Add(-60 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll")
|
|
||||||
if errors.Is(err, ErrAuth) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
// 其它错误(含上游临时抖动)不中断轮询,等 deadline 再判超时。
|
|
||||||
if err == nil {
|
|
||||||
var pr struct {
|
|
||||||
Data struct {
|
|
||||||
Generations []struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
GeneratedImages []struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"generated_images"`
|
|
||||||
} `json:"generations"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
|
|
||||||
g := pr.Data.Generations[0]
|
|
||||||
switch strings.ToUpper(g.Status) {
|
|
||||||
case "COMPLETE":
|
|
||||||
for _, img := range g.GeneratedImages {
|
|
||||||
if u := strings.TrimSpace(img.URL); u != "" {
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("%w: complete but no image url", ErrTemporaryUpstream)
|
|
||||||
case "FAILED":
|
|
||||||
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// graphqlError inspects a GraphQL response body for an "errors" array and maps the
|
|
||||||
// first message to a classified sentinel (auth / quota / temporary). Returns nil
|
|
||||||
// when there are no errors.
|
|
||||||
func graphqlError(body []byte) error {
|
|
||||||
var env struct {
|
|
||||||
Errors []struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Extensions struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Details struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"details"`
|
|
||||||
} `json:"extensions"`
|
|
||||||
} `json:"errors"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &env); err != nil || len(env.Errors) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
msg := strings.TrimSpace(env.Errors[0].Message)
|
|
||||||
// The generic "An error occurred." hides the real reason in extensions.
|
|
||||||
if detail := strings.TrimSpace(env.Errors[0].Extensions.Details.Message); detail != "" && detail != msg {
|
|
||||||
msg = msg + " (" + detail + ")"
|
|
||||||
}
|
|
||||||
low := strings.ToLower(msg)
|
|
||||||
switch {
|
|
||||||
case strings.Contains(low, "unauthor") || strings.Contains(low, "jwt") || strings.Contains(low, "token is") || strings.Contains(low, "forbidden"):
|
|
||||||
return ErrAuth
|
|
||||||
case strings.Contains(low, "token") || strings.Contains(low, "credit") || strings.Contains(low, "quota") || strings.Contains(low, "insufficient") || strings.Contains(low, "not enough"):
|
|
||||||
return ErrQuotaExhausted
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("leonardo: %s", clip([]byte(msg), 200))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
package leonardo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// qGenerationVideos polls one video generation's status AND its produced clip in
|
|
||||||
// a single round-trip (motionMP4URL carries the mp4).
|
|
||||||
const qGenerationVideos = `query GenerationVideos($where: generations_bool_exp = {}) {
|
|
||||||
generations(where: $where) {
|
|
||||||
id
|
|
||||||
status
|
|
||||||
generated_images {
|
|
||||||
id
|
|
||||||
motionMP4URL
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
// VideoAssets are the decoded reference assets a video request can carry:
|
|
||||||
// image references (strength MID), one audio track and video references. The
|
|
||||||
// caller enforces the per-type caps; durations are derived from the bytes here
|
|
||||||
// because Leonardo requires them for audio/video guidances.
|
|
||||||
type VideoAssets struct {
|
|
||||||
Images [][]byte
|
|
||||||
Audios [][]byte
|
|
||||||
Videos [][]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateVideo runs the Leonardo video pipeline (seedance-2.0 / -fast, hailuo) against
|
|
||||||
// one account cookie: upload every reference asset, submit the Generate mutation
|
|
||||||
// as a PRIVATE generation, poll until COMPLETE, then optionally download the mp4.
|
|
||||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string, width, height, durationSeconds int, refs VideoAssets, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
sess, err := c.GetSession(ctx, cookie)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model) == "" {
|
|
||||||
model = "seedance-2.0-fast"
|
|
||||||
}
|
|
||||||
|
|
||||||
guidances := map[string]any{}
|
|
||||||
var imageRefs []map[string]any
|
|
||||||
for _, img := range refs.Images {
|
|
||||||
if len(img) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
|
||||||
if upErr != nil {
|
|
||||||
return nil, nil, upErr
|
|
||||||
}
|
|
||||||
imageRefs = append(imageRefs, map[string]any{
|
|
||||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
|
||||||
"strength": "MID",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(imageRefs) > 0 {
|
|
||||||
guidances["image_reference"] = imageRefs
|
|
||||||
}
|
|
||||||
var videoRefs []map[string]any
|
|
||||||
for _, vid := range refs.Videos {
|
|
||||||
if len(vid) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(vid, "mp4"), vid)
|
|
||||||
if upErr != nil {
|
|
||||||
return nil, nil, upErr
|
|
||||||
}
|
|
||||||
video := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
|
||||||
if secs := MediaDurationSeconds(vid); secs > 0 {
|
|
||||||
video["duration"] = secs
|
|
||||||
}
|
|
||||||
videoRefs = append(videoRefs, map[string]any{"video": video})
|
|
||||||
}
|
|
||||||
if len(videoRefs) > 0 {
|
|
||||||
guidances["video_reference_base"] = videoRefs
|
|
||||||
}
|
|
||||||
// Leonardo rejects an audio reference that isn't paired with an image or
|
|
||||||
// video reference (audio_reference_only_compatible_with_image_or_video_reference).
|
|
||||||
var audioRefs []map[string]any
|
|
||||||
for _, aud := range refs.Audios {
|
|
||||||
if len(aud) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(imageRefs) == 0 && len(videoRefs) == 0 {
|
|
||||||
return nil, nil, errors.New("leonardo: audio reference requires an image or video reference")
|
|
||||||
}
|
|
||||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(aud, "mp3"), aud)
|
|
||||||
if upErr != nil {
|
|
||||||
return nil, nil, upErr
|
|
||||||
}
|
|
||||||
audio := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
|
||||||
if secs := MediaDurationSeconds(aud); secs > 0 {
|
|
||||||
audio["duration"] = secs
|
|
||||||
}
|
|
||||||
audioRefs = append(audioRefs, map[string]any{"audio": audio})
|
|
||||||
}
|
|
||||||
if len(audioRefs) > 0 {
|
|
||||||
guidances["audio_reference"] = audioRefs
|
|
||||||
}
|
|
||||||
|
|
||||||
parameters := map[string]any{
|
|
||||||
"height": height,
|
|
||||||
"width": width,
|
|
||||||
"duration": durationSeconds,
|
|
||||||
"motion_has_audio": true,
|
|
||||||
"quantity": 1,
|
|
||||||
"prompt": prompt,
|
|
||||||
"guidances": guidances,
|
|
||||||
}
|
|
||||||
// seedance 走随机种子;hailuo 的生成页 seedEnabled=false,请求里不带 seed。
|
|
||||||
if !strings.HasPrefix(model, "hailuo") {
|
|
||||||
parameters["seed"] = -1
|
|
||||||
}
|
|
||||||
genReq := map[string]any{
|
|
||||||
"operationName": "Generate",
|
|
||||||
"query": mGenerate,
|
|
||||||
"variables": map[string]any{
|
|
||||||
"request": map[string]any{
|
|
||||||
"model": model,
|
|
||||||
// 私有生成:不进公开 feed。
|
|
||||||
"public": false,
|
|
||||||
"parameters": parameters,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
payload, _ := json.Marshal(genReq)
|
|
||||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate-video")
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
var genResp struct {
|
|
||||||
Data struct {
|
|
||||||
Generate struct {
|
|
||||||
GenerationID string `json:"generationId"`
|
|
||||||
} `json:"generate"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &genResp); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
|
|
||||||
if genID == "" {
|
|
||||||
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
|
|
||||||
}
|
|
||||||
|
|
||||||
videoURL, err := c.pollVideo(ctx, cookie, genID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
info := map[string]any{
|
|
||||||
"generation_id": genID,
|
|
||||||
"video_url": videoURL,
|
|
||||||
"user_id": sess.UserID,
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, info, nil
|
|
||||||
}
|
|
||||||
data, err := c.downloadImage(ctx, videoURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollVideo polls one generation until COMPLETE (returning the mp4 url) or
|
|
||||||
// FAILED. Temporary upstream hiccups don't abort the wait — only ctx/deadline do.
|
|
||||||
func (c *Client) pollVideo(ctx context.Context, cookie, genID string) (string, error) {
|
|
||||||
payload, _ := json.Marshal(map[string]any{
|
|
||||||
"operationName": "GenerationVideos",
|
|
||||||
"query": qGenerationVideos,
|
|
||||||
"variables": map[string]any{
|
|
||||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
deadline := time.Now().Add(10 * time.Minute)
|
|
||||||
if dl, ok := ctx.Deadline(); ok {
|
|
||||||
deadline = dl.Add(-60 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll-video")
|
|
||||||
if errors.Is(err, ErrAuth) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
var pr struct {
|
|
||||||
Data struct {
|
|
||||||
Generations []struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
GeneratedImages []struct {
|
|
||||||
MotionMP4URL string `json:"motionMP4URL"`
|
|
||||||
} `json:"generated_images"`
|
|
||||||
} `json:"generations"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
|
|
||||||
g := pr.Data.Generations[0]
|
|
||||||
switch strings.ToUpper(g.Status) {
|
|
||||||
case "COMPLETE":
|
|
||||||
for _, img := range g.GeneratedImages {
|
|
||||||
if u := strings.TrimSpace(img.MotionMP4URL); u != "" {
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("%w: complete but no video url", ErrTemporaryUpstream)
|
|
||||||
case "FAILED":
|
|
||||||
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assetExtension sniffs the upload extension Leonardo expects for a reference
|
|
||||||
// asset; fallback is used when the bytes aren't recognized.
|
|
||||||
func assetExtension(data []byte, fallback string) string {
|
|
||||||
n := len(data)
|
|
||||||
switch {
|
|
||||||
case n >= 8 && string(data[1:4]) == "PNG":
|
|
||||||
return "png"
|
|
||||||
case n >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
|
||||||
return "jpg"
|
|
||||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
|
||||||
return "webp"
|
|
||||||
case n >= 12 && string(data[4:8]) == "ftyp":
|
|
||||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "qt") {
|
|
||||||
return "mov"
|
|
||||||
}
|
|
||||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "M4A") {
|
|
||||||
return "m4a"
|
|
||||||
}
|
|
||||||
return "mp4"
|
|
||||||
case n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3:
|
|
||||||
return "webm"
|
|
||||||
case n >= 3 && string(data[0:3]) == "ID3":
|
|
||||||
return "mp3"
|
|
||||||
case n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0:
|
|
||||||
return "mp3"
|
|
||||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE":
|
|
||||||
return "wav"
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// MediaDurationSeconds returns a media asset's duration in seconds (0 when it
|
|
||||||
// can't be determined). Leonardo's audio_reference / video_reference_base
|
|
||||||
// guidances carry the duration, and the caller validates video lengths with it,
|
|
||||||
// so it's derived from the uploaded bytes: mp4/mov via the mvhd box, wav via the
|
|
||||||
// fmt byte rate, mp3 from the first frame's bitrate.
|
|
||||||
func MediaDurationSeconds(data []byte) float64 {
|
|
||||||
if secs := mp4Duration(data); secs > 0 {
|
|
||||||
return secs
|
|
||||||
}
|
|
||||||
if secs := wavDuration(data); secs > 0 {
|
|
||||||
return secs
|
|
||||||
}
|
|
||||||
return mp3Duration(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mp4Duration walks the ISOBMFF box tree to moov/mvhd and reads timescale+duration.
|
|
||||||
func mp4Duration(data []byte) float64 {
|
|
||||||
if len(data) < 12 || string(data[4:8]) != "ftyp" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
moov := findBox(data, "moov")
|
|
||||||
if moov == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
mvhd := findBox(moov, "mvhd")
|
|
||||||
if len(mvhd) < 20 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
version := mvhd[0]
|
|
||||||
if version == 1 {
|
|
||||||
if len(mvhd) < 32 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
timescale := binary.BigEndian.Uint32(mvhd[20:24])
|
|
||||||
duration := binary.BigEndian.Uint64(mvhd[24:32])
|
|
||||||
if timescale == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return float64(duration) / float64(timescale)
|
|
||||||
}
|
|
||||||
timescale := binary.BigEndian.Uint32(mvhd[12:16])
|
|
||||||
duration := binary.BigEndian.Uint32(mvhd[16:20])
|
|
||||||
if timescale == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return float64(duration) / float64(timescale)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findBox returns the payload of the first box named typ among data's boxes
|
|
||||||
// (callers descend one level at a time by passing a parent's payload back in).
|
|
||||||
func findBox(data []byte, typ string) []byte {
|
|
||||||
for off := 0; off+8 <= len(data); {
|
|
||||||
size := int(binary.BigEndian.Uint32(data[off : off+4]))
|
|
||||||
name := string(data[off+4 : off+8])
|
|
||||||
header := 8
|
|
||||||
if size == 1 { // 64-bit size
|
|
||||||
if off+16 > len(data) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
size = int(binary.BigEndian.Uint64(data[off+8 : off+16]))
|
|
||||||
header = 16
|
|
||||||
} else if size == 0 {
|
|
||||||
size = len(data) - off
|
|
||||||
}
|
|
||||||
if size < header || off+size > len(data) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if name == typ {
|
|
||||||
return data[off+header : off+size]
|
|
||||||
}
|
|
||||||
off += size
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// wavDuration reads the RIFF fmt chunk's byte rate and the data chunk size.
|
|
||||||
func wavDuration(data []byte) float64 {
|
|
||||||
if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
byteRate := 0
|
|
||||||
dataSize := 0
|
|
||||||
for off := 12; off+8 <= len(data); {
|
|
||||||
name := string(data[off : off+4])
|
|
||||||
size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
|
|
||||||
body := off + 8
|
|
||||||
if size < 0 || body > len(data) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
switch name {
|
|
||||||
case "fmt ":
|
|
||||||
if body+16 <= len(data) {
|
|
||||||
byteRate = int(binary.LittleEndian.Uint32(data[body+8 : body+12]))
|
|
||||||
}
|
|
||||||
case "data":
|
|
||||||
dataSize = size
|
|
||||||
if body+size > len(data) {
|
|
||||||
dataSize = len(data) - body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
off = body + size + size%2
|
|
||||||
}
|
|
||||||
if byteRate <= 0 || dataSize <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return float64(dataSize) / float64(byteRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mp3Bitrates are the Layer III bitrate tables (kbps) indexed by the frame
|
|
||||||
// header's bitrate index: 1 = MPEG1, 2 = MPEG2/2.5.
|
|
||||||
var mp3Bitrates = map[int][]int{
|
|
||||||
1: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
|
|
||||||
2: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
// mp3Duration estimates the duration from the first frame header's bitrate
|
|
||||||
// (constant-bitrate assumption — good enough for the guidance duration field).
|
|
||||||
func mp3Duration(data []byte) float64 {
|
|
||||||
off := 0
|
|
||||||
if len(data) >= 10 && string(data[0:3]) == "ID3" {
|
|
||||||
// syncsafe int: 7 bits per byte
|
|
||||||
tagSize := int(data[6]&0x7F)<<21 | int(data[7]&0x7F)<<14 | int(data[8]&0x7F)<<7 | int(data[9]&0x7F)
|
|
||||||
off = 10 + tagSize
|
|
||||||
}
|
|
||||||
for ; off+4 <= len(data); off++ {
|
|
||||||
if data[off] != 0xFF || data[off+1]&0xE0 != 0xE0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
versionBits := (data[off+1] >> 3) & 0x03
|
|
||||||
layerBits := (data[off+1] >> 1) & 0x03
|
|
||||||
if layerBits != 0x01 { // Layer III only
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
table := 1
|
|
||||||
if versionBits != 0x03 { // MPEG2 / 2.5
|
|
||||||
table = 2
|
|
||||||
}
|
|
||||||
idx := int((data[off+2] >> 4) & 0x0F)
|
|
||||||
rates := mp3Bitrates[table]
|
|
||||||
if idx <= 0 || idx >= len(rates) || rates[idx] == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kbps := rates[idx]
|
|
||||||
return float64(len(data)-off) * 8 / float64(kbps*1000)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
// Package runway implements the Runway (runwayml.com) provider client. For now
|
|
||||||
// it only covers account management — JWT detection, workspace/team id
|
|
||||||
// extraction and credit-balance probing — mirroring the curl_cffi reference in
|
|
||||||
// query_credits.py with tls-client so the JA3/JA4 fingerprint matches Chrome.
|
|
||||||
package runway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
crand "crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
"github.com/bogdanfinn/tls-client/profiles"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
apiBase = "https://api.runwayml.com"
|
|
||||||
origin = "https://app.runwayml.com"
|
|
||||||
|
|
||||||
// userAgent / secChUA mirror the real browser that produced a successful
|
|
||||||
// registration + generation in the reference HAR (Edge 150 on Windows).
|
|
||||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"
|
|
||||||
secChUA = `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"`
|
|
||||||
|
|
||||||
// sourceApp / buildHash are the x-runway-source-application[-version] the real
|
|
||||||
// web app stamps on EVERY authed API call (319/336 requests in the reference
|
|
||||||
// HAR). buildHash is the web bundle's git sha; it also appears as the
|
|
||||||
// `sentry-release` segment of the baggage header. Runway anti-abuse keys on
|
|
||||||
// these being present + client-id (see clientIDFromToken): a write/upload that
|
|
||||||
// lacks them is judged a non-web client and the account's free credits are
|
|
||||||
// zeroed on the FIRST upload ("上传清零积分"). buildHash tracks Runway's web
|
|
||||||
// releases — refresh it from a current HAR if uploads start getting flagged.
|
|
||||||
sourceApp = "web"
|
|
||||||
buildHash = "3e96b2f0f85b8c0cafb7c0dcd7a6878305aaa0f0"
|
|
||||||
)
|
|
||||||
|
|
||||||
// clientIDOverride, when non-empty, forces the x-runway-client-id (used to pin an
|
|
||||||
// account to the exact persistent device id its real browser session used).
|
|
||||||
var clientIDOverride string
|
|
||||||
|
|
||||||
// clientIDUUIDNamespace is a fixed namespace so clientIDFromToken is stable
|
|
||||||
// across processes/restarts — the same account always derives the same
|
|
||||||
// x-runway-client-id, exactly like the browser's localStorage-persisted id.
|
|
||||||
var clientIDUUIDNamespace = uuid.MustParse("6ba7b811-9dad-11d1-80b4-00c04fd430c8")
|
|
||||||
|
|
||||||
// clientIDFromToken derives the persistent per-account x-runway-client-id. The
|
|
||||||
// real SPA generates this UUID once and stores it in localStorage; every request
|
|
||||||
// from that browser reuses the SAME value (5b89c786-… appears on all 319 authed
|
|
||||||
// calls in the HAR). We reproduce that stability by deterministically deriving a
|
|
||||||
// v5 UUID from the account's JWT "id" claim, so one account == one client-id for
|
|
||||||
// its whole lifetime without needing a DB column. Falls back to the raw token if
|
|
||||||
// the id claim is missing.
|
|
||||||
func clientIDFromToken(token string) string {
|
|
||||||
if clientIDOverride != "" {
|
|
||||||
return clientIDOverride
|
|
||||||
}
|
|
||||||
seed := TeamIDFromToken(token)
|
|
||||||
if seed == "" {
|
|
||||||
seed = strings.TrimPrefix(token, "Bearer ")
|
|
||||||
}
|
|
||||||
return uuid.NewSHA1(clientIDUUIDNamespace, []byte("runway-client-id:"+seed)).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids.
|
|
||||||
func randHex(n int) string {
|
|
||||||
b := make([]byte, n)
|
|
||||||
if _, err := crand.Read(b); err != nil {
|
|
||||||
return strings.Repeat("0", n*2)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// browserHeaders builds the full Chrome/Edge header set the Runway web app sends
|
|
||||||
// on its JSON API calls. tls-client only spoofs the TLS/JA3 fingerprint — it does
|
|
||||||
// NOT inject a User-Agent or the client-hint / fetch-metadata headers. Without
|
|
||||||
// them a request looks like a headless bot and Runway's anti-abuse revokes the
|
|
||||||
// account's free credits within minutes ("秒死"). teamID is optional.
|
|
||||||
func browserHeaders(token, teamID string) http.Header {
|
|
||||||
traceID := randHex(16)
|
|
||||||
spanID := randHex(8)
|
|
||||||
h := http.Header{
|
|
||||||
"accept": {"application/json"},
|
|
||||||
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
|
||||||
"authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")},
|
|
||||||
"baggage": {"sentry-environment=production,sentry-release=" + buildHash + ",sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID},
|
|
||||||
"content-type": {"application/json"},
|
|
||||||
"origin": {origin},
|
|
||||||
"priority": {"u=1, i"},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"sec-ch-ua": {secChUA},
|
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
|
||||||
"sec-ch-ua-platform": {`"Windows"`},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"same-site"},
|
|
||||||
"sentry-trace": {traceID + "-" + spanID},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
// x-runway-* identify this as the real web app to Runway's anti-abuse.
|
|
||||||
// client-id must be persistent per account (see clientIDFromToken);
|
|
||||||
// omitting these is what zeroes an account's credits on first upload.
|
|
||||||
"x-runway-client-id": {clientIDFromToken(token)},
|
|
||||||
"x-runway-source-application": {sourceApp},
|
|
||||||
"x-runway-source-application-version": {buildHash},
|
|
||||||
"x-runway-workspace": {teamID},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"accept", "accept-language", "authorization", "baggage",
|
|
||||||
"content-type", "origin", "priority", "referer",
|
|
||||||
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
|
||||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
|
||||||
"sentry-trace", "user-agent",
|
|
||||||
"x-runway-client-id", "x-runway-source-application",
|
|
||||||
"x-runway-source-application-version", "x-runway-workspace",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(teamID) == "" {
|
|
||||||
h.Del("x-runway-workspace")
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// browserAssetHeaders is the lighter header set the browser sends on cross-site
|
|
||||||
// asset transfers (presigned S3 upload / CloudFront artifact download): a
|
|
||||||
// User-Agent and client hints, but no Runway authorization.
|
|
||||||
func browserAssetHeaders() http.Header {
|
|
||||||
return http.Header{
|
|
||||||
"accept": {"*/*"},
|
|
||||||
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
|
||||||
"origin": {origin},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"sec-ch-ua": {secChUA},
|
|
||||||
"sec-ch-ua-mobile": {"?0"},
|
|
||||||
"sec-ch-ua-platform": {`"Windows"`},
|
|
||||||
"sec-fetch-dest": {"empty"},
|
|
||||||
"sec-fetch-mode": {"cors"},
|
|
||||||
"sec-fetch-site": {"cross-site"},
|
|
||||||
"user-agent": {userAgent},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrAuth = errors.New("runway auth failed")
|
|
||||||
ErrQuotaExhausted = errors.New("runway quota exhausted")
|
|
||||||
ErrTemporaryUpstream = errors.New("runway upstream temporary error")
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
proxy string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(proxy string) *Client {
|
|
||||||
return &Client{proxy: strings.TrimSpace(proxy)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SetProxy(proxy string) {
|
|
||||||
c.proxy = strings.TrimSpace(proxy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRunwayToken reports whether a JWT looks like a Runway access token: a
|
|
||||||
// top-level numeric "id" plus an "sso" claim, and crucially NO OpenAI
|
|
||||||
// (https://api.openai.com/*) claims — that's what disambiguates it from a
|
|
||||||
// ChatGPT token, which is otherwise also an opaque three-part JWT.
|
|
||||||
func IsRunwayToken(token string) bool {
|
|
||||||
claims := decodeJWTPayload(token)
|
|
||||||
if len(claims) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for k := range claims {
|
|
||||||
if strings.HasPrefix(k, "https://api.openai.com/") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, hasSSO := claims["sso"]
|
|
||||||
return hasSSO && claims["id"] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TeamIDFromToken returns the Runway workspace/team id, which equals the JWT
|
|
||||||
// "id" claim (query_credits.py / gen_video.py both derive teamId this way).
|
|
||||||
func TeamIDFromToken(token string) string {
|
|
||||||
claims := decodeJWTPayload(token)
|
|
||||||
switch v := claims["id"].(type) {
|
|
||||||
case float64:
|
|
||||||
return strconv.FormatInt(int64(v), 10)
|
|
||||||
case json.Number:
|
|
||||||
return v.String()
|
|
||||||
case string:
|
|
||||||
return strings.TrimSpace(v)
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractAccountInfo decodes the free (no-network) JWT claims for the accounts
|
|
||||||
// view: email, team id and expiry.
|
|
||||||
func ExtractAccountInfo(token string) map[string]any {
|
|
||||||
claims := decodeJWTPayload(token)
|
|
||||||
return map[string]any{
|
|
||||||
"email": emptyStringNil(strings.TrimSpace(stringValue(claims["email"]))),
|
|
||||||
"team_id": emptyStringNil(TeamIDFromToken(token)),
|
|
||||||
"expires_at": claims["exp"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchCreditsBalance probes the account's plan credits via /v1/profile/features
|
|
||||||
// (query_credits.py). Returns a normalized map mirroring the Adobe client so the
|
|
||||||
// TokenService quota plumbing can treat all providers uniformly. A 401/403 maps
|
|
||||||
// to ErrAuth (token dead); any other failure is reported as unknown without
|
|
||||||
// killing the account.
|
|
||||||
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
|
|
||||||
token = strings.TrimSpace(token)
|
|
||||||
if token == "" {
|
|
||||||
return unknownBalance("empty token"), nil
|
|
||||||
}
|
|
||||||
teamID := TeamIDFromToken(token)
|
|
||||||
if teamID == "" {
|
|
||||||
return unknownBalance("no team id"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
url := apiBase + "/v1/profile/features?asTeamId=" + teamID
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = browserHeaders(token, teamID)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return unknownBalance("network: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Per ops decision a rate-limit (403) is treated as a dead account too, same as
|
|
||||||
// a 401 — a throttled Runway token is considered done.
|
|
||||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
||||||
return nil, ErrAuth
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160))), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return unknownBalance("non-json"), nil
|
|
||||||
}
|
|
||||||
features, _ := payload["features"].(map[string]any)
|
|
||||||
permitted, _ := features["permitted"].(map[string]any)
|
|
||||||
used, _ := features["used"].(map[string]any)
|
|
||||||
total := intValue(permitted["numPlanCredits"])
|
|
||||||
spent := intValue(used["numPlanCredits"])
|
|
||||||
remaining := total - spent
|
|
||||||
if remaining < 0 {
|
|
||||||
remaining = 0
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"remaining": remaining,
|
|
||||||
"used": spent,
|
|
||||||
"total": total,
|
|
||||||
"unknown": false,
|
|
||||||
"error": nil,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func unknownBalance(reason string) map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"remaining": nil,
|
|
||||||
"used": nil,
|
|
||||||
"total": nil,
|
|
||||||
"unknown": true,
|
|
||||||
"error": reason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
|
||||||
|
|
||||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
|
||||||
// reference-image upload, polling and result download.
|
|
||||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
|
||||||
|
|
||||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
|
||||||
options := []tlsclient.HttpClientOption{
|
|
||||||
// Whole-request timeout, incl. reading the response body. 30s was too
|
|
||||||
// tight for downloading a rendered image/video artifact off a slow CDN,
|
|
||||||
// surfacing as "request canceled (Client.Timeout ... while reading body)".
|
|
||||||
// The caller's genCtx still bounds the overall generation.
|
|
||||||
tlsclient.WithTimeoutSeconds(300),
|
|
||||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
|
||||||
tlsclient.WithRandomTLSExtensionOrder(),
|
|
||||||
}
|
|
||||||
if useProxy && c.proxy != "" {
|
|
||||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
|
||||||
}
|
|
||||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeJWTPayload(token string) map[string]any {
|
|
||||||
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
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 int64:
|
|
||||||
return int(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 {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if 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
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package runway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GenerateImage runs a Runway gemini image text/image-to-image pipeline:
|
|
||||||
// upload each reference image (DATASET + DATASET_PREVIEW → dataset) to obtain
|
|
||||||
// its {assetId, url}, create a gemini image task and poll it to completion,
|
|
||||||
// then download the rendered PNG. modelID selects the variant: "runway-nano-banana-2"
|
|
||||||
// → gemini_3_1_flash_image / gemini-3.1-flash-image-preview (with aspect_ratio),
|
|
||||||
// anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize
|
|
||||||
// is the "1K"/"2K"/"4K" tier. teamID is the workspace id; if empty it's derived
|
|
||||||
// from the token. refs may be empty (pure text-to-image).
|
|
||||||
func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
if teamID == "" {
|
|
||||||
teamID = TeamIDFromToken(token)
|
|
||||||
}
|
|
||||||
if teamID == "" {
|
|
||||||
return nil, nil, errors.New("runway: no team id")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(imageSize) == "" {
|
|
||||||
imageSize = "1K"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the task-create (generate submit) egresses via the proxy; reference
|
|
||||||
// upload, polling and download run on the local IP.
|
|
||||||
submitClient, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
directClient, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var refImages []map[string]any
|
|
||||||
var refAssetIDs []string
|
|
||||||
for i, raw := range refs {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filename := fmt.Sprintf("ref_%s_%d.png", time.Now().UTC().Format("20060102_150405"), i+1)
|
|
||||||
assetID, url, upErr := c.uploadReference(ctx, directClient, token, teamID, filename, raw)
|
|
||||||
if upErr != nil {
|
|
||||||
return nil, nil, upErr
|
|
||||||
}
|
|
||||||
refImages = append(refImages, map[string]any{
|
|
||||||
"tag": fmt.Sprintf("IMG_%d", i+1),
|
|
||||||
"assetId": assetID,
|
|
||||||
"url": url,
|
|
||||||
})
|
|
||||||
refAssetIDs = append(refAssetIDs, assetID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser order: create the real session FIRST, attach the references and its
|
|
||||||
// own asset group, THEN submit the task into it (see createSession). The task
|
|
||||||
// carries this session's assetGroupId, NOT the account-wide "Generations"
|
|
||||||
// group.
|
|
||||||
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
for _, aid := range refAssetIDs {
|
|
||||||
c.attachReference(ctx, submitClient, token, teamID, sessionID, aid)
|
|
||||||
}
|
|
||||||
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
|
||||||
|
|
||||||
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID, refImages)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
meta := map[string]any{
|
|
||||||
"provider": "runway",
|
|
||||||
"task_id": taskID,
|
|
||||||
"team_id": teamID,
|
|
||||||
"image_url": artifactURL,
|
|
||||||
}
|
|
||||||
// downloadResult=false (API-key url-only mode): return the upstream artifact
|
|
||||||
// URL without downloading the PNG.
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, meta, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, directClient, artifactURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadReference uploads one reference image through the dataset pipeline
|
|
||||||
// (DATASET_PREVIEW + DATASET uploads → /v1/datasets) and returns its asset id
|
|
||||||
// (= dataset id) and the cloudfront URL the task references.
|
|
||||||
func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename string, data []byte) (string, string, error) {
|
|
||||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", "", errors.New("runway: failed to decode reference image")
|
|
||||||
}
|
|
||||||
previewUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET_PREVIEW", data)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
// The DATASET upload's completed URL is exactly what the task references.
|
|
||||||
datasetUploadID, refURL, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET", data)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
assetID, _, err := c.createDataset(ctx, client, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return assetID, refURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createImageTask submits a gemini image task (workflow_gemini_image for Pro,
|
|
||||||
// gemini_3_1_flash_image for Nano Banana 2) INTO the pre-created session and
|
|
||||||
// returns its id. sessionID must be a real server-side session (see
|
|
||||||
// createSession) and assetGroupID must be that session's own group.
|
|
||||||
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID string, refImages []map[string]any) (string, error) {
|
|
||||||
if strings.TrimSpace(aspectRatio) == "" {
|
|
||||||
aspectRatio = "16:9"
|
|
||||||
}
|
|
||||||
taskType := "workflow_gemini_image"
|
|
||||||
opts := map[string]any{
|
|
||||||
"name": "Nano Banana Pro - " + prompt,
|
|
||||||
"text_prompt": prompt,
|
|
||||||
"aspect_ratio": aspectRatio,
|
|
||||||
"num_images": 1,
|
|
||||||
"image_size": imageSize,
|
|
||||||
"model": "gemini-3-pro-image-preview",
|
|
||||||
"exploreMode": false,
|
|
||||||
"creationSource": "tool-mode",
|
|
||||||
}
|
|
||||||
if modelID == "runway-nano-banana-2" {
|
|
||||||
taskType = "gemini_3_1_flash_image"
|
|
||||||
opts["name"] = "Nano Banana 2 - " + prompt
|
|
||||||
opts["model"] = "gemini-3.1-flash-image-preview"
|
|
||||||
}
|
|
||||||
if assetGroupID != "" {
|
|
||||||
opts["assetGroupId"] = assetGroupID
|
|
||||||
}
|
|
||||||
if len(refImages) > 0 {
|
|
||||||
opts["reference_images"] = refImages
|
|
||||||
}
|
|
||||||
// Pre-flight cost estimate, exactly as the web app does before every spend.
|
|
||||||
c.estimateCost(ctx, client, token, teamID, "gemini_image", opts)
|
|
||||||
|
|
||||||
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
|
||||||
"taskType": taskType,
|
|
||||||
"options": opts,
|
|
||||||
"asTeamId": jsonNumberOrString(teamID),
|
|
||||||
"sessionId": sessionID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
task, _ := res["task"].(map[string]any)
|
|
||||||
id := strings.TrimSpace(stringValue(task["id"]))
|
|
||||||
if id == "" {
|
|
||||||
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
// Post-submit: generation record + session play (session already exists).
|
|
||||||
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
package runway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
|
||||||
"io"
|
|
||||||
mrand "math/rand/v2"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
http "github.com/bogdanfinn/fhttp"
|
|
||||||
tlsclient "github.com/bogdanfinn/tls-client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size.
|
|
||||||
// These are the only dimensions gen4_turbo accepts; "2K" is a UI label over this
|
|
||||||
// native tier (see runway-video-gen-spec). Unknown ratios fall back to 16:9.
|
|
||||||
func ratioDimensions(aspectRatio string) (int, int) {
|
|
||||||
switch strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":")) {
|
|
||||||
case "16:9":
|
|
||||||
return 1280, 720
|
|
||||||
case "9:16":
|
|
||||||
return 720, 1280
|
|
||||||
case "1:1":
|
|
||||||
return 960, 960
|
|
||||||
case "4:3":
|
|
||||||
return 1104, 832
|
|
||||||
case "3:4":
|
|
||||||
return 832, 1104
|
|
||||||
case "21:9":
|
|
||||||
return 1584, 672
|
|
||||||
default:
|
|
||||||
return 1280, 720
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateVideo runs the full i2v pipeline (gen_video.py): upload the first-frame
|
|
||||||
// image (preview + dataset), create a dataset, create a gen4_turbo task and poll
|
|
||||||
// it to completion, then download the rendered MP4. teamID is the workspace id
|
|
||||||
// (meta["team_id"]); if empty it's derived from the token. seconds must be 5 or
|
|
||||||
// 10; aspectRatio picks the native output size.
|
|
||||||
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
|
|
||||||
// With downloadResult=false it returns nil bytes and the upstream artifact URL in
|
|
||||||
// meta["video_url"] — used by the async /v1/videos job, which proxies that URL on
|
|
||||||
// /content instead of persisting the file.
|
|
||||||
func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspectRatio string, seconds int, frame []byte, downloadResult bool) ([]byte, map[string]any, error) {
|
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
|
||||||
if token == "" {
|
|
||||||
return nil, nil, ErrAuth
|
|
||||||
}
|
|
||||||
if teamID == "" {
|
|
||||||
teamID = TeamIDFromToken(token)
|
|
||||||
}
|
|
||||||
if teamID == "" {
|
|
||||||
return nil, nil, errors.New("runway: no team id")
|
|
||||||
}
|
|
||||||
if len(frame) == 0 {
|
|
||||||
return nil, nil, errors.New("runway: first-frame image required")
|
|
||||||
}
|
|
||||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(frame))
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, errors.New("runway: failed to decode first-frame image")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the task-create (generate submit) egresses via the proxy; reference
|
|
||||||
// upload, dataset create, polling and download run on the local IP (matches
|
|
||||||
// the image pipeline).
|
|
||||||
submitClient, err := c.newTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
directClient, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png"
|
|
||||||
previewUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET_PREVIEW", frame)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
datasetUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET", frame)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
assetID, imageURL, err := c.createDataset(ctx, directClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
// Browser order: real session first, attach the first-frame asset + its own
|
|
||||||
// asset group, THEN submit the task into it (see createSession).
|
|
||||||
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
c.attachReference(ctx, submitClient, token, teamID, sessionID, assetID)
|
|
||||||
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
|
||||||
|
|
||||||
taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio, seconds)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
meta := map[string]any{
|
|
||||||
"provider": "runway",
|
|
||||||
"task_id": taskID,
|
|
||||||
"team_id": teamID,
|
|
||||||
"video_url": artifactURL,
|
|
||||||
}
|
|
||||||
if !downloadResult {
|
|
||||||
return nil, meta, nil
|
|
||||||
}
|
|
||||||
data, err := c.download(ctx, directClient, artifactURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return data, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadFile mirrors gen_video.upload_file: register the upload, PUT the bytes to
|
|
||||||
// the returned S3 URL, then complete. Returns the upload id and final url.
|
|
||||||
func (c *Client) uploadFile(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, uploadType string, data []byte) (string, string, error) {
|
|
||||||
info, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads", map[string]any{
|
|
||||||
"filename": filename,
|
|
||||||
"numberOfParts": 1,
|
|
||||||
"type": uploadType,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
uploadID := strings.TrimSpace(stringValue(info["id"]))
|
|
||||||
urls, _ := info["uploadUrls"].([]any)
|
|
||||||
if uploadID == "" || len(urls) == 0 {
|
|
||||||
return "", "", fmt.Errorf("%w: upload register missing fields", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
putURL := strings.TrimSpace(stringValue(urls[0]))
|
|
||||||
contentType := "application/octet-stream"
|
|
||||||
if hdrs, ok := info["uploadHeaders"].(map[string]any); ok {
|
|
||||||
if ct := strings.TrimSpace(stringValue(hdrs["Content-Type"])); ct != "" {
|
|
||||||
contentType = ct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
etag, err := c.putBytes(ctx, client, putURL, contentType, data)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads/"+uploadID+"/complete", map[string]any{
|
|
||||||
"parts": []map[string]any{{"PartNumber": 1, "ETag": etag}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return uploadID, strings.TrimSpace(stringValue(res["url"])), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) createDataset(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, datasetUploadID, previewUploadID string, w, h int) (string, string, error) {
|
|
||||||
teamIDNum := jsonNumberOrString(teamID)
|
|
||||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/datasets", map[string]any{
|
|
||||||
"fileCount": 1,
|
|
||||||
"name": filename,
|
|
||||||
"uploadId": datasetUploadID,
|
|
||||||
"previewUploadIds": []string{previewUploadID},
|
|
||||||
"metadata": map[string]any{"size": map[string]any{"width": w, "height": h}},
|
|
||||||
"type": map[string]any{"name": "image", "type": "image", "isDirectory": false},
|
|
||||||
"asTeamId": teamIDNum,
|
|
||||||
"privateInTeam": true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
ds, _ := res["dataset"].(map[string]any)
|
|
||||||
id := strings.TrimSpace(stringValue(ds["id"]))
|
|
||||||
url := strings.TrimSpace(stringValue(ds["url"]))
|
|
||||||
if id == "" || url == "" {
|
|
||||||
return "", "", fmt.Errorf("%w: dataset missing fields", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return id, url, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
|
||||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet,
|
|
||||||
"/v1/asset_groups/by_name?name=Generations&asTeamId="+teamID+"&privateInTeam=true", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
ag, _ := res["assetGroup"].(map[string]any)
|
|
||||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio string, seconds int) (string, error) {
|
|
||||||
w, h := ratioDimensions(aspectRatio)
|
|
||||||
opts := map[string]any{
|
|
||||||
"route": "i2v",
|
|
||||||
"name": "Gen-4 Turbo - " + prompt,
|
|
||||||
"text_prompt": prompt,
|
|
||||||
"seconds": seconds,
|
|
||||||
"width": w,
|
|
||||||
"height": h,
|
|
||||||
"init_image": imageURL,
|
|
||||||
"imageAssetId": assetID,
|
|
||||||
"exploreMode": false,
|
|
||||||
"creationSource": "tool-mode",
|
|
||||||
"seed": mrand.IntN(999999999) + 1,
|
|
||||||
"watermark": true,
|
|
||||||
}
|
|
||||||
if assetGroupID != "" {
|
|
||||||
opts["assetGroupId"] = assetGroupID
|
|
||||||
}
|
|
||||||
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
|
||||||
"taskType": "gen4_turbo",
|
|
||||||
"options": opts,
|
|
||||||
"asTeamId": jsonNumberOrString(teamID),
|
|
||||||
"sessionId": sessionID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
task, _ := res["task"].(map[string]any)
|
|
||||||
id := strings.TrimSpace(stringValue(task["id"]))
|
|
||||||
if id == "" {
|
|
||||||
return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
// Post-submit: generation record + session play (session already exists).
|
|
||||||
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID string) (string, error) {
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet, "/v1/tasks/"+taskID+"?asTeamId="+teamID, nil)
|
|
||||||
if err != nil {
|
|
||||||
// A transient blip shouldn't kill a render that may still succeed.
|
|
||||||
if errors.Is(err, ErrTemporaryUpstream) {
|
|
||||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
|
||||||
return "", ctx.Err()
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
task, _ := res["task"].(map[string]any)
|
|
||||||
status := strings.ToUpper(strings.TrimSpace(stringValue(task["status"])))
|
|
||||||
switch status {
|
|
||||||
case "SUCCEEDED":
|
|
||||||
arts, _ := task["artifacts"].([]any)
|
|
||||||
for _, raw := range arts {
|
|
||||||
art, _ := raw.(map[string]any)
|
|
||||||
if url := strings.TrimSpace(stringValue(art["url"])); url != "" {
|
|
||||||
return url, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", errors.New("runway: task succeeded with no artifact url")
|
|
||||||
case "FAILED", "CANCELED":
|
|
||||||
reason := strings.TrimSpace(stringValue(task["error"]))
|
|
||||||
if isCreditError(reason) {
|
|
||||||
return "", fmt.Errorf("%w: %s", ErrQuotaExhausted, reason)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("runway: task %s: %s", status, reason)
|
|
||||||
}
|
|
||||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
|
||||||
return "", ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// estimateCost fires the /v1/billing/estimate_feature_cost_credits pre-flight the
|
|
||||||
// web app ALWAYS does before a spend (52 times in the reference HAR, always with
|
|
||||||
// the exact taskOptions right before /v1/tasks). It returns no reservation token,
|
|
||||||
// so its purpose is purely to mark the spend as coming from the real UI flow;
|
|
||||||
// submitting a task with no preceding estimate for that spec is a scripted-abuse
|
|
||||||
// tell. Best-effort — the estimate itself costs nothing.
|
|
||||||
func (c *Client) estimateCost(ctx context.Context, client tlsclient.HttpClient, token, teamID, feature string, taskOptions map[string]any) {
|
|
||||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/billing/estimate_feature_cost_credits", map[string]any{
|
|
||||||
"feature": feature,
|
|
||||||
"count": 1,
|
|
||||||
"asTeamId": jsonNumberOrString(teamID),
|
|
||||||
"taskOptions": taskOptions,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// createSession creates an EMPTY tool-mode session (POST /v1/sessions with an
|
|
||||||
// empty taskIds array) and returns its real server-side id. This is the crux of
|
|
||||||
// the whole flow: the browser creates the session BEFORE submitting the first
|
|
||||||
// task, then submits the task INTO that real session. Submitting /v1/tasks with a
|
|
||||||
// sessionId that was never created server-side is a forgery tell that makes
|
|
||||||
// Runway revoke the account's free credits (permitted numPlanCredits 500 -> 0)
|
|
||||||
// the instant the task lands ("提交生图就清零"). Note taskIds must be [] — passing
|
|
||||||
// a bogus id yields "Invalid task IDs".
|
|
||||||
func (c *Client) createSession(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
|
||||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions", map[string]any{
|
|
||||||
"asTeamId": jsonNumberOrString(teamID),
|
|
||||||
"taskIds": []string{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sess, _ := res["session"].(map[string]any)
|
|
||||||
id := strings.TrimSpace(stringValue(sess["id"]))
|
|
||||||
if id == "" {
|
|
||||||
return "", fmt.Errorf("%w: session missing id", ErrTemporaryUpstream)
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachReference attaches an uploaded asset to the session (the browser does
|
|
||||||
// this before the task, so the task's reference_images belong to a real session).
|
|
||||||
func (c *Client) attachReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID, assetID string) {
|
|
||||||
if strings.TrimSpace(assetID) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
|
||||||
"/v1/sessions/"+sessionID+"/references", map[string]any{
|
|
||||||
"assetId": assetID,
|
|
||||||
"asTeamId": jsonNumberOrString(teamID),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionAssetGroup creates the session's OWN asset group (POST
|
|
||||||
// /v1/sessions/{id}/assetGroup) and returns its id — this, NOT the account-wide
|
|
||||||
// "Generations" group, is the assetGroupId the browser puts on the task.
|
|
||||||
func (c *Client) sessionAssetGroup(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID string) (string, error) {
|
|
||||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
|
||||||
"/v1/sessions/"+sessionID+"/assetGroup", map[string]any{"asTeamId": jsonNumberOrString(teamID)})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
ag, _ := res["assetGroup"].(map[string]any)
|
|
||||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordGeneration is the post-submit lifecycle the browser fires AFTER the task
|
|
||||||
// lands: the generation record (POST /v1/generations, recordingEnabled=true) and
|
|
||||||
// a session /play. The session itself already exists (createSession ran before
|
|
||||||
// the task), so this no longer creates it. Best-effort.
|
|
||||||
func (c *Client) recordGeneration(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID, sessionID, prompt string, taskOptions map[string]any) {
|
|
||||||
settings := map[string]any{"taskId": taskID, "recordingEnabled": true}
|
|
||||||
for k, v := range taskOptions {
|
|
||||||
if k == "exploreMode" { // not present on the browser's generation record
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
settings[k] = v
|
|
||||||
}
|
|
||||||
// The generation record carries the model id WITHOUT the "-preview" suffix
|
|
||||||
// the task uses (gemini-3-pro-image-preview -> gemini-3-pro-image in the HAR).
|
|
||||||
if m, ok := settings["model"].(string); ok {
|
|
||||||
settings["model"] = strings.TrimSuffix(m, "-preview")
|
|
||||||
}
|
|
||||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/generations", map[string]any{
|
|
||||||
"toolId": "generate",
|
|
||||||
"prompt": prompt,
|
|
||||||
"outputs": map[string]any{"outputUrls": []any{}},
|
|
||||||
"settings": settings,
|
|
||||||
})
|
|
||||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions/"+sessionID+"/play",
|
|
||||||
map[string]any{"asTeamId": jsonNumberOrString(teamID), "taskId": taskID})
|
|
||||||
}
|
|
||||||
|
|
||||||
// submitTask POSTs a /v1/tasks create. Transient (network / 5xx) failures
|
|
||||||
// surface as ErrTemporaryUpstream so the pool fails over to the NEXT account
|
|
||||||
// (换号重试) instead of retrying this one.
|
|
||||||
func (c *Client) submitTask(ctx context.Context, client tlsclient.HttpClient, token, teamID string, body map[string]any) (map[string]any, error) {
|
|
||||||
return c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiJSON performs an authed JSON request against the Runway API and returns the
|
|
||||||
// parsed body, mapping status codes to the shared provider error sentinels.
|
|
||||||
func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token, teamID, method, path string, body any) (map[string]any, error) {
|
|
||||||
var reader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
raw, _ := json.Marshal(body)
|
|
||||||
reader = bytes.NewReader(raw)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(method, apiBase+path, reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = browserHeaders(token, teamID)
|
|
||||||
// The generate-submit endpoint is the ONE authed call the real web bundle
|
|
||||||
// sends WITHOUT x-runway-client-id / source-application[-version] — every
|
|
||||||
// other endpoint carries them, /v1/tasks carries only x-runway-workspace
|
|
||||||
// (verified across the whole reference HAR). Leaving them on /v1/tasks is a
|
|
||||||
// bot tell that gets the account's free credits revoked (permitted
|
|
||||||
// numPlanCredits 500 -> 0) the instant a task is submitted. Strip them here so
|
|
||||||
// the submit matches the browser exactly.
|
|
||||||
if method == http.MethodPost && path == "/v1/tasks" {
|
|
||||||
// NOTE: raw map delete, NOT req.Header.Del() — fhttp canonicalizes the key
|
|
||||||
// in Del() ("X-Runway-Client-Id") but our headers are stored lowercase (so
|
|
||||||
// they go on the h2 wire lowercase like Chrome), so Del() silently no-ops.
|
|
||||||
delete(req.Header, "x-runway-client-id")
|
|
||||||
delete(req.Header, "x-runway-source-application")
|
|
||||||
delete(req.Header, "x-runway-source-application-version")
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
|
||||||
// Rate-limit (403) is treated as a dead account too, same as a 401.
|
|
||||||
return nil, fmt.Errorf("%w: %s %d %s", ErrAuth, path, resp.StatusCode, clip(raw, 200))
|
|
||||||
case resp.StatusCode == 429:
|
|
||||||
return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200))
|
|
||||||
case resp.StatusCode >= 500:
|
|
||||||
return nil, fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, resp.StatusCode, clip(raw, 200))
|
|
||||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
|
||||||
if isCreditError(string(raw)) {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 200))
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("runway: %s %d %s", path, resp.StatusCode, clip(raw, 200))
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return map[string]any{}, nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// putBytes uploads raw bytes to a presigned S3 URL (no auth) and returns the
|
|
||||||
// ETag, mirroring the plain requests.Session().put in gen_video.py.
|
|
||||||
func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url, contentType string, data []byte) (string, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = browserAssetHeaders()
|
|
||||||
req.Header.Set("content-type", contentType)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return "", fmt.Errorf("%w: s3 put %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
return strings.Trim(resp.Header.Get("ETag"), `"`), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
req.Header = browserAssetHeaders()
|
|
||||||
req.Header.Set("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
|
||||||
req.Header.Set("sec-fetch-dest", "image")
|
|
||||||
req.Header.Set("sec-fetch-mode", "no-cors")
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil, errors.New("runway: empty artifact download")
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsonNumberOrString returns the team id as a JSON number when it's purely
|
|
||||||
// numeric (Runway's asTeamId is an integer in the reference payloads), else the
|
|
||||||
// raw string.
|
|
||||||
func jsonNumberOrString(teamID string) any {
|
|
||||||
return json.Number(strings.TrimSpace(teamID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCreditError(s string) bool {
|
|
||||||
s = strings.ToLower(s)
|
|
||||||
return strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
|
||||||
}
|
|
||||||
|
|
||||||
// sleepCtx sleeps for d or until ctx is done; returns ctx.Err() if cancelled.
|
|
||||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
|
||||||
t := time.NewTimer(d)
|
|
||||||
defer t.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-t.C:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backend/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChannelRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChannelRepository(db *gorm.DB) *ChannelRepository {
|
||||||
|
return &ChannelRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelWithUpstream pairs a channel row with the upstream model name a
|
||||||
|
// binding carries — the shape dispatch needs in one fetch.
|
||||||
|
type ChannelWithUpstream struct {
|
||||||
|
Channel model.Channel
|
||||||
|
UpstreamModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) List(ctx context.Context) ([]model.Channel, error) {
|
||||||
|
var items []model.Channel
|
||||||
|
if err := r.db.WithContext(ctx).Order("created_at asc").Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) Get(ctx context.Context, id string) (*model.Channel, error) {
|
||||||
|
var item model.Channel
|
||||||
|
if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) Create(ctx context.Context, item *model.Channel) error {
|
||||||
|
return r.db.WithContext(ctx).Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.Channel, error) {
|
||||||
|
patch["updated_at"] = time.Now()
|
||||||
|
if err := r.db.WithContext(ctx).Model(&model.Channel{}).Where("id = ?", id).Updates(patch).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) Delete(ctx context.Context, id string) (int64, error) {
|
||||||
|
res := r.db.WithContext(ctx).Delete(&model.Channel{}, "id = ?", id)
|
||||||
|
return res.RowsAffected, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bindings returns all channel_models rows for a channel.
|
||||||
|
func (r *ChannelRepository) Bindings(ctx context.Context, channelID string) ([]model.ChannelModel, error) {
|
||||||
|
var items []model.ChannelModel
|
||||||
|
if err := r.db.WithContext(ctx).Where("channel_id = ?", channelID).Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind upserts one channel↔model binding (upstream model name is refreshed on
|
||||||
|
// conflict so re-import can rename it).
|
||||||
|
func (r *ChannelRepository) Bind(ctx context.Context, channelID, modelID, upstreamModel string) error {
|
||||||
|
row := model.ChannelModel{ChannelID: channelID, ModelID: modelID, UpstreamModel: upstreamModel}
|
||||||
|
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "channel_id"}, {Name: "model_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"upstream_model", "updated_at"}),
|
||||||
|
}).Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) Unbind(ctx context.Context, channelID, modelID string) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbindExcept removes every binding of a channel whose upstream model name is
|
||||||
|
// NOT in the keep list (upstream_model, falling back to model_id for legacy
|
||||||
|
// rows) — the "true sync" counterpart used after saving a channel.
|
||||||
|
func (r *ChannelRepository) UnbindExcept(ctx context.Context, channelID string, upstreamIDs []string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Where("channel_id = ? AND NOT (COALESCE(NULLIF(upstream_model, ''), model_id) IN ?)", channelID, upstreamIDs).
|
||||||
|
Delete(&model.ChannelModel{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChannelRepository) DeleteBindingsByModel(ctx context.Context, modelID string) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "model_id = ?", modelID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForModel returns enabled channels bound to modelID (channel rows + the
|
||||||
|
// upstream model name each binding carries), stable-ordered for dispatch.
|
||||||
|
func (r *ChannelRepository) ForModel(ctx context.Context, modelID string) ([]ChannelWithUpstream, error) {
|
||||||
|
var rows []struct {
|
||||||
|
model.Channel
|
||||||
|
UpstreamModel string `gorm:"column:upstream_model"`
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("channels").
|
||||||
|
Select("channels.*, channel_models.upstream_model").
|
||||||
|
Joins("JOIN channel_models ON channel_models.channel_id = channels.id").
|
||||||
|
Where("channel_models.model_id = ? AND channels.enabled = ?", modelID, true).
|
||||||
|
Order("channels.id asc").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ChannelWithUpstream, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, ChannelWithUpstream{Channel: row.Channel, UpstreamModel: row.UpstreamModel})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForModelAnyState is ForModel without the enabled filter — used by admin
|
||||||
|
// channel-pinned tests which deliberately target one channel even when it's
|
||||||
|
// disabled for traffic.
|
||||||
|
func (r *ChannelRepository) ForModelAnyState(ctx context.Context, modelID string) ([]ChannelWithUpstream, error) {
|
||||||
|
var rows []struct {
|
||||||
|
model.Channel
|
||||||
|
UpstreamModel string `gorm:"column:upstream_model"`
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("channels").
|
||||||
|
Select("channels.*, channel_models.upstream_model").
|
||||||
|
Joins("JOIN channel_models ON channel_models.channel_id = channels.id").
|
||||||
|
Where("channel_models.model_id = ?", modelID).
|
||||||
|
Order("channels.id asc").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ChannelWithUpstream, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, ChannelWithUpstream{Channel: row.Channel, UpstreamModel: row.UpstreamModel})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelIDsByChannel lists local model ids bound to a channel.
|
||||||
|
func (r *ChannelRepository) ModelIDsByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||||
|
var ids []string
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.ChannelModel{}).
|
||||||
|
Where("channel_id = ?", channelID).Pluck("model_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamModelMap returns model_id → upstream_model for a channel.
|
||||||
|
func (r *ChannelRepository) UpstreamModelMap(ctx context.Context, channelID string) (map[string]string, error) {
|
||||||
|
var rows []model.ChannelModel
|
||||||
|
if err := r.db.WithContext(ctx).Where("channel_id = ?", channelID).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out[row.ModelID] = row.UpstreamModel
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoutableModelIDs returns the set of local model ids bound to at least one
|
||||||
|
// ENABLED channel — the "can actually dispatch" set behind the sellable flag.
|
||||||
|
func (r *ChannelRepository) RoutableModelIDs(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
var ids []string
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("channel_models").
|
||||||
|
Select("DISTINCT channel_models.model_id").
|
||||||
|
Joins("JOIN channels ON channels.id = channel_models.channel_id AND channels.enabled = ?", true).
|
||||||
|
Scan(&ids).Error
|
||||||
|
out := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
out[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelNamesByModel returns model_id -> bound channel display names in one
|
||||||
|
// JOIN query — used by the admin models table to annotate each row with the
|
||||||
|
// channel(s) it was synced from.
|
||||||
|
func (r *ChannelRepository) ChannelNamesByModel(ctx context.Context) (map[string][]string, error) {
|
||||||
|
var rows []struct {
|
||||||
|
ModelID string `gorm:"column:model_id"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("channel_models").
|
||||||
|
Select("channel_models.model_id, channels.name").
|
||||||
|
Joins("JOIN channels ON channels.id = channel_models.channel_id").
|
||||||
|
Scan(&rows).Error
|
||||||
|
out := make(map[string][]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out[row.ModelID] = append(out[row.ModelID], row.Name)
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
|
||||||
|
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
|
||||||
|
var ids []string
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.ChannelModel{}).
|
||||||
|
Where("model_id = ?", modelID).Pluck("channel_id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrphanModelIDs returns local model ids that no channel binds anymore.
|
||||||
|
// Soft-deleted rows are skipped — they're already gone from every live path.
|
||||||
|
func (r *ChannelRepository) OrphanModelIDs(ctx context.Context) ([]string, error) {
|
||||||
|
var ids []string
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("model_configs").
|
||||||
|
Select("model_configs.id").
|
||||||
|
Joins("LEFT JOIN channel_models ON channel_models.model_id = model_configs.id").
|
||||||
|
Where("channel_models.model_id IS NULL AND model_configs.deleted_at IS NULL").
|
||||||
|
Scan(&ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BumpSuccess records one successful upstream call (count + reported credits
|
||||||
|
// spent + last-used timestamp). Best-effort.
|
||||||
|
func (r *ChannelRepository) BumpSuccess(ctx context.Context, channelID string, credits float64) error {
|
||||||
|
now := time.Now()
|
||||||
|
return r.db.WithContext(ctx).Model(&model.Channel{}).Where("id = ?", channelID).Updates(map[string]any{
|
||||||
|
"success_total": gorm.Expr("success_total + 1"),
|
||||||
|
"used_credits": gorm.Expr("used_credits + ?", credits),
|
||||||
|
"last_used_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type EventListFilter struct {
|
|||||||
UserID string
|
UserID string
|
||||||
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
|
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
|
||||||
Query string // free-text search over prompt / model / error (server-side, 跨页)
|
Query string // free-text search over prompt / model / error (server-side, 跨页)
|
||||||
|
KeywordUserIDs []string // with Query set: also match rows whose user_id is in this list (单搜索框: 用户 OR 内容)
|
||||||
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
||||||
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
|
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
|
||||||
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
|
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
|
||||||
@@ -66,8 +67,12 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m
|
|||||||
}
|
}
|
||||||
if term := strings.TrimSpace(filter.Query); term != "" {
|
if term := strings.TrimSpace(filter.Query); term != "" {
|
||||||
like := "%" + term + "%"
|
like := "%" + term + "%"
|
||||||
|
if len(filter.KeywordUserIDs) > 0 {
|
||||||
|
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ? OR user_id IN ?)", like, like, like, filter.KeywordUserIDs)
|
||||||
|
} else {
|
||||||
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ?)", like, like, like)
|
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ?)", like, like, like)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if filter.ExcludeSource != "" {
|
if filter.ExcludeSource != "" {
|
||||||
q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource)
|
q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource)
|
||||||
}
|
}
|
||||||
@@ -184,7 +189,7 @@ type DashboardWindow struct {
|
|||||||
Pending int64 `json:"pending"`
|
Pending int64 `json:"pending"`
|
||||||
Image int64 `json:"image"`
|
Image int64 `json:"image"`
|
||||||
Video int64 `json:"video"`
|
Video int64 `json:"video"`
|
||||||
API int64 `json:"api"` // source = 'v1' (OpenAI-compatible key)
|
API int64 `json:"api"` // source = 'v1' (vk_ external API key)
|
||||||
Web int64 `json:"web"` // everything else (web / playground)
|
Web int64 `json:"web"` // everything else (web / playground)
|
||||||
Spent float64 `json:"spent"`
|
Spent float64 `json:"spent"`
|
||||||
}
|
}
|
||||||
@@ -430,12 +435,41 @@ const stalePendingWhere = "status = ? AND ((kind = ? AND ts < ?) OR (kind <> ? A
|
|||||||
|
|
||||||
// StaleEvent identifies a purged pending event so the caller can refund the
|
// StaleEvent identifies a purged pending event so the caller can refund the
|
||||||
// credits debited up-front AND attribute the failure to the account the
|
// credits debited up-front AND attribute the failure to the account the
|
||||||
// (now-abandoned) generation was using.
|
// (now-abandoned) generation was using. UpstreamJob/Kind/Model/File/Source are
|
||||||
|
// what the channel-resume path needs to pick the job back up.
|
||||||
type StaleEvent struct {
|
type StaleEvent struct {
|
||||||
ID string `gorm:"column:id"`
|
ID string `gorm:"column:id"`
|
||||||
UserID string `gorm:"column:user_id"`
|
UserID string `gorm:"column:user_id"`
|
||||||
AccountID string `gorm:"column:account_id"`
|
AccountID string `gorm:"column:account_id"`
|
||||||
|
UpstreamJob string `gorm:"column:upstream_job"`
|
||||||
|
Kind string `gorm:"column:kind"`
|
||||||
|
Model string `gorm:"column:model"`
|
||||||
|
File string `gorm:"column:file"`
|
||||||
|
Source string `gorm:"column:source"`
|
||||||
Cost float64 `gorm:"column:cost"`
|
Cost float64 `gorm:"column:cost"`
|
||||||
|
TS time.Time `gorm:"column:ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumableStale returns pending events past their per-kind deadline that still
|
||||||
|
// carry an upstream job id — a process restart orphaned the local goroutine but
|
||||||
|
// the upstream job may still be running, so these get resumed (not purged).
|
||||||
|
// Events older than maxResumeAge are excluded and fall through to PurgeStale.
|
||||||
|
func (r *EventRepository) ResumableStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = 600 * time.Second
|
||||||
|
}
|
||||||
|
if videoMaxAge <= 0 {
|
||||||
|
videoMaxAge = maxAge
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-maxAge)
|
||||||
|
videoCutoff := time.Now().Add(-videoMaxAge)
|
||||||
|
resumeFloor := time.Now().Add(-maxResumeAge)
|
||||||
|
var stale []StaleEvent
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
|
||||||
|
Where(stalePendingWhere+" AND upstream_job <> '' AND ts >= ?", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
|
Select("id", "user_id", "account_id", "upstream_job", "kind", "model", "file", "source", "cost", "ts").
|
||||||
|
Scan(&stale).Error
|
||||||
|
return stale, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
|
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
|
||||||
@@ -446,7 +480,10 @@ type StaleEvent struct {
|
|||||||
//
|
//
|
||||||
// videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的
|
// videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的
|
||||||
// 期限扫会在它出片前就判死并退款。
|
// 期限扫会在它出片前就判死并退款。
|
||||||
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge time.Duration) ([]StaleEvent, error) {
|
//
|
||||||
|
// Events carrying an upstream job id and younger than resumeFloor are skipped —
|
||||||
|
// ResumableStale resumes them against the channel instead of killing them.
|
||||||
|
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
|
||||||
if maxAge <= 0 {
|
if maxAge <= 0 {
|
||||||
maxAge = 600 * time.Second
|
maxAge = 600 * time.Second
|
||||||
}
|
}
|
||||||
@@ -455,13 +492,14 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
|
|||||||
}
|
}
|
||||||
cutoff := time.Now().Add(-maxAge)
|
cutoff := time.Now().Add(-maxAge)
|
||||||
videoCutoff := time.Now().Add(-videoMaxAge)
|
videoCutoff := time.Now().Add(-videoMaxAge)
|
||||||
|
resumeFloor := time.Now().Add(-maxResumeAge)
|
||||||
var stale []StaleEvent
|
var stale []StaleEvent
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Snapshot who/what to refund BEFORE flipping status, so a concurrent
|
// Snapshot who/what to refund BEFORE flipping status, so a concurrent
|
||||||
// sweep can't double-count (the UPDATE in the same tx removes them from
|
// sweep can't double-count (the UPDATE in the same tx removes them from
|
||||||
// the pending set).
|
// the pending set).
|
||||||
if err := tx.Model(&model.EventLog{}).
|
if err := tx.Model(&model.EventLog{}).
|
||||||
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff).
|
Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
Select("id", "user_id", "account_id", "cost").
|
Select("id", "user_id", "account_id", "cost").
|
||||||
Scan(&stale).Error; err != nil {
|
Scan(&stale).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -470,7 +508,7 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return tx.Model(&model.EventLog{}).
|
return tx.Model(&model.EventLog{}).
|
||||||
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff).
|
Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
|
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
|
||||||
@@ -618,9 +656,16 @@ func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (boo
|
|||||||
return res.RowsAffected == 1, nil
|
return res.RowsAffected == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAccount stamps which provider account is fulfilling an in-flight event.
|
// SetUpstreamJob records the job id the upstream assigned (NDJSON jobId frame)
|
||||||
// Called when generation commits to a token, so the accounts view can count
|
// for cross-instance debugging. Best-effort.
|
||||||
// pending events per account and an abandoned-event purge can attribute back.
|
func (r *EventRepository) SetUpstreamJob(ctx context.Context, eventID, jobID string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.EventLog{}).
|
||||||
|
Where("id = ?", eventID).
|
||||||
|
Update("upstream_job", jobID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAccount stamps which channel is fulfilling an in-flight event.
|
||||||
func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, accountEmail string) error {
|
func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, accountEmail string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.EventLog{}).
|
Model(&model.EventLog{}).
|
||||||
@@ -628,6 +673,29 @@ func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, ac
|
|||||||
Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error
|
Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FailedByAccount counts failed events grouped by account_id (channel id) —
|
||||||
|
// the channel list's 失败数 column.
|
||||||
|
func (r *EventRepository) FailedByAccount(ctx context.Context) (map[string]int64, error) {
|
||||||
|
type row struct {
|
||||||
|
AccountID string `gorm:"column:account_id"`
|
||||||
|
Count int64 `gorm:"column:count"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.EventLog{}).
|
||||||
|
Select("account_id, COUNT(*) AS count").
|
||||||
|
Where("status = ? AND account_id <> ''", "failed").
|
||||||
|
Group("account_id").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]int64, len(rows))
|
||||||
|
for _, item := range rows {
|
||||||
|
out[item.AccountID] = item.Count
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InFlightByAccount counts pending (in-flight) events grouped by account_id, for
|
// InFlightByAccount counts pending (in-flight) events grouped by account_id, for
|
||||||
// the accounts view's live "in-flight" column.
|
// the accounts view's live "in-flight" column.
|
||||||
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
|
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.Model
|
|||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAny includes soft-deleted rows — the import/revive path and orphan
|
||||||
|
// cleanup need to see rows normal queries hide.
|
||||||
|
func (r *ModelRepository) GetAny(ctx context.Context, modelID string) (*model.ModelConfig, error) {
|
||||||
|
var item model.ModelConfig
|
||||||
|
if err := r.db.WithContext(ctx).Unscoped().First(&item, "(alias <> '' AND alias = ?) OR (alias = '' AND id = ?)", modelID, modelID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ModelRepository) NameMap(ctx context.Context) (map[string]string, error) {
|
func (r *ModelRepository) NameMap(ctx context.Context) (map[string]string, error) {
|
||||||
items, err := r.List(ctx)
|
items, err := r.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,18 +82,40 @@ func (r *ModelRepository) Create(ctx context.Context, item *model.ModelConfig) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ModelRepository) Update(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) {
|
func (r *ModelRepository) Update(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) {
|
||||||
|
return r.update(ctx, modelID, patch, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAny can reach soft-deleted rows (revive path: patch deleted_at=NULL).
|
||||||
|
func (r *ModelRepository) UpdateAny(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) {
|
||||||
|
return r.update(ctx, modelID, patch, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ModelRepository) update(ctx context.Context, modelID string, patch map[string]any, anyState bool) (*model.ModelConfig, error) {
|
||||||
patch["updated_at"] = time.Now()
|
patch["updated_at"] = time.Now()
|
||||||
if err := r.db.WithContext(ctx).Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil {
|
q := r.db.WithContext(ctx)
|
||||||
|
if anyState {
|
||||||
|
q = q.Unscoped()
|
||||||
|
}
|
||||||
|
if err := q.Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var item model.ModelConfig
|
var item model.ModelConfig
|
||||||
if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil {
|
if err := q.First(&item, "id = ?", modelID).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete soft-deletes the model (gorm.DeletedAt): the row keeps its counters
|
||||||
|
// and pricing so re-importing it later revives the same record.
|
||||||
func (r *ModelRepository) Delete(ctx context.Context, modelID string) (int64, error) {
|
func (r *ModelRepository) Delete(ctx context.Context, modelID string) (int64, error) {
|
||||||
res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID)
|
res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID)
|
||||||
return res.RowsAffected, res.Error
|
return res.RowsAffected, res.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteHard physically removes the row — only for legacy/provider-era rows
|
||||||
|
// that must not linger as soft-deleted junk.
|
||||||
|
func (r *ModelRepository) DeleteHard(ctx context.Context, modelID string) (int64, error) {
|
||||||
|
res := r.db.WithContext(ctx).Unscoped().Delete(&model.ModelConfig{}, "id = ?", modelID)
|
||||||
|
return res.RowsAffected, res.Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
package repo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backend/internal/model"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RefreshProfileRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRefreshProfileRepository(db *gorm.DB) *RefreshProfileRepository {
|
|
||||||
return &RefreshProfileRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RefreshProfileRepository) List(ctx context.Context) ([]model.RefreshProfile, error) {
|
|
||||||
var items []model.RefreshProfile
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Order("created_at desc").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RefreshProfileRepository) Get(ctx context.Context, id string) (*model.RefreshProfile, error) {
|
|
||||||
var item model.RefreshProfile
|
|
||||||
if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RefreshProfileRepository) Create(ctx context.Context, item *model.RefreshProfile) error {
|
|
||||||
return r.db.WithContext(ctx).Create(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RefreshProfileRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.RefreshProfile, error) {
|
|
||||||
patch["updated_at"] = time.Now()
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.RefreshProfile{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Updates(patch).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return r.Get(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RefreshProfileRepository) Delete(ctx context.Context, id string) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id = ?", id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RefreshProfileRepository) DeleteByIDs(ctx context.Context, ids []string) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id IN ?", ids).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListDue returns enabled profiles whose next_retry_at has passed (or is unset,
|
|
||||||
// e.g. freshly imported). The background maintenance loop refreshes these.
|
|
||||||
func (r *RefreshProfileRepository) ListDue(ctx context.Context, now time.Time) ([]model.RefreshProfile, error) {
|
|
||||||
var items []model.RefreshProfile
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("enabled = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", true, now).
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
@@ -63,7 +63,6 @@ func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.Sh
|
|||||||
|
|
||||||
grouped := map[string][]model.ShowcaseItem{
|
grouped := map[string][]model.ShowcaseItem{
|
||||||
"hero": {},
|
"hero": {},
|
||||||
"bento": {},
|
|
||||||
"work": {},
|
"work": {},
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@@ -1,529 +0,0 @@
|
|||||||
package repo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backend/internal/model"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TokenRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTokenRepository(db *gorm.DB) *TokenRepository {
|
|
||||||
return &TokenRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TokenRepository) List(ctx context.Context) ([]model.TokenAccount, error) {
|
|
||||||
var items []model.TokenAccount
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Order("pool asc, created_at desc").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TokenRepository) ListByPool(ctx context.Context, pool string) ([]model.TokenAccount, error) {
|
|
||||||
var items []model.TokenAccount
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("pool = ?", pool).
|
|
||||||
Order("created_at desc").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TokenRepository) Get(ctx context.Context, pool, id string) (*model.TokenAccount, error) {
|
|
||||||
var item model.TokenAccount
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
First(&item, "pool = ? AND id = ?", pool, id).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByPoolEmail finds an account in a pool by its account_email (the logical
|
|
||||||
// identity for import dedup). Returns (nil, nil) when none / email is blank.
|
|
||||||
func (r *TokenRepository) GetByPoolEmail(ctx context.Context, pool, email string) (*model.TokenAccount, error) {
|
|
||||||
email = strings.TrimSpace(email)
|
|
||||||
if email == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var item model.TokenAccount
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Where("pool = ? AND account_email = ?", pool, email).
|
|
||||||
First(&item).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TokenRepository) Create(ctx context.Context, item *model.TokenAccount) error {
|
|
||||||
return r.db.WithContext(ctx).Create(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TokenRepository) Update(ctx context.Context, pool, id string, patch map[string]any) (*model.TokenAccount, error) {
|
|
||||||
patch["updated_at"] = time.Now()
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.TokenAccount{}).
|
|
||||||
Where("pool = ? AND id = ?", pool, id).
|
|
||||||
Updates(patch).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return r.Get(ctx, pool, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SwapValue replaces an account's credential only while the stored one is still
|
|
||||||
// the value the caller started from. A rotating cookie is minted from whatever
|
|
||||||
// was in the row, so a goroutine that has been holding an older copy (a long
|
|
||||||
// render, a slow quota probe) must NOT be allowed to write it back over a newer
|
|
||||||
// rotation — that older copy no longer authenticates, and the account then looks
|
|
||||||
// dead. Reports whether the row was updated.
|
|
||||||
func (r *TokenRepository) SwapValue(ctx context.Context, pool, id, from, to string) (bool, error) {
|
|
||||||
res := r.db.WithContext(ctx).
|
|
||||||
Model(&model.TokenAccount{}).
|
|
||||||
Where("pool = ? AND id = ? AND value = ?", pool, id, from).
|
|
||||||
Updates(map[string]any{"value": to, "updated_at": time.Now()})
|
|
||||||
if res.Error != nil {
|
|
||||||
return false, res.Error
|
|
||||||
}
|
|
||||||
return res.RowsAffected > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveQuota atomically pre-deducts `amount` from an account's cached image
|
|
||||||
// token balance under a row lock, so concurrent picks of the same near-empty
|
|
||||||
// account can never over-commit it. Returns:
|
|
||||||
// - allowed=true, deducted=true: balance was known and ≥ amount → decremented.
|
|
||||||
// - allowed=true, deducted=false: balance unknown → allowed without a hold
|
|
||||||
// (benefit of the doubt; a post-render reconcile writes the real value).
|
|
||||||
// - allowed=false: balance known and < amount → caller should fail over.
|
|
||||||
// RefundQuota releases a hold made with deducted=true when the render fails.
|
|
||||||
func (r *TokenRepository) ReserveQuota(ctx context.Context, pool, id string, amount int) (allowed, deducted bool, err error) {
|
|
||||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var item model.TokenAccount
|
|
||||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
rem, known := metaInt(item.Meta, "cached_quota_remaining")
|
|
||||||
if !known {
|
|
||||||
allowed, deducted = true, false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if rem < amount {
|
|
||||||
allowed, deducted = false, false
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
meta := cloneMeta(item.Meta)
|
|
||||||
meta["cached_quota_remaining"] = rem - amount
|
|
||||||
if e := tx.Model(&model.TokenAccount{}).
|
|
||||||
Where("pool = ? AND id = ?", pool, id).
|
|
||||||
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error; e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
allowed, deducted = true, true
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return allowed, deducted, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grok accounts carry a forced local quota instead of an upstream balance:
|
|
||||||
// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,下单预扣、失败退回,两个都归零直接判死。
|
|
||||||
const (
|
|
||||||
GrokImageQuotaKey = "grok_image_remaining"
|
|
||||||
GrokVideoQuotaKey = "grok_video_remaining"
|
|
||||||
GrokImageQuota = 5
|
|
||||||
GrokVideoQuota = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
// grokQuotas reads an account's local per-kind counters, falling back to the
|
|
||||||
// forced defaults for accounts imported before the counters existed.
|
|
||||||
func grokQuotas(meta datatypes.JSONMap) (images, videos int) {
|
|
||||||
images, known := metaInt(meta, GrokImageQuotaKey)
|
|
||||||
if !known {
|
|
||||||
images = GrokImageQuota
|
|
||||||
}
|
|
||||||
videos, known = metaInt(meta, GrokVideoQuotaKey)
|
|
||||||
if !known {
|
|
||||||
videos = GrokVideoQuota
|
|
||||||
}
|
|
||||||
return images, videos
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveGrokQuota pre-deducts one unit of a grok account's local per-kind quota
|
|
||||||
// under a row lock, so concurrent orders on the same near-empty account can't
|
|
||||||
// over-commit it(下单即扣,不会超扣)。allowed=false 表示这一份已经用光,调用方换号。
|
|
||||||
// 归零的那一份打上 image_limited / video_limited 让调度跳过;判死留给
|
|
||||||
// FinalizeGrokQuota(生成真的成功了才锁号),失败时用 RefundGrokQuota 退回。
|
|
||||||
func (r *TokenRepository) ReserveGrokQuota(ctx context.Context, id, kind string) (allowed bool, err error) {
|
|
||||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var item model.TokenAccount
|
|
||||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
images, videos := grokQuotas(item.Meta)
|
|
||||||
if kind == "video" {
|
|
||||||
if videos <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
videos--
|
|
||||||
} else {
|
|
||||||
if images <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
images--
|
|
||||||
}
|
|
||||||
meta := cloneMeta(item.Meta)
|
|
||||||
meta[GrokImageQuotaKey] = images
|
|
||||||
meta[GrokVideoQuotaKey] = videos
|
|
||||||
if e := tx.Model(&model.TokenAccount{}).
|
|
||||||
Where("pool = ? AND id = ?", "grok", id).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"meta": meta,
|
|
||||||
"image_limited": images <= 0,
|
|
||||||
"video_limited": videos <= 0,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
}).Error; e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
allowed = true
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return allowed, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefundGrokQuota gives back a unit reserved by ReserveGrokQuota when the render
|
|
||||||
// failed, clearing that kind's limited flag again. Capped at the forced default so
|
|
||||||
// repeated refunds can't inflate the quota.
|
|
||||||
func (r *TokenRepository) RefundGrokQuota(ctx context.Context, id, kind string) error {
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var item model.TokenAccount
|
|
||||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
images, videos := grokQuotas(item.Meta)
|
|
||||||
if kind == "video" {
|
|
||||||
videos = min(videos+1, GrokVideoQuota)
|
|
||||||
} else {
|
|
||||||
images = min(images+1, GrokImageQuota)
|
|
||||||
}
|
|
||||||
meta := cloneMeta(item.Meta)
|
|
||||||
meta[GrokImageQuotaKey] = images
|
|
||||||
meta[GrokVideoQuotaKey] = videos
|
|
||||||
return tx.Model(&model.TokenAccount{}).
|
|
||||||
Where("pool = ? AND id = ?", "grok", id).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"meta": meta,
|
|
||||||
"image_limited": images <= 0,
|
|
||||||
"video_limited": videos <= 0,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// FinalizeGrokQuota locks an account down once a successful generation left both
|
|
||||||
// local counters at zero (no reset time — 用完就废).
|
|
||||||
func (r *TokenRepository) FinalizeGrokQuota(ctx context.Context, id string) error {
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var item model.TokenAccount
|
|
||||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
images, videos := grokQuotas(item.Meta)
|
|
||||||
if images > 0 || videos > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return tx.Model(&model.TokenAccount{}).
|
|
||||||
Where("pool = ? AND id = ?", "grok", id).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"status": "disabled",
|
|
||||||
"dead": true,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefundQuota atomically adds `amount` back to cached_quota_remaining (releasing a
|
|
||||||
// hold from a reservation whose render then failed). No-op if the balance is
|
|
||||||
// unknown. Row-locked like ReserveQuota.
|
|
||||||
func (r *TokenRepository) RefundQuota(ctx context.Context, pool, id string, amount int) error {
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
var item model.TokenAccount
|
|
||||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
rem, known := metaInt(item.Meta, "cached_quota_remaining")
|
|
||||||
if !known {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
meta := cloneMeta(item.Meta)
|
|
||||||
meta["cached_quota_remaining"] = rem + amount
|
|
||||||
return tx.Model(&model.TokenAccount{}).
|
|
||||||
Where("pool = ? AND id = ?", pool, id).
|
|
||||||
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneMeta(m datatypes.JSONMap) datatypes.JSONMap {
|
|
||||||
out := datatypes.JSONMap{}
|
|
||||||
for k, v := range m {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func metaInt(m datatypes.JSONMap, key string) (int, bool) {
|
|
||||||
if m == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v, ok := m[key]
|
|
||||||
if !ok || v == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
switch x := v.(type) {
|
|
||||||
case int:
|
|
||||||
return x, true
|
|
||||||
case int64:
|
|
||||||
return int(x), true
|
|
||||||
case float64:
|
|
||||||
return int(x), true
|
|
||||||
case json.Number:
|
|
||||||
n, e := x.Int64()
|
|
||||||
if e != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return int(n), true
|
|
||||||
case string:
|
|
||||||
n, e := strconv.Atoi(strings.TrimSpace(x))
|
|
||||||
if e != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return n, true
|
|
||||||
default:
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TouchLastUsed stamps last_used_at at the moment a token is SELECTED, so the
|
|
||||||
// accounts view reflects an accurate "last used" time. Rotation order is driven
|
|
||||||
// by the in-memory strict round-robin cursor in the service layer (see
|
|
||||||
// V1Service.rotateRoundRobin), not by this timestamp.
|
|
||||||
func (r *TokenRepository) TouchLastUsed(ctx context.Context, id string) error {
|
|
||||||
return r.db.WithContext(ctx).
|
|
||||||
Model(&model.TokenAccount{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Update("last_used_at", time.Now()).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrementFail bumps an account's failure counters by one. Used to attribute
|
|
||||||
// an abandoned (purged) generation's failure back to the account it was using,
|
|
||||||
// since that generation never reached the normal markTokenFailure path.
|
|
||||||
func (r *TokenRepository) IncrementFail(ctx context.Context, id string) error {
|
|
||||||
return r.db.WithContext(ctx).
|
|
||||||
Model(&model.TokenAccount{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"fail_total": gorm.Expr("fail_total + 1"),
|
|
||||||
"fails": gorm.Expr("fails + 1"),
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TokenRepository) Delete(ctx context.Context, pool, id string) (int64, error) {
|
|
||||||
res := r.db.WithContext(ctx).
|
|
||||||
Delete(&model.TokenAccount{}, "pool = ? AND id = ?", pool, id)
|
|
||||||
return res.RowsAffected, res.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteByIDs removes accounts by id across pools (ids are globally unique),
|
|
||||||
// for bulk delete. Returns the number of rows removed.
|
|
||||||
func (r *TokenRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
res := r.db.WithContext(ctx).Delete(&model.TokenAccount{}, "id IN ?", ids)
|
|
||||||
return res.RowsAffected, res.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// leonardoDailyTokens is the free-tier daily allowance restored at each reset.
|
|
||||||
// A paid account's true balance is reconciled on its next successful render.
|
|
||||||
const leonardoDailyTokens = 150
|
|
||||||
|
|
||||||
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed.
|
|
||||||
// Reset source: cached_quota_reset_after (upstream marker) first, else the
|
|
||||||
// quota_recover_at fallback stamped when the token was marked quota-exhausted.
|
|
||||||
// Mirrors Python TokenPool.recover_quota; returns the count reactivated.
|
|
||||||
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed and
|
|
||||||
// returns the accounts it recovered, so the caller can re-sync their real balance
|
|
||||||
// (the providers only sync quota when accessed).
|
|
||||||
func (r *TokenRepository) RecoverQuota(ctx context.Context) ([]model.TokenAccount, error) {
|
|
||||||
// Also pick up accounts that are only single-kind limited (image_limited /
|
|
||||||
// video_limited) — those keep status "active" and would otherwise never have
|
|
||||||
// their per-kind flag cleared. This is legacy support; Adobe no longer sets
|
|
||||||
// per-kind limits.
|
|
||||||
var items []model.TokenAccount
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("status = ? OR image_limited = ? OR video_limited = ?", "quota", true, true).
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
var recovered []model.TokenAccount
|
|
||||||
for i := range items {
|
|
||||||
t := &items[i]
|
|
||||||
// Runway's reset marker is the JWT expiry, not a quota-refresh time, and
|
|
||||||
// there's no way to refresh a bare JWT — so a runway account is never
|
|
||||||
// "recovered"; it's expired-to-dead by ExpireByReset instead.
|
|
||||||
if t.Pool == "runway" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
|
||||||
if reset == nil {
|
|
||||||
reset = t.QuotaRecoverAt
|
|
||||||
}
|
|
||||||
if reset == nil || now.Before(*reset) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
patch := map[string]any{
|
|
||||||
"fails": 0,
|
|
||||||
"quota_recover_at": nil,
|
|
||||||
}
|
|
||||||
// For Adobe, also clear any legacy per-kind limit flags.
|
|
||||||
if t.Pool == "adobe" {
|
|
||||||
patch["image_limited"] = false
|
|
||||||
patch["video_limited"] = false
|
|
||||||
}
|
|
||||||
// Only flip status back to active if it was sunk to "quota" (both kinds
|
|
||||||
// limited); a single-kind limit left status untouched.
|
|
||||||
if t.Status == "quota" {
|
|
||||||
patch["status"] = "active"
|
|
||||||
}
|
|
||||||
// Leonardo's free tokens fully renew at each daily reset — restore the
|
|
||||||
// balance and advance the reset marker to the next 08:00 Beijing (== next
|
|
||||||
// UTC midnight), so the account is immediately usable instead of stuck at a
|
|
||||||
// stale 0. A paid account's real balance is corrected on its next render.
|
|
||||||
if t.Pool == "leonardo" || t.Pool == "krea" || t.Pool == "imagine" {
|
|
||||||
meta := cloneMeta(t.Meta)
|
|
||||||
if t.Pool == "leonardo" {
|
|
||||||
meta["cached_quota_remaining"] = leonardoDailyTokens
|
|
||||||
} else {
|
|
||||||
// Krea/Imagine balances re-sync from upstream (billing-data / v1/credit)
|
|
||||||
// on next probe — drop the stale value so the account isn't shown as
|
|
||||||
// empty after reset.
|
|
||||||
delete(meta, "cached_quota_remaining")
|
|
||||||
}
|
|
||||||
meta["cached_quota_at"] = int(now.Unix())
|
|
||||||
patch["meta"] = meta
|
|
||||||
patch["cached_quota_reset_after"] = time.Unix((now.Unix()/86400+1)*86400, 0).UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
if _, err := r.Update(ctx, t.Pool, t.ID, patch); err != nil {
|
|
||||||
return recovered, err
|
|
||||||
}
|
|
||||||
recovered = append(recovered, *t)
|
|
||||||
}
|
|
||||||
return recovered, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RollResetMarkers advances a stale (past) daily-reset marker to its next future
|
|
||||||
// occurrence — same time-of-day, +N whole days — for ACTIVE accounts of the given
|
|
||||||
// daily-reset pools, so the 恢复时间 column always shows the upcoming reset rather
|
|
||||||
// than yesterday's. Only active accounts are rolled: a 限额 account must keep its
|
|
||||||
// past marker so RecoverQuota can recover it (rolling it forward early would
|
|
||||||
// prevent recovery). Returns the number advanced.
|
|
||||||
func (r *TokenRepository) RollResetMarkers(ctx context.Context, pools []string) (int, error) {
|
|
||||||
var items []model.TokenAccount
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("pool IN ? AND dead = ? AND status = ? AND image_limited = ? AND video_limited = ? AND cached_quota_reset_after <> ''",
|
|
||||||
pools, false, "active", false, false).
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
n := 0
|
|
||||||
for i := range items {
|
|
||||||
t := &items[i]
|
|
||||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
|
||||||
if reset == nil || !reset.Before(now) {
|
|
||||||
continue // unparseable or already in the future
|
|
||||||
}
|
|
||||||
next := *reset
|
|
||||||
for !next.After(now) {
|
|
||||||
next = next.Add(24 * time.Hour)
|
|
||||||
}
|
|
||||||
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
|
|
||||||
"cached_quota_reset_after": next.UTC().Format(time.RFC3339),
|
|
||||||
}); err != nil {
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExpireByReset marks accounts of a pool dead once their reset marker has passed.
|
|
||||||
// For runway the marker IS the JWT expiry and there's no refresh, so an expired
|
|
||||||
// token can only 401 — we proactively flip it to disabled+dead (the same end
|
|
||||||
// state a 401 would produce) instead of leaving a doomed account "active".
|
|
||||||
func (r *TokenRepository) ExpireByReset(ctx context.Context, pool string) (int, error) {
|
|
||||||
var items []model.TokenAccount
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("pool = ? AND dead = ?", pool, false).
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
expired := 0
|
|
||||||
for i := range items {
|
|
||||||
t := &items[i]
|
|
||||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
|
||||||
if reset == nil || now.Before(*reset) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
|
|
||||||
"status": "disabled",
|
|
||||||
"dead": true,
|
|
||||||
}); err != nil {
|
|
||||||
return expired, err
|
|
||||||
}
|
|
||||||
expired++
|
|
||||||
}
|
|
||||||
return expired, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseResetMarker best-effort parses a quota reset marker into a time. Accepts
|
|
||||||
// epoch seconds (numeric string) or ISO-8601 (e.g. Adobe's available_until
|
|
||||||
// "2026-06-16T23:59:59.999Z"). Returns nil if unparseable.
|
|
||||||
func parseResetMarker(v string) *time.Time {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 946684800 {
|
|
||||||
t := time.Unix(int64(f), 0)
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.999Z07:00", "2006-01-02T15:04:05Z07:00"} {
|
|
||||||
if t, err := time.Parse(layout, v); err == nil {
|
|
||||||
return &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -19,20 +19,20 @@ type AdminReadService struct {
|
|||||||
models *repo.ModelRepository
|
models *repo.ModelRepository
|
||||||
events *repo.EventRepository
|
events *repo.EventRepository
|
||||||
settings *repo.SiteSettingRepository
|
settings *repo.SiteSettingRepository
|
||||||
tokens *repo.TokenRepository
|
channels *repo.ChannelRepository
|
||||||
cdks *repo.CDKRepository
|
cdks *repo.CDKRepository
|
||||||
store *storage.Client
|
store *storage.Client
|
||||||
showcase *repo.ShowcaseRepository
|
showcase *repo.ShowcaseRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, channels *repo.ChannelRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
||||||
return &AdminReadService{
|
return &AdminReadService{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
users: users,
|
users: users,
|
||||||
models: models,
|
models: models,
|
||||||
events: events,
|
events: events,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
tokens: tokens,
|
channels: channels,
|
||||||
cdks: cdks,
|
cdks: cdks,
|
||||||
store: store,
|
store: store,
|
||||||
showcase: showcase,
|
showcase: showcase,
|
||||||
@@ -84,15 +84,20 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
routable, _ := s.channels.RoutableModelIDs(ctx)
|
||||||
|
channelNames, _ := s.channels.ChannelNamesByModel(ctx)
|
||||||
out := make([]map[string]any, 0, len(items))
|
out := make([]map[string]any, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
_, bound := routable[item.ID]
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
"alias": item.Alias,
|
"alias": item.Alias,
|
||||||
"type": item.Type,
|
"type": item.Type,
|
||||||
"name": item.Name,
|
"name": item.Name,
|
||||||
"provider": item.Provider,
|
"provider": item.Provider,
|
||||||
"enabled": item.Enabled,
|
"channels": channelNames[item.ID],
|
||||||
|
// 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
|
||||||
|
"sellable": bound && ModelFullyPriced(&item),
|
||||||
"ratios": repo.JSONStrings(item.Ratios),
|
"ratios": repo.JSONStrings(item.Ratios),
|
||||||
"prices": map[string]any(item.Prices),
|
"prices": map[string]any(item.Prices),
|
||||||
"resolutions": repo.JSONStrings(item.Resolutions),
|
"resolutions": repo.JSONStrings(item.Resolutions),
|
||||||
@@ -102,6 +107,12 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
|||||||
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
||||||
"durations": repo.JSONStrings(item.Durations),
|
"durations": repo.JSONStrings(item.Durations),
|
||||||
"max_reference_images": item.MaxReferenceImages,
|
"max_reference_images": item.MaxReferenceImages,
|
||||||
|
"max_reference_videos": item.MaxReferenceVideos,
|
||||||
|
"max_reference_audios": item.MaxReferenceAudios,
|
||||||
|
"duration_min": item.DurationMin,
|
||||||
|
"duration_max": item.DurationMax,
|
||||||
|
"max_ref_video_seconds": item.MaxRefVideoSeconds,
|
||||||
|
"max_ref_audio_seconds": item.MaxRefAudioSeconds,
|
||||||
"reference_mode": item.ReferenceMode,
|
"reference_mode": item.ReferenceMode,
|
||||||
"weight": item.Weight,
|
"weight": item.Weight,
|
||||||
"generation_count": item.GenerationCount,
|
"generation_count": item.GenerationCount,
|
||||||
@@ -113,6 +124,15 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
||||||
|
return s.logs(ctx, limit, offset, kind, status, statuses, since, userID, userIDs, nil, query, excludeSource, source, hasFile, excludeShowcase, mediaOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogsKeyword 单搜索框:keyword 命中 提示词/模型/错误,或 user_id 在 kwUserIDs 内(名称/邮箱/ID 命中)即保留。
|
||||||
|
func (s *AdminReadService) LogsKeyword(ctx context.Context, limit, offset int, kind, status string, since *time.Time, keyword string, kwUserIDs []string, source string) ([]model.EventLog, int64, *repo.EventStats, error) {
|
||||||
|
return s.logs(ctx, limit, offset, kind, status, nil, since, "", nil, kwUserIDs, keyword, "", source, false, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminReadService) logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs, kwUserIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
||||||
var excludeFiles []string
|
var excludeFiles []string
|
||||||
if excludeShowcase {
|
if excludeShowcase {
|
||||||
excludeFiles = s.showcaseFileList(ctx)
|
excludeFiles = s.showcaseFileList(ctx)
|
||||||
@@ -127,6 +147,7 @@ func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, st
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
UserIDs: userIDs,
|
UserIDs: userIDs,
|
||||||
Query: query,
|
Query: query,
|
||||||
|
KeywordUserIDs: kwUserIDs,
|
||||||
ExcludeSource: excludeSource,
|
ExcludeSource: excludeSource,
|
||||||
Source: source,
|
Source: source,
|
||||||
HasFile: hasFile,
|
HasFile: hasFile,
|
||||||
@@ -194,24 +215,24 @@ func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountNameMap builds a token-account id -> display label lookup (account
|
// AccountNameMap builds a channel id -> display name lookup used to annotate
|
||||||
// email, else display name, else id) used to annotate log rows with which
|
// log rows with which channel fulfilled each generation (event_logs.account_id
|
||||||
// provider account fulfilled each generation (event_logs.account_id).
|
// now stores the channel id; account_email carries a denormalized name stamp).
|
||||||
func (s *AdminReadService) AccountNameMap(ctx context.Context) (map[string]string, error) {
|
func (s *AdminReadService) AccountNameMap(ctx context.Context) (map[string]string, error) {
|
||||||
accounts, err := s.tokens.List(ctx)
|
if s.channels == nil {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
channels, err := s.channels.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make(map[string]string, len(accounts))
|
out := make(map[string]string, len(channels))
|
||||||
for _, a := range accounts {
|
for _, ch := range channels {
|
||||||
label := strings.TrimSpace(a.AccountEmail)
|
label := strings.TrimSpace(ch.Name)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = strings.TrimSpace(a.AccountDisplayName)
|
label = ch.ID
|
||||||
}
|
}
|
||||||
if label == "" {
|
out[ch.ID] = label
|
||||||
label = a.ID
|
|
||||||
}
|
|
||||||
out[a.ID] = label
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -420,66 +441,30 @@ func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *r
|
|||||||
return s.users.AllInvites(ctx, reward)
|
return s.users.AllInvites(ctx, reward)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Providers is now a channel summary (the account pools are gone): one row per
|
||||||
|
// channel with its bound-model count and local usage stats.
|
||||||
func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) {
|
func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) {
|
||||||
models, err := s.models.List(ctx)
|
if s.channels == nil {
|
||||||
|
return []map[string]any{}, nil
|
||||||
|
}
|
||||||
|
channels, err := s.channels.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tokens, err := s.tokens.List(ctx)
|
out := make([]map[string]any, 0, len(channels))
|
||||||
if err != nil {
|
for _, ch := range channels {
|
||||||
return nil, err
|
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, ch.ID)
|
||||||
}
|
|
||||||
modelCounts := map[string]int{}
|
|
||||||
for _, item := range models {
|
|
||||||
modelCounts[item.Provider]++
|
|
||||||
}
|
|
||||||
type aggregate struct {
|
|
||||||
active int
|
|
||||||
disabled int
|
|
||||||
quota int
|
|
||||||
}
|
|
||||||
tokenCounts := map[string]*aggregate{}
|
|
||||||
for _, item := range tokens {
|
|
||||||
if _, ok := tokenCounts[item.Pool]; !ok {
|
|
||||||
tokenCounts[item.Pool] = &aggregate{}
|
|
||||||
}
|
|
||||||
switch item.Status {
|
|
||||||
case "active":
|
|
||||||
tokenCounts[item.Pool].active++
|
|
||||||
case "quota":
|
|
||||||
tokenCounts[item.Pool].quota++
|
|
||||||
default:
|
|
||||||
tokenCounts[item.Pool].disabled++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
providers := []struct {
|
|
||||||
Name string
|
|
||||||
Pool string
|
|
||||||
Type string
|
|
||||||
}{
|
|
||||||
{Name: "chatgpt", Pool: "chatgpt", Type: "openai"},
|
|
||||||
{Name: "adobe", Pool: "adobe", Type: "adobe"},
|
|
||||||
{Name: "runway", Pool: "runway", Type: "runway"},
|
|
||||||
{Name: "leonardo", Pool: "leonardo", Type: "leonardo"},
|
|
||||||
{Name: "krea", Pool: "krea", Type: "krea"},
|
|
||||||
{Name: "imagine", Pool: "imagine", Type: "imagine"},
|
|
||||||
{Name: "grok", Pool: "grok", Type: "grok"},
|
|
||||||
}
|
|
||||||
out := make([]map[string]any, 0, len(providers))
|
|
||||||
for _, item := range providers {
|
|
||||||
count := tokenCounts[item.Pool]
|
|
||||||
if count == nil {
|
|
||||||
count = &aggregate{}
|
|
||||||
}
|
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"name": item.Name,
|
"id": ch.ID,
|
||||||
"token_pool": item.Pool,
|
"name": ch.Name,
|
||||||
"type": item.Type,
|
"base_url": ch.BaseURL,
|
||||||
"model_count": modelCounts[item.Name],
|
"enabled": ch.Enabled,
|
||||||
"tokens_total": count.active + count.disabled + count.quota,
|
"concurrency": ch.Concurrency,
|
||||||
"tokens_active": count.active,
|
"weight": ch.Weight,
|
||||||
"tokens_disabled": count.disabled,
|
"model_count": len(modelIDs),
|
||||||
"tokens_quota": count.quota,
|
"success_total": ch.SuccessTotal,
|
||||||
|
"used_credits": ch.UsedCredits,
|
||||||
|
"type": "vivid",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -28,18 +28,18 @@ type AdminWriteService struct {
|
|||||||
models *repo.ModelRepository
|
models *repo.ModelRepository
|
||||||
events *repo.EventRepository
|
events *repo.EventRepository
|
||||||
apiKeys *repo.APIKeyRepository
|
apiKeys *repo.APIKeyRepository
|
||||||
tokens *repo.TokenRepository
|
channels *repo.ChannelRepository
|
||||||
orders *repo.OrderRepository
|
orders *repo.OrderRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, tokens *repo.TokenRepository, orders *repo.OrderRepository) *AdminWriteService {
|
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, channels *repo.ChannelRepository, orders *repo.OrderRepository) *AdminWriteService {
|
||||||
return &AdminWriteService{
|
return &AdminWriteService{
|
||||||
users: users,
|
users: users,
|
||||||
showcase: showcase,
|
showcase: showcase,
|
||||||
models: models,
|
models: models,
|
||||||
events: events,
|
events: events,
|
||||||
apiKeys: apiKeys,
|
apiKeys: apiKeys,
|
||||||
tokens: tokens,
|
channels: channels,
|
||||||
orders: orders,
|
orders: orders,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +292,7 @@ func (s *AdminWriteService) DeleteUserAPIKey(ctx context.Context, userID, keyID
|
|||||||
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
|
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
|
||||||
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
return nil, errors.New("kind must be hero, bento or work")
|
return nil, errors.New("kind must be hero or work")
|
||||||
}
|
}
|
||||||
image := strings.TrimSpace(stringValue(body["image"]))
|
image := strings.TrimSpace(stringValue(body["image"]))
|
||||||
if image == "" {
|
if image == "" {
|
||||||
@@ -333,7 +333,7 @@ func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string,
|
|||||||
if _, ok := body["kind"]; ok {
|
if _, ok := body["kind"]; ok {
|
||||||
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
return nil, errors.New("kind must be hero, bento or work")
|
return nil, errors.New("kind must be hero or work")
|
||||||
}
|
}
|
||||||
patch["kind"] = kind
|
patch["kind"] = kind
|
||||||
}
|
}
|
||||||
@@ -371,7 +371,7 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
|||||||
return nil, errors.New("type must be image or video")
|
return nil, errors.New("type must be image or video")
|
||||||
}
|
}
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
return nil, errors.New("provider required")
|
provider = "channel"
|
||||||
}
|
}
|
||||||
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
|
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -391,7 +391,6 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
|||||||
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
||||||
Alias: alias,
|
Alias: alias,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Enabled: boolValueWithDefault(body["enabled"], true),
|
|
||||||
Ratios: jsonArray(body["ratios"]),
|
Ratios: jsonArray(body["ratios"]),
|
||||||
Prices: prices,
|
Prices: prices,
|
||||||
Resolutions: resolutions,
|
Resolutions: resolutions,
|
||||||
@@ -401,6 +400,10 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
|||||||
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
|
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
|
||||||
Durations: jsonArray(body["durations"]),
|
Durations: jsonArray(body["durations"]),
|
||||||
MaxReferenceImages: intValue(body["max_reference_images"]),
|
MaxReferenceImages: intValue(body["max_reference_images"]),
|
||||||
|
MaxReferenceVideos: intValue(body["max_reference_videos"]),
|
||||||
|
MaxReferenceAudios: intValue(body["max_reference_audios"]),
|
||||||
|
MaxRefVideoSeconds: intValue(body["max_ref_video_seconds"]),
|
||||||
|
MaxRefAudioSeconds: intValue(body["max_ref_audio_seconds"]),
|
||||||
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
||||||
Weight: intValue(body["weight"]),
|
Weight: intValue(body["weight"]),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -434,17 +437,9 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
|||||||
}
|
}
|
||||||
if _, ok := body["provider"]; ok {
|
if _, ok := body["provider"]; ok {
|
||||||
provider := strings.TrimSpace(stringValue(body["provider"]))
|
provider := strings.TrimSpace(stringValue(body["provider"]))
|
||||||
if provider == "" {
|
if provider != "" {
|
||||||
return nil, errors.New("provider required")
|
|
||||||
}
|
|
||||||
patch["provider"] = provider
|
patch["provider"] = provider
|
||||||
}
|
}
|
||||||
// Only touch `enabled` when the caller explicitly sends a non-null value;
|
|
||||||
// mirrors Python models_store.update ("enabled" in fields and is not None).
|
|
||||||
// Without this guard a PATCH that omits the field would default it to false
|
|
||||||
// and silently disable the model.
|
|
||||||
if raw, ok := body["enabled"]; ok && raw != nil {
|
|
||||||
patch["enabled"] = boolValueWithDefault(raw, true)
|
|
||||||
}
|
}
|
||||||
if _, ok := body["ratios"]; ok {
|
if _, ok := body["ratios"]; ok {
|
||||||
patch["ratios"] = jsonArray(body["ratios"])
|
patch["ratios"] = jsonArray(body["ratios"])
|
||||||
@@ -477,9 +472,27 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
|||||||
if _, ok := body["max_reference_images"]; ok {
|
if _, ok := body["max_reference_images"]; ok {
|
||||||
patch["max_reference_images"] = intValue(body["max_reference_images"])
|
patch["max_reference_images"] = intValue(body["max_reference_images"])
|
||||||
}
|
}
|
||||||
|
if _, ok := body["max_reference_videos"]; ok {
|
||||||
|
patch["max_reference_videos"] = intValue(body["max_reference_videos"])
|
||||||
|
}
|
||||||
|
if _, ok := body["max_reference_audios"]; ok {
|
||||||
|
patch["max_reference_audios"] = intValue(body["max_reference_audios"])
|
||||||
|
}
|
||||||
if _, ok := body["reference_mode"]; ok {
|
if _, ok := body["reference_mode"]; ok {
|
||||||
patch["reference_mode"] = defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none")
|
patch["reference_mode"] = defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none")
|
||||||
}
|
}
|
||||||
|
if _, ok := body["duration_min"]; ok {
|
||||||
|
patch["duration_min"] = intValue(body["duration_min"])
|
||||||
|
}
|
||||||
|
if _, ok := body["duration_max"]; ok {
|
||||||
|
patch["duration_max"] = intValue(body["duration_max"])
|
||||||
|
}
|
||||||
|
if _, ok := body["max_ref_video_seconds"]; ok {
|
||||||
|
patch["max_ref_video_seconds"] = intValue(body["max_ref_video_seconds"])
|
||||||
|
}
|
||||||
|
if _, ok := body["max_ref_audio_seconds"]; ok {
|
||||||
|
patch["max_ref_audio_seconds"] = intValue(body["max_ref_audio_seconds"])
|
||||||
|
}
|
||||||
if _, ok := body["weight"]; ok {
|
if _, ok := body["weight"]; ok {
|
||||||
patch["weight"] = intValue(body["weight"])
|
patch["weight"] = intValue(body["weight"])
|
||||||
}
|
}
|
||||||
@@ -524,52 +537,14 @@ func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) err
|
|||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
// Clean up: strip this model id from every custom upstream account's
|
// Clean up: drop every channel binding pointing at the deleted model so no
|
||||||
// supported-models list so no orphan reference is left behind.
|
// orphan reference is left behind.
|
||||||
s.removeModelFromUpstreams(ctx, modelID)
|
if s.channels != nil {
|
||||||
|
_ = s.channels.DeleteBindingsByModel(ctx, modelID)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeModelFromUpstreams drops modelID from the CSV in each custom account's
|
|
||||||
// Meta["models"]. Best-effort — a failure here doesn't undo the model delete.
|
|
||||||
func (s *AdminWriteService) removeModelFromUpstreams(ctx context.Context, modelID string) {
|
|
||||||
if s.tokens == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := s.tokens.ListByPool(ctx, "custom")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, it := range items {
|
|
||||||
raw, _ := it.Meta["models"].(string)
|
|
||||||
if strings.TrimSpace(raw) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept := make([]string, 0, len(strings.Split(raw, ",")))
|
|
||||||
changed := false
|
|
||||||
for _, p := range strings.Split(raw, ",") {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p == modelID {
|
|
||||||
changed = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, p)
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
meta := datatypes.JSONMap{}
|
|
||||||
for k, v := range it.Meta {
|
|
||||||
meta[k] = v
|
|
||||||
}
|
|
||||||
meta["models"] = strings.Join(kept, ",")
|
|
||||||
_, _ = s.tokens.Update(ctx, "custom", it.ID, map[string]any{"meta": meta})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
|
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
|
||||||
return s.events.DeleteAll(ctx)
|
return s.events.DeleteAll(ctx)
|
||||||
}
|
}
|
||||||
@@ -606,7 +581,7 @@ func normalizedStatus(status string) string {
|
|||||||
|
|
||||||
func normalizedShowcaseKind(kind string) string {
|
func normalizedShowcaseKind(kind string) string {
|
||||||
switch strings.TrimSpace(kind) {
|
switch strings.TrimSpace(kind) {
|
||||||
case "hero", "bento", "work":
|
case "hero", "work":
|
||||||
return strings.TrimSpace(kind)
|
return strings.TrimSpace(kind)
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
@@ -680,8 +655,8 @@ func boolValueWithDefault(v any, fallback bool) bool {
|
|||||||
|
|
||||||
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
|
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
|
||||||
// an image model's quality tiers ARE its price keys (the admin form never sends
|
// an image model's quality tiers ARE its price keys (the admin form never sends
|
||||||
// `resolutions` for images), returned in canonical 1K/2K/4K order. gpt-image-2,
|
// `resolutions` for images), returned in canonical 1K/2K/4K order. A model that
|
||||||
// for example, only ever has a "1K" price, so it resolves to exactly ["1K"].
|
// only ever has a "1K" price, for example, resolves to exactly ["1K"].
|
||||||
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
|
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
|
||||||
out := []string{}
|
out := []string{}
|
||||||
for _, r := range []string{"1K", "2K", "4K"} {
|
for _, r := range []string{"1K", "2K", "4K"} {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -109,8 +110,15 @@ func (s *APIKeyService) DeleteOne(ctx context.Context, userID, keyID string) err
|
|||||||
return s.keys.DeleteByID(ctx, userID, keyID)
|
return s.keys.DeleteByID(ctx, userID, keyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generatePlainAPIKey mints a vivid-shaped key: vk_ + 48 lowercase hex chars
|
||||||
|
// (upstream channel api_keys use the same shape, so one key format rules
|
||||||
|
// everything — no per-direction prefixes).
|
||||||
func generatePlainAPIKey() (string, error) {
|
func generatePlainAPIKey() (string, error) {
|
||||||
return "sk-" + randomUpper(38), nil
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "vk_" + hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewAPIKey(plain string) string {
|
func previewAPIKey(plain string) string {
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ func ownsImageDir(user *model.User, owner string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OwnsImageDir is the exported alias used by other services (image access, etc.)
|
||||||
|
func (s *AuthService) OwnsImageDir(user *model.User, owner string) bool {
|
||||||
|
return ownsImageDir(user, owner)
|
||||||
|
}
|
||||||
|
|
||||||
// checkOwnerDirCollision rejects a signup whose sanitized username or email
|
// checkOwnerDirCollision rejects a signup whose sanitized username or email
|
||||||
// local-part collides with any existing user's directory candidates. Media
|
// local-part collides with any existing user's directory candidates. Media
|
||||||
// authorization (ownsImageDir) matches on those candidates, so without this
|
// authorization (ownsImageDir) matches on those candidates, so without this
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backend/internal/model"
|
||||||
|
"backend/internal/repo"
|
||||||
|
"backend/internal/upstream"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelService manages upstream vivid-protocol channels: CRUD, upstream key
|
||||||
|
// encryption at rest, model fetching/import/binding, and live balance probes.
|
||||||
|
// The actual request dispatch lives on V1Service (it owns concurrency + events).
|
||||||
|
type ChannelService struct {
|
||||||
|
channels *repo.ChannelRepository
|
||||||
|
models *repo.ModelRepository
|
||||||
|
events *repo.EventRepository
|
||||||
|
conc *ConcurrencyService
|
||||||
|
up *upstream.Client
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, events *repo.EventRepository, conc *ConcurrencyService, up *upstream.Client, secret string) *ChannelService {
|
||||||
|
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
|
||||||
|
return &ChannelService{channels: channels, models: models, events: events, conc: conc, up: up, secret: sum[:]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelView is the admin list row: local stats + bound model names + a live
|
||||||
|
// upstream balance probe (nil when the upstream doesn't implement /v1/balance
|
||||||
|
// or the probe failed).
|
||||||
|
type ChannelView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
Models []string `json:"models"`
|
||||||
|
// RunningCount 运行中 = Redis 并发槽位实时计数(与调度器同源);
|
||||||
|
// FailCount 失败数 = event_logs 实时聚合(channel 数少,无需预计算)。
|
||||||
|
RunningCount int64 `json:"running_count"`
|
||||||
|
FailCount int64 `json:"fail_count"`
|
||||||
|
SuccessTotal int64 `json:"success_total"`
|
||||||
|
UsedCredits float64 `json:"used_credits"`
|
||||||
|
UpstreamBalance *int64 `json:"upstream_balance"`
|
||||||
|
LastUsedAt *int64 `json:"last_used_at"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
|
||||||
|
items, err := s.channels.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fails, _ := s.events.FailedByAccount(ctx)
|
||||||
|
out := make([]ChannelView, len(items))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, ch := range items {
|
||||||
|
out[i] = ChannelView{
|
||||||
|
ID: ch.ID,
|
||||||
|
Name: ch.Name,
|
||||||
|
BaseURL: ch.BaseURL,
|
||||||
|
Enabled: ch.Enabled,
|
||||||
|
Concurrency: ch.Concurrency,
|
||||||
|
Weight: ch.Weight,
|
||||||
|
RunningCount: int64(s.conc.Count(ctx, "conc:c:"+ch.ID)),
|
||||||
|
FailCount: fails[ch.ID],
|
||||||
|
SuccessTotal: ch.SuccessTotal,
|
||||||
|
UsedCredits: ch.UsedCredits,
|
||||||
|
CreatedAt: ch.CreatedAt.Unix(),
|
||||||
|
UpdatedAt: ch.UpdatedAt.Unix(),
|
||||||
|
}
|
||||||
|
if ch.LastUsedAt != nil {
|
||||||
|
t := ch.LastUsedAt.Unix()
|
||||||
|
out[i].LastUsedAt = &t
|
||||||
|
}
|
||||||
|
if ids, err := s.channels.ModelIDsByChannel(ctx, ch.ID); err == nil {
|
||||||
|
names := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if m, err := s.models.Get(ctx, id); err == nil {
|
||||||
|
names = append(names, m.EffectiveName())
|
||||||
|
} else {
|
||||||
|
names = append(names, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[i].Models = names
|
||||||
|
}
|
||||||
|
if key := s.decrypt(ch.APIKeyEnc); key != "" {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, ch model.Channel, key string) {
|
||||||
|
defer wg.Done()
|
||||||
|
bctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if bal, err := s.up.Balance(bctx, ch.BaseURL, key); err == nil {
|
||||||
|
out[i].UpstreamBalance = bal
|
||||||
|
}
|
||||||
|
}(i, ch, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveChannelDto is the admin create/update payload. ApiKey empty or the mask
|
||||||
|
// placeholder keeps the existing key on update; required on create.
|
||||||
|
type SaveChannelDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save upserts a channel then auto-syncs its upstream models (same semantics
|
||||||
|
// as vivid: upstream /v1/models is the truth). The returned warning is a
|
||||||
|
// non-fatal sync message — "" means fully synced.
|
||||||
|
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, string, error) {
|
||||||
|
name := strings.TrimSpace(d.Name)
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
|
||||||
|
if base == "" {
|
||||||
|
return nil, "", errors.New("base_url required")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||||
|
return nil, "", errors.New("base_url must start with http:// or https://")
|
||||||
|
}
|
||||||
|
if d.Concurrency < 0 || d.Concurrency > math.MaxInt32 {
|
||||||
|
return nil, "", errors.New("并发数需在 0-2147483647 之间(0 = 不限制)")
|
||||||
|
}
|
||||||
|
weight := d.Weight
|
||||||
|
if weight < 1 {
|
||||||
|
weight = 1
|
||||||
|
}
|
||||||
|
if d.ID == "" {
|
||||||
|
if strings.TrimSpace(d.APIKey) == "" {
|
||||||
|
return nil, "", errors.New("api_key required")
|
||||||
|
}
|
||||||
|
enc, err := s.encrypt(d.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
ch := &model.Channel{
|
||||||
|
ID: "ch-" + randomUpper(10),
|
||||||
|
Name: name,
|
||||||
|
BaseURL: base,
|
||||||
|
APIKeyEnc: enc,
|
||||||
|
Enabled: d.Enabled,
|
||||||
|
Concurrency: d.Concurrency,
|
||||||
|
Weight: weight,
|
||||||
|
}
|
||||||
|
if ch.Name == "" {
|
||||||
|
ch.Name = base
|
||||||
|
}
|
||||||
|
if err := s.channels.Create(ctx, ch); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return ch, syncWarn(s.syncChannelModels(ctx, ch.ID)), nil
|
||||||
|
}
|
||||||
|
patch := map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"base_url": base,
|
||||||
|
"enabled": d.Enabled,
|
||||||
|
"concurrency": d.Concurrency,
|
||||||
|
"weight": weight,
|
||||||
|
}
|
||||||
|
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
|
||||||
|
enc, err := s.encrypt(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
patch["api_key_enc"] = enc
|
||||||
|
}
|
||||||
|
ch, err := s.channels.Update(ctx, d.ID, patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return ch, syncWarn(s.syncChannelModels(ctx, d.ID)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncWarn folds the sync error into the save-path warning (vivid parity: a
|
||||||
|
// failed upstream fetch degrades to a non-fatal warning instead of blocking
|
||||||
|
// the channel save).
|
||||||
|
func syncWarn(warn string, err error) string {
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return warn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncModels manually triggers a full upstream-model sync for a saved channel
|
||||||
|
// (same truth semantics as the save path). Unlike Save, an unreachable upstream
|
||||||
|
// or empty model list is a hard error, not a warning.
|
||||||
|
func (s *ChannelService) SyncModels(ctx context.Context, channelID string) (string, error) {
|
||||||
|
if _, err := s.channels.Get(ctx, strings.TrimSpace(channelID)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
warn, err := s.syncChannelModels(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if warn != "" {
|
||||||
|
return warn, nil
|
||||||
|
}
|
||||||
|
return "已同步上游模型", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncChannelModels runs after Save: upstream /v1/models is the truth — every
|
||||||
|
// sellable upstream model is upserted locally (alias = upstream id) and bound;
|
||||||
|
// bindings on this channel not in the upstream list are removed, and orphan
|
||||||
|
// local models are deleted. An unreachable upstream or empty list is an error
|
||||||
|
// (the save path degrades it to a warning); warn carries non-fatal skips.
|
||||||
|
func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string) (string, error) {
|
||||||
|
ch, err := s.channels.Get(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
key := s.decrypt(ch.APIKeyEnc)
|
||||||
|
if key == "" {
|
||||||
|
return "", errors.New("模型同步失败:无法解密上游密钥")
|
||||||
|
}
|
||||||
|
fctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
models, err := s.up.Models(fctx, ch.BaseURL, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return "", errors.New("上游未返回任何模型,已跳过同步")
|
||||||
|
}
|
||||||
|
specs := make([]ChannelModelSpec, 0, len(models))
|
||||||
|
upstreamIDs := make([]string, 0, len(models))
|
||||||
|
var skipped []string
|
||||||
|
for _, m := range models {
|
||||||
|
spec, serr := normalizeRemoteModel(m)
|
||||||
|
if serr != nil {
|
||||||
|
skipped = append(skipped, m.ID+":"+serr.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
specs = append(specs, spec)
|
||||||
|
upstreamIDs = append(upstreamIDs, spec.ID)
|
||||||
|
}
|
||||||
|
if len(upstreamIDs) == 0 {
|
||||||
|
return "", fmt.Errorf("上游模型均规格异常,已跳过同步:%s", strings.Join(skipped, ";"))
|
||||||
|
}
|
||||||
|
if err := s.ImportModels(ctx, channelID, specs); err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
if err := s.channels.UnbindExcept(ctx, channelID, upstreamIDs); err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
if orphans, oerr := s.channels.OrphanModelIDs(ctx); oerr == nil {
|
||||||
|
for _, mid := range orphans {
|
||||||
|
_, _ = s.models.Delete(ctx, mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
return fmt.Sprintf("已同步上游模型,跳过 %d 个异常项:%s", len(skipped), strings.Join(skipped, ";")), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeRemoteModel validates an upstream /v1/models item into an import
|
||||||
|
// spec (alias = upstream id).
|
||||||
|
func normalizeRemoteModel(m upstream.RemoteModel) (ChannelModelSpec, error) {
|
||||||
|
spec := ChannelModelSpec{
|
||||||
|
ID: strings.TrimSpace(m.ID),
|
||||||
|
Kind: strings.TrimSpace(m.Kind),
|
||||||
|
Qualities: m.Qualities,
|
||||||
|
Duration: m.Duration,
|
||||||
|
RefImages: m.RefImages,
|
||||||
|
RefVideos: m.RefVideos,
|
||||||
|
RefAudios: m.RefAudios,
|
||||||
|
RefVideoSeconds: m.RefVideoSeconds,
|
||||||
|
RefAudioSeconds: m.RefAudioSeconds,
|
||||||
|
}
|
||||||
|
if spec.ID == "" {
|
||||||
|
return spec, errors.New("模型 id 为空")
|
||||||
|
}
|
||||||
|
switch spec.Kind {
|
||||||
|
case "image", "video", "text":
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("未知类型 %q", spec.Kind)
|
||||||
|
}
|
||||||
|
if spec.Duration != nil {
|
||||||
|
switch spec.Duration.Mode {
|
||||||
|
case "options", "range":
|
||||||
|
default:
|
||||||
|
return spec, errors.New("时长模式仅支持 options / range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a channel, its bindings, and any local model that ends up
|
||||||
|
// bound to no channel at all (same semantics as vivid).
|
||||||
|
func (s *ChannelService) Delete(ctx context.Context, id string) error {
|
||||||
|
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, id)
|
||||||
|
if err := s.channels.DeleteBindingsByChannel(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := 0
|
||||||
|
if n, err := s.channels.Delete(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
res = int(n)
|
||||||
|
}
|
||||||
|
if res == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
for _, mid := range modelIDs {
|
||||||
|
rest, _ := s.channels.ChannelIDsByModel(ctx, mid)
|
||||||
|
if len(rest) == 0 {
|
||||||
|
_, _ = s.models.Delete(ctx, mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchModelsDto resolves credentials for a model fetch: Id uses the stored
|
||||||
|
// channel's key; otherwise BaseURL+ApiKey preview an unsaved channel.
|
||||||
|
type FetchModelsDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchModels lists the upstream's sellable models; when a saved channel id is
|
||||||
|
// given, items already bound to that channel are flagged Bound so the admin UI
|
||||||
|
// can render them as a bound/unbound toggle.
|
||||||
|
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
|
||||||
|
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := s.up.Models(ctx, base, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.ID) != "" {
|
||||||
|
if bound, berr := s.channels.UpstreamModelMap(ctx, d.ID); berr == nil && len(bound) > 0 {
|
||||||
|
set := make(map[string]struct{}, len(bound))
|
||||||
|
for _, upID := range bound {
|
||||||
|
set[upID] = struct{}{}
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if _, ok := set[items[i].ID]; ok {
|
||||||
|
items[i].Bound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeOrphanModels sweeps model_configs rows left over from the provider era:
|
||||||
|
// an unbound model can never be routed, so any row that didn't come through a
|
||||||
|
// channel import (provider != "channel") is deleted at startup. Channel-typed
|
||||||
|
// orphans keep their pricing config for a later rebind.
|
||||||
|
func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) {
|
||||||
|
ids, err := s.channels.OrphanModelIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
m, gerr := s.models.GetAny(ctx, id)
|
||||||
|
if gerr != nil || m == nil || m.Provider == "channel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// provider 时代残留行没有复活路径,直接物理删除,不留软删垃圾。
|
||||||
|
if _, derr := s.models.DeleteHard(ctx, id); derr == nil {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelModelSpec is one model the admin ticks for import (same shape as the
|
||||||
|
// upstream /v1/models item plus an optional local alias).
|
||||||
|
type ChannelModelSpec struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Qualities []string `json:"qualities"`
|
||||||
|
Duration *upstream.RemoteDuration `json:"duration,omitempty"`
|
||||||
|
RefImages int64 `json:"refImages"`
|
||||||
|
RefVideos int64 `json:"refVideos"`
|
||||||
|
RefAudios int64 `json:"refAudios"`
|
||||||
|
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||||
|
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelService) resolveCreds(ctx context.Context, id, base, key string) (string, string, error) {
|
||||||
|
if strings.TrimSpace(id) != "" {
|
||||||
|
ch, err := s.channels.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return strings.TrimRight(ch.BaseURL, "/"), s.decrypt(ch.APIKeyEnc), nil
|
||||||
|
}
|
||||||
|
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||||
|
if base == "" || strings.TrimSpace(key) == "" {
|
||||||
|
return "", "", errors.New("id or base_url+api_key required")
|
||||||
|
}
|
||||||
|
return base, strings.TrimSpace(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportModels creates/updates local model rows for the ticked specs and binds
|
||||||
|
// them to the channel. Pricing stays whatever the local row already has
|
||||||
|
// (empty for new rows — admin prices them in 模型管理).
|
||||||
|
func (s *ChannelService) ImportModels(ctx context.Context, channelID string, specs []ChannelModelSpec) error {
|
||||||
|
ch, err := s.channels.Get(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(specs) > 50 {
|
||||||
|
return errors.New("单次最多导入 50 个模型")
|
||||||
|
}
|
||||||
|
seenAliases := make(map[string]string, len(specs)) // localID → 上游 id,别名冲突当场拒绝
|
||||||
|
for _, spec := range specs {
|
||||||
|
upID := strings.TrimSpace(spec.ID)
|
||||||
|
if upID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if spec.Duration != nil && spec.Duration.Mode != "options" && spec.Duration.Mode != "range" {
|
||||||
|
return fmt.Errorf("模型 %s:时长模式仅支持 options / range", upID)
|
||||||
|
}
|
||||||
|
alias := strings.TrimSpace(spec.Alias)
|
||||||
|
if alias == upID {
|
||||||
|
return fmt.Errorf("「%s」别名不能与上游模型同名,留空即可", upID)
|
||||||
|
}
|
||||||
|
localID := alias
|
||||||
|
if localID == "" {
|
||||||
|
localID = upID
|
||||||
|
}
|
||||||
|
if prev, dup := seenAliases[localID]; dup && prev != upID {
|
||||||
|
return fmt.Errorf("别名 %s 被多个上游模型复用", localID)
|
||||||
|
}
|
||||||
|
seenAliases[localID] = upID
|
||||||
|
// GetAny: 软删行也纳入——重导同一模型时复活原行,计数/定价不丢。
|
||||||
|
existing, gerr := s.models.GetAny(ctx, localID)
|
||||||
|
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
|
||||||
|
existing = nil
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Type != "" && spec.Kind != "" && existing.Type != spec.Kind {
|
||||||
|
return fmt.Errorf("模型 %s 已存在且类型不同(%s)", localID, existing.Type)
|
||||||
|
}
|
||||||
|
var local *model.ModelConfig
|
||||||
|
if existing != nil && existing.ID == localID {
|
||||||
|
local = existing
|
||||||
|
}
|
||||||
|
patch := modelPatchFromSpec(spec, local)
|
||||||
|
if local == nil {
|
||||||
|
local = &model.ModelConfig{
|
||||||
|
ID: localID,
|
||||||
|
Name: localID,
|
||||||
|
Type: spec.Kind,
|
||||||
|
Provider: "channel",
|
||||||
|
Ratios: jsonArray([]string{"16:9"}),
|
||||||
|
Prices: datatypes.JSONMap{},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
applySpec(local, patch)
|
||||||
|
if err := s.models.Create(ctx, local); err != nil {
|
||||||
|
return fmt.Errorf("model %s: %w", localID, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if local.DeletedAt.Valid {
|
||||||
|
patch["deleted_at"] = nil // revive
|
||||||
|
}
|
||||||
|
if _, err := s.models.UpdateAny(ctx, localID, patch); err != nil {
|
||||||
|
return fmt.Errorf("model %s: %w", localID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.channels.Bind(ctx, ch.ID, localID, upID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelPatchFromSpec maps an upstream model spec onto model_configs columns.
|
||||||
|
// Prices/alias are intentionally untouched on update so admin pricing survives
|
||||||
|
// re-import.
|
||||||
|
func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any {
|
||||||
|
patch := map[string]any{
|
||||||
|
"type": spec.Kind,
|
||||||
|
"resolutions": jsonArray(spec.Qualities),
|
||||||
|
"max_reference_images": spec.RefImages,
|
||||||
|
"max_reference_videos": spec.RefVideos,
|
||||||
|
"max_reference_audios": spec.RefAudios,
|
||||||
|
"max_ref_video_seconds": spec.RefVideoSeconds,
|
||||||
|
"max_ref_audio_seconds": spec.RefAudioSeconds,
|
||||||
|
}
|
||||||
|
if spec.RefImages+spec.RefVideos+spec.RefAudios > 0 {
|
||||||
|
patch["reference_mode"] = "asset"
|
||||||
|
patch["image_to_image"] = spec.RefImages > 0
|
||||||
|
}
|
||||||
|
if d := spec.Duration; d != nil {
|
||||||
|
switch d.Mode {
|
||||||
|
case "options":
|
||||||
|
opts := make([]string, 0, len(d.Options))
|
||||||
|
for _, s := range d.Options {
|
||||||
|
opts = append(opts, fmt.Sprintf("%ds", s))
|
||||||
|
}
|
||||||
|
patch["durations"] = jsonArray(opts)
|
||||||
|
patch["duration_min"], patch["duration_max"] = int64(0), int64(0)
|
||||||
|
case "range":
|
||||||
|
patch["duration_min"], patch["duration_max"] = d.Min, d.Max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySpec(m *model.ModelConfig, patch map[string]any) {
|
||||||
|
if v, ok := patch["resolutions"]; ok {
|
||||||
|
m.Resolutions = datatypes.JSON(mustJSON(v))
|
||||||
|
}
|
||||||
|
if v, ok := patch["durations"]; ok {
|
||||||
|
m.Durations = datatypes.JSON(mustJSON(v))
|
||||||
|
}
|
||||||
|
if v, ok := patch["duration_min"]; ok {
|
||||||
|
m.DurationMin = int(v.(int64))
|
||||||
|
}
|
||||||
|
if v, ok := patch["duration_max"]; ok {
|
||||||
|
m.DurationMax = int(v.(int64))
|
||||||
|
}
|
||||||
|
m.MaxReferenceImages = int(specInt(patch, "max_reference_images"))
|
||||||
|
m.MaxReferenceVideos = int(specInt(patch, "max_reference_videos"))
|
||||||
|
m.MaxReferenceAudios = int(specInt(patch, "max_reference_audios"))
|
||||||
|
m.MaxRefVideoSeconds = int(specInt(patch, "max_ref_video_seconds"))
|
||||||
|
m.MaxRefAudioSeconds = int(specInt(patch, "max_ref_audio_seconds"))
|
||||||
|
if v, ok := patch["reference_mode"].(string); ok {
|
||||||
|
m.ReferenceMode = v
|
||||||
|
}
|
||||||
|
if v, ok := patch["image_to_image"].(bool); ok {
|
||||||
|
m.ImageToImage = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func specInt(patch map[string]any, key string) int64 {
|
||||||
|
switch v := patch[key].(type) {
|
||||||
|
case int64:
|
||||||
|
return v
|
||||||
|
case int:
|
||||||
|
return int64(v)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v any) []byte {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelsForModel returns enabled channels bound to the model with their
|
||||||
|
// upstream model names — dispatch order is decided by the caller.
|
||||||
|
func (s *ChannelService) ChannelsForModel(ctx context.Context, modelID string) ([]repo.ChannelWithUpstream, error) {
|
||||||
|
return s.channels.ForModel(ctx, modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelsForModelAnyState includes disabled channels (admin pin tests).
|
||||||
|
func (s *ChannelService) ChannelsForModelAnyState(ctx context.Context, modelID string) ([]repo.ChannelWithUpstream, error) {
|
||||||
|
return s.channels.ForModelAnyState(ctx, modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt exposes the upstream key for dispatch.
|
||||||
|
// Get loads one channel row by id (resume path needs the owning channel).
|
||||||
|
func (s *ChannelService) Get(ctx context.Context, id string) (*model.Channel, error) {
|
||||||
|
return s.channels.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
|
||||||
|
|
||||||
|
// Upstream returns the shared protocol client (dispatch needs it for
|
||||||
|
// UploadRef/Generate/Download; admin handlers for balance probes).
|
||||||
|
func (s *ChannelService) Upstream() *upstream.Client { return s.up }
|
||||||
|
|
||||||
|
// BumpSuccess forwards local stats updates to the repo.
|
||||||
|
func (s *ChannelService) BumpSuccess(ctx context.Context, channelID string, credits float64) {
|
||||||
|
_ = s.channels.BumpSuccess(ctx, channelID, credits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AES-GCM key encryption ---
|
||||||
|
|
||||||
|
const encPrefix = "enc:v1:"
|
||||||
|
|
||||||
|
func (s *ChannelService) encrypt(plain string) (string, error) {
|
||||||
|
block, err := aes.NewCipher(s.secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return encPrefix + base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plain), nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelService) decrypt(stored string) string {
|
||||||
|
if !strings.HasPrefix(stored, encPrefix) {
|
||||||
|
return stored // plaintext rows (e.g. seeded by hand)
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(s.secret)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(raw) < gcm.NonceSize() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(plain)
|
||||||
|
}
|
||||||
@@ -48,6 +48,21 @@ func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, er
|
|||||||
return s.showcase.IsPublicFile(ctx, rel)
|
return s.showcase.IsPublicFile(ctx, rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ImageAccessService) IsAuthorized(ctx context.Context, sessionCookie, owner string) (bool, error) {
|
func (s *ImageAccessService) IsAuthorized(ctx context.Context, authHeader, sessionCookie, owner string) (bool, error) {
|
||||||
|
// Private images may be requested by an <img> tag with the session cookie
|
||||||
|
// OR by the SPA via a Bearer token. Admins may view anyone's images.
|
||||||
|
if authHeader != "" {
|
||||||
|
user, _, err := s.auth.CurrentUserFromBearer(ctx, authHeader)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return s.auth.OwnsImageDir(user, owner), nil
|
||||||
|
}
|
||||||
return s.auth.IsAuthorizedForPrivateImage(ctx, sessionCookie, owner)
|
return s.auth.IsAuthorizedForPrivateImage(ctx, sessionCookie, owner)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,47 +5,39 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/model"
|
|
||||||
"backend/internal/repo"
|
"backend/internal/repo"
|
||||||
"backend/internal/storage"
|
"backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaintenanceService runs the periodic self-healing sweep that the Python
|
// MaintenanceService runs the periodic self-healing sweep. Without it stale
|
||||||
// original did via a 60s daemon thread plus read-time lazy cleanup. Without it
|
// pending events permanently block a user's generation gate, and old media /
|
||||||
// the Go token pool only ever loses capacity: tokens never re-activate after a
|
// logs / temp objects accumulate unbounded. Token-pool and cookie-refresh
|
||||||
// quota reset, cookies never auto-renew, stale pending events permanently block
|
// upkeep is gone — channels are stateless upstream credentials.
|
||||||
// a user's generation gate, and old media/logs accumulate unbounded.
|
|
||||||
type MaintenanceService struct {
|
type MaintenanceService struct {
|
||||||
tokens *repo.TokenRepository
|
|
||||||
tokenSvc *TokenService
|
|
||||||
events *repo.EventRepository
|
events *repo.EventRepository
|
||||||
users *repo.UserRepository
|
users *repo.UserRepository
|
||||||
refresh *RefreshProfileService
|
|
||||||
settings *repo.SiteSettingRepository
|
settings *repo.SiteSettingRepository
|
||||||
store *storage.Client
|
store *storage.Client
|
||||||
inflight *InflightRegistry
|
v1 *V1Service
|
||||||
showcase *repo.ShowcaseRepository
|
showcase *repo.ShowcaseRepository
|
||||||
orders *repo.OrderRepository
|
orders *repo.OrderRepository
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
stalePending time.Duration
|
stalePending time.Duration
|
||||||
stalePendingVid time.Duration
|
stalePendingVid time.Duration
|
||||||
|
maxResumeAge time.Duration
|
||||||
mediaPruneEvery time.Duration
|
mediaPruneEvery time.Duration
|
||||||
lastMediaPrune time.Time
|
lastMediaPrune time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService, events *repo.EventRepository, users *repo.UserRepository, refresh *RefreshProfileService, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, v1 *V1Service, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
||||||
return &MaintenanceService{
|
return &MaintenanceService{
|
||||||
tokens: tokens,
|
|
||||||
tokenSvc: tokenSvc,
|
|
||||||
events: events,
|
events: events,
|
||||||
users: users,
|
users: users,
|
||||||
refresh: refresh,
|
|
||||||
settings: settings,
|
settings: settings,
|
||||||
store: store,
|
store: store,
|
||||||
inflight: inflight,
|
v1: v1,
|
||||||
showcase: showcase,
|
showcase: showcase,
|
||||||
orders: orders,
|
orders: orders,
|
||||||
interval: 60 * time.Second,
|
interval: 60 * time.Second,
|
||||||
@@ -53,6 +45,9 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
|
|||||||
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
||||||
// 10 分钟去扫会在它出片前判死并退款。
|
// 10 分钟去扫会在它出片前判死并退款。
|
||||||
stalePendingVid: 30 * time.Minute,
|
stalePendingVid: 30 * time.Minute,
|
||||||
|
// 带上游 jobId 的逾期任务先尝试续等而不是判死;超过 3 小时总时限
|
||||||
|
// (vivid 同款)才放弃走判死退款。
|
||||||
|
maxResumeAge: 3 * time.Hour,
|
||||||
mediaPruneEvery: 60 * time.Second,
|
mediaPruneEvery: 60 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,10 +58,6 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
|
|||||||
func (m *MaintenanceService) Run(ctx context.Context) {
|
func (m *MaintenanceService) Run(ctx context.Context) {
|
||||||
ticker := time.NewTicker(m.interval)
|
ticker := time.NewTicker(m.interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
// The leonardo session keep-alive gets its own loop: one tick() can take tens
|
|
||||||
// of minutes (219 adobe cookie profiles alone), which would stretch a 5-minute
|
|
||||||
// keep-alive to the tick's real duration.
|
|
||||||
go m.runLeonardoKeepalive(ctx)
|
|
||||||
m.tick(ctx)
|
m.tick(ctx)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -78,55 +69,6 @@ func (m *MaintenanceService) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runLeonardoKeepalive re-checks every minute which leonardo accounts are due for
|
|
||||||
// a session renewal (the 5-minute due-ness itself is read per account from the DB,
|
|
||||||
// so a restart can't skip one).
|
|
||||||
func (m *MaintenanceService) runLeonardoKeepalive(ctx context.Context) {
|
|
||||||
if m.tokenSvc == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
m.tokenSvc.RefreshLeonardoSessions(ctx)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncRecoveredQuota re-probes each just-recovered account so its displayed
|
|
||||||
// balance reflects the post-reset value (these providers only sync quota when
|
|
||||||
// accessed). krea additionally needs /app (Activate) to actually grant the daily
|
|
||||||
// free balance before billing-data reports it. Bounded concurrency avoids a
|
|
||||||
// thundering herd at the daily reset.
|
|
||||||
func (m *MaintenanceService) syncRecoveredQuota(accs []model.TokenAccount) {
|
|
||||||
sem := make(chan struct{}, 4)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for _, acc := range accs {
|
|
||||||
switch acc.Pool {
|
|
||||||
case "chatgpt", "leonardo", "krea", "imagine":
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
sem <- struct{}{}
|
|
||||||
go func(a model.TokenAccount) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-sem }()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if a.Pool == "krea" && m.tokenSvc.krea != nil {
|
|
||||||
m.tokenSvc.krea.Activate(ctx, a.Value)
|
|
||||||
}
|
|
||||||
_, _ = m.tokenSvc.Quota(ctx, a.Pool, a.ID)
|
|
||||||
}(acc)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MaintenanceService) tick(ctx context.Context) {
|
func (m *MaintenanceService) tick(ctx context.Context) {
|
||||||
// 0. Auto-cancel unpaid recharge orders past their 30-min TTL.
|
// 0. Auto-cancel unpaid recharge orders past their 30-min TTL.
|
||||||
if m.orders != nil {
|
if m.orders != nil {
|
||||||
@@ -137,77 +79,26 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Re-activate quota-exhausted tokens whose reset time has passed, then
|
// 1a. Pending events with an upstream job id that outlived their local
|
||||||
// auto-sync their real balance — these providers only refresh quota when
|
// goroutine (process restart) get resumed against the owning channel —
|
||||||
// accessed, so recovery alone would leave a stale 0/—. For krea the sync
|
// the upstream job keeps running even while we were down.
|
||||||
// must first load /app (Activate) to grant the daily free balance.
|
if m.v1 != nil {
|
||||||
if recovered, err := m.tokens.RecoverQuota(ctx); err != nil {
|
if resumable, err := m.events.ResumableStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
|
||||||
log.Printf("maintenance: recover_quota: %v", err)
|
log.Printf("maintenance: resumable_stale: %v", err)
|
||||||
} else if len(recovered) > 0 {
|
} else {
|
||||||
log.Printf("maintenance: recovered %d quota token(s)", len(recovered))
|
for _, e := range resumable {
|
||||||
if m.tokenSvc != nil {
|
m.v1.ResumeStaleJob(ctx, e)
|
||||||
go m.syncRecoveredQuota(recovered)
|
}
|
||||||
|
if len(resumable) > 0 {
|
||||||
|
log.Printf("maintenance: resuming %d stale job(s) on their channels", len(resumable))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1a. Roll the 恢复时间 marker of ACTIVE daily-reset accounts forward to the next
|
// 1b. Fail long-pending events so they stop blocking the per-user gate, and
|
||||||
// future reset (same time-of-day, +1 day) so the column never shows a stale
|
|
||||||
// past time. Limited accounts are intentionally skipped (RecoverQuota owns
|
|
||||||
// their marker). adobe/leonardo/krea/imagine all renew daily.
|
|
||||||
if _, err := m.tokens.RollResetMarkers(ctx, []string{"adobe", "leonardo", "krea", "imagine"}); err != nil {
|
|
||||||
log.Printf("maintenance: roll_reset: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1b. Runway tokens have no refresh — once the JWT expiry marker passes the
|
|
||||||
// token can only 401, so flip it to disabled+dead proactively instead of
|
|
||||||
// leaving a doomed account "active". Grok is intentionally NOT swept here:
|
|
||||||
// its reset marker is billingPeriodEnd (a credits-renewal date), not a
|
|
||||||
// death deadline — a grok sso keeps working past billingPeriodEnd, so
|
|
||||||
// expiring on it kills live accounts. Grok death is caught for real by the
|
|
||||||
// import-time FetchSession check and by marking dead on a 401 at use.
|
|
||||||
for _, pool := range []string{"runway"} {
|
|
||||||
if n, err := m.tokens.ExpireByReset(ctx, pool); err != nil {
|
|
||||||
log.Printf("maintenance: expire_%s: %v", pool, err)
|
|
||||||
} else if n > 0 {
|
|
||||||
log.Printf("maintenance: expired %d %s token(s)", n, pool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1c. Proactively renew krea/imagine sessions ~10min before expiry so a
|
|
||||||
// dormant account's rotating refresh_token never lapses (a dead token
|
|
||||||
// can't be recovered and, for krea, blocks the daily free-credit meter
|
|
||||||
// from being re-created). Only near-expiry accounts hit the network.
|
|
||||||
if m.tokenSvc != nil {
|
|
||||||
m.tokenSvc.RefreshExpiringTokens(ctx)
|
|
||||||
// 1d. Once-per-day krea /app activation for accounts not yet synced since the
|
|
||||||
// daily reset — krea only grants the free balance after /app loads, so an
|
|
||||||
// always-active account (never went 限额) would otherwise read 0 / 402
|
|
||||||
// after each reset. Self-guarded + background; no-op once all are done.
|
|
||||||
m.tokenSvc.ActivateKreaDue(ctx)
|
|
||||||
// 1e. Re-validate grok accounts: an empty /rest/subscriptions (or 401)
|
|
||||||
// means the membership lapsed → disable+dead; otherwise re-sync the
|
|
||||||
// credits balance and 恢复时间 (from the credits' weekly reset).
|
|
||||||
m.tokenSvc.RefreshGrokLiveness(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Auto-renew Adobe cookies whose refresh interval has elapsed.
|
|
||||||
if m.refresh != nil {
|
|
||||||
if m.settings != nil {
|
|
||||||
if proxy, err := m.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
|
|
||||||
m.refresh.SetProxy(proxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n, err := m.refresh.RefreshDue(ctx); err != nil {
|
|
||||||
log.Printf("maintenance: refresh_due: %v", err)
|
|
||||||
} else if n > 0 {
|
|
||||||
log.Printf("maintenance: refreshed %d cookie profile(s)", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fail long-pending events so they stop blocking the per-user gate, and
|
|
||||||
// refund the credits debited up-front for each abandoned generation (the
|
// refund the credits debited up-front for each abandoned generation (the
|
||||||
// normal failure-refund path never ran for a process-restart orphan).
|
// normal failure-refund path never ran for a process-restart orphan).
|
||||||
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid); err != nil {
|
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
|
||||||
log.Printf("maintenance: purge_stale: %v", err)
|
log.Printf("maintenance: purge_stale: %v", err)
|
||||||
} else if len(purged) > 0 {
|
} else if len(purged) > 0 {
|
||||||
refunded := 0
|
refunded := 0
|
||||||
@@ -216,16 +107,9 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
|||||||
// Stop the generation goroutine if it's still running, so it doesn't
|
// Stop the generation goroutine if it's still running, so it doesn't
|
||||||
// keep grinding for minutes and surface a late "success" on this
|
// keep grinding for minutes and surface a late "success" on this
|
||||||
// just-abandoned event.
|
// just-abandoned event.
|
||||||
if m.inflight != nil && m.inflight.Cancel(e.ID) {
|
if m.v1 != nil && m.v1.Inflight().Cancel(e.ID) {
|
||||||
cancelled++
|
cancelled++
|
||||||
}
|
}
|
||||||
// Attribute the abandoned failure back to the account it was using
|
|
||||||
// (the normal markTokenFailure path never ran for an orphaned job).
|
|
||||||
if e.AccountID != "" {
|
|
||||||
if err := m.tokens.IncrementFail(ctx, e.AccountID); err != nil {
|
|
||||||
log.Printf("maintenance: fail-count abandoned event %s (account %s): %v", e.ID, e.AccountID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if e.UserID == "" || e.Cost <= 0 {
|
if e.UserID == "" || e.Cost <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -248,10 +132,11 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
|||||||
log.Printf("maintenance: marked %d stale pending event(s) failed, refunded %d, cancelled %d in-flight", len(purged), refunded, cancelled)
|
log.Printf("maintenance: marked %d stale pending event(s) failed, refunded %d, cancelled %d in-flight", len(purged), refunded, cancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Enforce the admin-configured log retention window.
|
// 2. Enforce the admin-configured log retention window.
|
||||||
m.pruneLogs(ctx)
|
m.pruneLogs(ctx)
|
||||||
|
|
||||||
// 5. Enforce the media retention window. Runs every 60s like the log prune;
|
// 3. Enforce the media retention window + sweep expired tmp/ (v1 API job
|
||||||
|
// results) and stale refs/ objects. Runs every 60s like the log prune;
|
||||||
// mediaPruneEvery still gates it in case the interval is ever shortened.
|
// mediaPruneEvery still gates it in case the interval is ever shortened.
|
||||||
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
|
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
|
||||||
m.pruneMedia(ctx)
|
m.pruneMedia(ctx)
|
||||||
@@ -274,10 +159,11 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
days := m.retentionDays(ctx, "media.retention_days")
|
days := m.retentionDays(ctx, "media.retention_days")
|
||||||
if days <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||||
|
// tmp/ holds /v1 job results: presigned for 30 min, pruned shortly after.
|
||||||
|
tmpCutoff := time.Now().Add(-40 * time.Minute)
|
||||||
|
// <uid>/refs/ holds uploaded reference material: live for the presign TTL.
|
||||||
|
refCutoff := time.Now().Add(-2 * PresignTTL)
|
||||||
objs, err := m.store.List(ctx, "")
|
objs, err := m.store.List(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("maintenance: list media: %v", err)
|
log.Printf("maintenance: list media: %v", err)
|
||||||
@@ -303,10 +189,20 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
|||||||
removed, skipped := 0, 0
|
removed, skipped := 0, 0
|
||||||
var clearedKeys []string
|
var clearedKeys []string
|
||||||
for _, o := range objs {
|
for _, o := range objs {
|
||||||
if !o.LastModified.Before(cutoff) {
|
key := strings.TrimLeft(o.Key, "/")
|
||||||
|
expired := false
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(key, "tmp/"):
|
||||||
|
expired = o.LastModified.Before(tmpCutoff)
|
||||||
|
case strings.Contains(key, "/refs/"):
|
||||||
|
expired = o.LastModified.Before(refCutoff)
|
||||||
|
case days > 0:
|
||||||
|
expired = o.LastModified.Before(cutoff)
|
||||||
|
}
|
||||||
|
if !expired {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := pinned[strings.TrimLeft(o.Key, "/")]; ok {
|
if _, ok := pinned[key]; ok {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backend/internal/model"
|
|
||||||
"backend/internal/provider/adobe"
|
|
||||||
"backend/internal/repo"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RefreshProfileService struct {
|
|
||||||
profiles *repo.RefreshProfileRepository
|
|
||||||
tokens *repo.TokenRepository
|
|
||||||
adobe *adobe.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *repo.TokenRepository, adobeClient *adobe.Client) *RefreshProfileService {
|
|
||||||
return &RefreshProfileService{
|
|
||||||
profiles: profiles,
|
|
||||||
tokens: tokens,
|
|
||||||
adobe: adobeClient,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RefreshProfileService) SetProxy(proxy string) {
|
|
||||||
if s.adobe != nil {
|
|
||||||
s.adobe.SetProxy(proxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) {
|
|
||||||
return s.profiles.List(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error {
|
|
||||||
if s.adobe == nil || s.tokens == nil {
|
|
||||||
return errors.New("refresh client not configured")
|
|
||||||
}
|
|
||||||
profile, err := s.profiles.Get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if profile.Pool != "adobe" || profile.Kind != "adobe_cookie" {
|
|
||||||
return errors.New("unsupported refresh profile")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
_, _ = s.profiles.Update(ctx, id, map[string]any{
|
|
||||||
"last_attempt_at": now,
|
|
||||||
})
|
|
||||||
|
|
||||||
result, err := s.adobe.ExchangeCookie(ctx, profile.Cookie)
|
|
||||||
if err != nil {
|
|
||||||
failures := profile.ConsecutiveFailures + 1
|
|
||||||
// Exponential backoff: 60s per consecutive failure, capped at 1h.
|
|
||||||
secs := 60 * failures
|
|
||||||
if secs > 3600 {
|
|
||||||
secs = 3600
|
|
||||||
}
|
|
||||||
msg := err.Error()
|
|
||||||
if len(msg) > 300 {
|
|
||||||
msg = msg[:300]
|
|
||||||
}
|
|
||||||
_, _ = s.profiles.Update(ctx, id, map[string]any{
|
|
||||||
"last_error": msg,
|
|
||||||
"consecutive_failures": failures,
|
|
||||||
"next_retry_at": now.Add(time.Duration(secs) * time.Second),
|
|
||||||
})
|
|
||||||
// Mark the token dead when the cookie exchange keeps failing:
|
|
||||||
// - 5 consecutive failures → cookie is likely expired/revoked
|
|
||||||
// - ride_AdobeID_acct_actreq (Adobe requires account action) → permanently broken
|
|
||||||
if failures >= 5 || strings.Contains(msg, "ride_AdobeID_acct_actreq") {
|
|
||||||
_, _ = s.tokens.Update(ctx, "adobe", id, map[string]any{
|
|
||||||
"status": "disabled",
|
|
||||||
"dead": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenPatch := map[string]any{
|
|
||||||
"value": result.AccessToken,
|
|
||||||
"status": "active",
|
|
||||||
"dead": false,
|
|
||||||
"fails": 0,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
email, _ := parseJWTEmailExpiry(result.AccessToken)
|
|
||||||
if email != "" {
|
|
||||||
tokenPatch["account_email"] = email
|
|
||||||
}
|
|
||||||
if profile.IntervalSeconds > 0 {
|
|
||||||
tokenPatch["cached_quota_reset_after"] = now.Add(time.Duration(profile.IntervalSeconds) * time.Second).Format(time.RFC3339)
|
|
||||||
} else {
|
|
||||||
tokenPatch["cached_quota_reset_after"] = now.Add(54000 * time.Second).Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil {
|
|
||||||
if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" {
|
|
||||||
tokenPatch["account_email"] = email
|
|
||||||
}
|
|
||||||
if displayName := strings.TrimSpace(stringValue(profileData["display_name"])); displayName != "" {
|
|
||||||
tokenPatch["account_display_name"] = displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
planKnown := false
|
|
||||||
if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil {
|
|
||||||
meta := datatypes.JSONMap{
|
|
||||||
"cached_quota_at": int(time.Now().Unix()),
|
|
||||||
}
|
|
||||||
if remaining, ok := quotaData["remaining"].(int); ok {
|
|
||||||
meta["cached_quota_remaining"] = remaining
|
|
||||||
}
|
|
||||||
if used, ok := quotaData["used"].(int); ok {
|
|
||||||
meta["cached_quota_used"] = used
|
|
||||||
}
|
|
||||||
if total, ok := quotaData["total"].(int); ok {
|
|
||||||
meta["cached_quota_total"] = total
|
|
||||||
}
|
|
||||||
tokenPatch["meta"] = meta
|
|
||||||
|
|
||||||
planCap := strings.ToLower(strings.TrimSpace(stringValue(quotaData["plan"])))
|
|
||||||
isVIP := planCap != "" && !strings.EqualFold(planCap, "free")
|
|
||||||
planKnown = planCap != ""
|
|
||||||
// 额度打到 0 的 VIP(母号/子号) 视为普号:写死 plan=free,额度恢复(>0)后下次刷新自动还原真实身份。
|
|
||||||
if isVIP {
|
|
||||||
if rem, ok := quotaData["remaining"].(int); ok && rem <= 0 {
|
|
||||||
isVIP = false
|
|
||||||
planCap = "free"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
meta["plan"] = planCap
|
|
||||||
|
|
||||||
// VIP: use Adobe's reset time; set concurrency 5.
|
|
||||||
// 母号/子号 身份固定,不随积分动态变化——只有降级普号才刷新身份。
|
|
||||||
if isVIP {
|
|
||||||
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
|
|
||||||
tokenPatch["cached_quota_reset_after"] = resetAfter
|
|
||||||
}
|
|
||||||
tokenPatch["concurrency"] = 5
|
|
||||||
// 身份固定:这里重建了整个 meta,若不带上 is_sub_account,刷新会把导入时
|
|
||||||
// 确定的 母号/子号 标记冲掉。身份只在导入时确定,刷新只沿用、绝不靠积分猜:
|
|
||||||
// 已有标记就带过来;取不到(没 meta / 缺字段)说明身份未知,直接置死号。
|
|
||||||
if existing, gerr := s.tokens.Get(ctx, "adobe", id); gerr == nil {
|
|
||||||
if v, ok := existing.Meta["is_sub_account"]; ok {
|
|
||||||
meta["is_sub_account"] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := meta["is_sub_account"]; !ok {
|
|
||||||
tokenPatch["status"] = "disabled"
|
|
||||||
tokenPatch["dead"] = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Free account: concurrency 1, limit image+video
|
|
||||||
tokenPatch["concurrency"] = 1
|
|
||||||
tokenPatch["image_limited"] = true
|
|
||||||
tokenPatch["video_limited"] = true
|
|
||||||
meta["is_sub_account"] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
|
|
||||||
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
|
|
||||||
if !planKnown {
|
|
||||||
tokenPatch["status"] = "disabled"
|
|
||||||
tokenPatch["dead"] = true
|
|
||||||
}
|
|
||||||
if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := profile.IntervalSeconds
|
|
||||||
if interval <= 0 {
|
|
||||||
interval = 54000
|
|
||||||
}
|
|
||||||
_, err = s.profiles.Update(ctx, id, map[string]any{
|
|
||||||
"last_success_at": now,
|
|
||||||
"next_retry_at": now.Add(time.Duration(interval) * time.Second),
|
|
||||||
"last_error": "",
|
|
||||||
"consecutive_failures": 0,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshDue refreshes every enabled profile whose next_retry_at has passed.
|
|
||||||
// Driven by the background maintenance loop so Adobe cookies auto-renew without
|
|
||||||
// an admin clicking "refresh". Individual failures are recorded on the profile
|
|
||||||
// (backoff + dead escalation) and don't abort the sweep.
|
|
||||||
func (s *RefreshProfileService) RefreshDue(ctx context.Context) (int, error) {
|
|
||||||
if s.adobe == nil || s.tokens == nil {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
due, err := s.profiles.ListDue(ctx, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
refreshed := 0
|
|
||||||
for _, p := range due {
|
|
||||||
if p.Pool != "adobe" || p.Kind != "adobe_cookie" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := s.RefreshNow(ctx, p.ID); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
refreshed++
|
|
||||||
}
|
|
||||||
return refreshed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RefreshProfileService) Update(ctx context.Context, id string, body map[string]any) (*model.RefreshProfile, error) {
|
|
||||||
patch := map[string]any{}
|
|
||||||
if raw, ok := body["enabled"]; ok {
|
|
||||||
patch["enabled"] = boolValueWithDefault(raw, false)
|
|
||||||
}
|
|
||||||
if raw, ok := body["name"]; ok {
|
|
||||||
patch["name"] = stringValue(raw)
|
|
||||||
}
|
|
||||||
if raw, ok := body["interval_seconds"]; ok {
|
|
||||||
n := intValue(raw)
|
|
||||||
if n <= 0 {
|
|
||||||
return nil, errors.New("interval_seconds must be positive")
|
|
||||||
}
|
|
||||||
patch["interval_seconds"] = n
|
|
||||||
}
|
|
||||||
if len(patch) == 0 {
|
|
||||||
return s.profiles.Get(ctx, id)
|
|
||||||
}
|
|
||||||
return s.profiles.Update(ctx, id, patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *RefreshProfileService) Delete(ctx context.Context, id string) error {
|
|
||||||
return s.profiles.Delete(ctx, id)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveOwnedImage stores a user-uploaded image under their owner directory so it
|
||||||
|
// behaves like a generated output: it shows up in the 我的图片 picker, can be
|
||||||
|
// dropped onto the infinite canvas, and is served by /images/<owner>/<name>.
|
||||||
|
// format must be a decoder-registered image format ("png", "jpeg", ...).
|
||||||
|
func (s *AdminReadService) SaveOwnedImage(ctx context.Context, owner, format string, data []byte) (string, error) {
|
||||||
|
owner = strings.TrimSpace(owner)
|
||||||
|
if owner == "" || s.store == nil || !s.store.Configured() {
|
||||||
|
return "", errors.New("storage not configured")
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(strings.TrimSpace(format))
|
||||||
|
contentType := ""
|
||||||
|
switch ext {
|
||||||
|
case "jpeg":
|
||||||
|
ext = "jpg"
|
||||||
|
contentType = "image/jpeg"
|
||||||
|
case "png", "gif", "webp":
|
||||||
|
contentType = "image/" + ext
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported image format")
|
||||||
|
}
|
||||||
|
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
|
||||||
|
return "", errors.New("invalid image data")
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%s/up_%d%s.%s", owner, time.Now().Unix(), randomUpper(6), ext)
|
||||||
|
if err := s.store.Put(ctx, key, data, contentType); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Best-effort thumbnail, same convention as generated outputs.
|
||||||
|
if thumb, terr := makeThumbnail(data); terr == nil {
|
||||||
|
_ = s.store.Put(ctx, ThumbKey(key), thumb, "image/jpeg")
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
@@ -40,8 +40,8 @@ type UserGenerateRequest struct {
|
|||||||
// DeAI applies 去AI特征 post-processing to the generated image (image only)
|
// DeAI applies 去AI特征 post-processing to the generated image (image only)
|
||||||
// and charges the per-tier surcharge on top of the model price.
|
// and charges the per-tier surcharge on top of the model price.
|
||||||
DeAI bool
|
DeAI bool
|
||||||
// AccountID pins an admin test to one specific provider account (账号生图测试).
|
// ChannelID pins an admin test to one specific channel (渠道生图测试).
|
||||||
AccountID string
|
ChannelID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
|
func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
|
||||||
@@ -95,9 +95,10 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateReferenceMode mirrors the /v1 checks: the override must be "frame"
|
// validateReferenceMode mirrors the /v1 checks: the override must be "frame",
|
||||||
// or "asset", the model must support references at all, and frame mode carries
|
// "asset" or "media", the model must support references at all, and frame mode
|
||||||
// at most 2 images (first+last frame).
|
// carries at most 2 images (first+last frame). "media" = 参考视频/音频 — refs
|
||||||
|
// may be video or audio blobs (vivid-style upstreams that accept all kinds).
|
||||||
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
|
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
|
||||||
rm = strings.TrimSpace(rm)
|
rm = strings.TrimSpace(rm)
|
||||||
if rm == "" {
|
if rm == "" {
|
||||||
@@ -107,8 +108,8 @@ func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int
|
|||||||
if supported == "" || supported == "none" {
|
if supported == "" || supported == "none" {
|
||||||
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
|
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
|
||||||
}
|
}
|
||||||
if rm != "frame" && rm != "asset" {
|
if rm != "frame" && rm != "asset" && rm != "media" {
|
||||||
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
|
return fmt.Errorf("%w: reference_mode must be 'frame', 'asset' or 'media'", ErrUnsupportedParams)
|
||||||
}
|
}
|
||||||
if rm == "frame" && refCount > 2 {
|
if rm == "frame" && refCount > 2 {
|
||||||
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
|
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
|
||||||
@@ -137,7 +138,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
|
|||||||
AspectRatio: in.Ratio,
|
AspectRatio: in.Ratio,
|
||||||
Resolution: in.Resolution,
|
Resolution: in.Resolution,
|
||||||
ReferenceImages: in.ReferenceImages,
|
ReferenceImages: in.ReferenceImages,
|
||||||
AccountID: in.AccountID,
|
ChannelID: in.ChannelID,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
|
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
|
||||||
@@ -146,7 +147,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
|
|||||||
AspectRatio: in.Ratio,
|
AspectRatio: in.Ratio,
|
||||||
Resolution: in.Resolution,
|
Resolution: in.Resolution,
|
||||||
ReferenceImages: in.ReferenceImages,
|
ReferenceImages: in.ReferenceImages,
|
||||||
AccountID: in.AccountID,
|
ChannelID: in.ChannelID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PresignGet issues a query-string SigV4 URL that lets anyone holding it GET
|
||||||
|
// the object for ttl. Used for /v1/generate result URLs and reference
|
||||||
|
// read-back.
|
||||||
|
func (c *Client) PresignGet(key string, ttl time.Duration) (string, error) {
|
||||||
|
return c.presign(http.MethodGet, key, "", ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignPut issues a query-string SigV4 URL bound to PUT with the exact
|
||||||
|
// contentType signed in — the uploader must send that Content-Type verbatim
|
||||||
|
// (same contract vivid upstreams expect on /v1/uploads sessions).
|
||||||
|
func (c *Client) PresignPut(key, contentType string, ttl time.Duration) (string, error) {
|
||||||
|
if strings.TrimSpace(contentType) == "" {
|
||||||
|
return "", fmt.Errorf("presign put requires contentType")
|
||||||
|
}
|
||||||
|
return c.presign(http.MethodPut, key, contentType, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// presign builds an AWS SigV4 presigned URL (UNSIGNED-PAYLOAD). Signed headers
|
||||||
|
// are host (+content-type for PUT). Validity is capped at 7 days by SigV4.
|
||||||
|
func (c *Client) presign(method, key, contentType string, ttl time.Duration) (string, error) {
|
||||||
|
if !c.Configured() {
|
||||||
|
return "", fmt.Errorf("rustfs not configured")
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 30 * time.Minute
|
||||||
|
}
|
||||||
|
if ttl > 7*24*time.Hour {
|
||||||
|
ttl = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
amzDate := now.Format("20060102T150405Z")
|
||||||
|
dateStamp := now.Format("20060102")
|
||||||
|
scope := dateStamp + "/" + region + "/" + service + "/aws4_request"
|
||||||
|
|
||||||
|
signedHeaders := "host"
|
||||||
|
if contentType != "" {
|
||||||
|
signedHeaders = "content-type;host"
|
||||||
|
}
|
||||||
|
|
||||||
|
q := map[string]string{
|
||||||
|
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
||||||
|
"X-Amz-Credential": c.ak + "/" + scope,
|
||||||
|
"X-Amz-Date": amzDate,
|
||||||
|
"X-Amz-Expires": strconv.FormatInt(int64(ttl.Seconds()), 10),
|
||||||
|
"X-Amz-SignedHeaders": signedHeaders,
|
||||||
|
}
|
||||||
|
canonicalURI := "/" + uriEncode(c.bucket+"/"+strings.TrimPrefix(key, "/"), true)
|
||||||
|
canonicalQuery := canonicalQueryString(q)
|
||||||
|
|
||||||
|
var canonHeaders strings.Builder
|
||||||
|
if contentType != "" {
|
||||||
|
canonHeaders.WriteString("content-type:" + contentType + "\n")
|
||||||
|
}
|
||||||
|
canonHeaders.WriteString("host:" + c.host + "\n")
|
||||||
|
|
||||||
|
canonicalRequest := strings.Join([]string{
|
||||||
|
method, canonicalURI, canonicalQuery, canonHeaders.String(), signedHeaders, "UNSIGNED-PAYLOAD",
|
||||||
|
}, "\n")
|
||||||
|
stringToSign := strings.Join([]string{
|
||||||
|
"AWS4-HMAC-SHA256", amzDate, scope, hexSHA256([]byte(canonicalRequest)),
|
||||||
|
}, "\n")
|
||||||
|
sig := hex.EncodeToString(hmacSHA256(signingKey(c.sk, dateStamp), []byte(stringToSign)))
|
||||||
|
|
||||||
|
// Rebuild the query with url.Values so the signature chars get escaped
|
||||||
|
// correctly, then append X-Amz-Signature verbatim (hex, safe).
|
||||||
|
vals := url.Values{}
|
||||||
|
for k, v := range q {
|
||||||
|
vals.Set(k, v)
|
||||||
|
}
|
||||||
|
return c.endpoint + canonicalURI + "?" + vals.Encode() + "&X-Amz-Signature=" + sig, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
// Package upstream is the vivid-protocol client used to talk to channel
|
||||||
|
// upstreams (other same-protocol instances). It covers the full public
|
||||||
|
// surface: POST /v1/generate (NDJSON stream with jobId reconnect), POST
|
||||||
|
// /v1/uploads (presigned reference upload), GET /v1/models, GET /v1/balance,
|
||||||
|
// plus presigned-URL result download.
|
||||||
|
package upstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error classes. Dispatch decisions (retry / fail over / fail the job) key off
|
||||||
|
// these sentinels via errors.Is — KindOf maps them for log messages.
|
||||||
|
var (
|
||||||
|
// ErrAuth = channel credentials rejected (4011 / HTTP 401 / 403). The
|
||||||
|
// channel is skipped without consuming a retry budget.
|
||||||
|
ErrAuth = errors.New("upstream auth rejected")
|
||||||
|
// ErrInvalid = upstream rejected the request itself (4001 / HTTP 4xx).
|
||||||
|
// Retrying on another channel would fail identically — terminal.
|
||||||
|
ErrInvalid = errors.New("upstream rejected request")
|
||||||
|
// ErrJobFailed = the upstream ran the job and returned a failed frame.
|
||||||
|
// The frame's error text is user-safe and surfaced as-is.
|
||||||
|
ErrJobFailed = errors.New("upstream job failed")
|
||||||
|
// ErrRetryable = transient upstream failure (4293 / 5001 / 5xx / quota
|
||||||
|
// / timeout / transport). Fail over to the next channel.
|
||||||
|
ErrRetryable = errors.New("upstream temporary failure")
|
||||||
|
// ErrAmbiguous = the generate request was sent but the stream dropped
|
||||||
|
// before any jobId arrived — the job may or may not exist upstream.
|
||||||
|
// Treated as retryable for dispatch purposes.
|
||||||
|
ErrAmbiguous = errors.New("upstream submission ambiguous")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind is the coarse dispatch classification of an upstream error.
|
||||||
|
type Kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindRetryable Kind = iota
|
||||||
|
KindAuth
|
||||||
|
KindInvalid
|
||||||
|
KindJobFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// KindOf classifies an error returned by this package.
|
||||||
|
func KindOf(err error) Kind {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrAuth):
|
||||||
|
return KindAuth
|
||||||
|
case errors.Is(err, ErrInvalid):
|
||||||
|
return KindInvalid
|
||||||
|
case errors.Is(err, ErrJobFailed):
|
||||||
|
return KindJobFailed
|
||||||
|
default:
|
||||||
|
return KindRetryable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failoverable reports whether the request should be re-tried on another
|
||||||
|
// channel. ErrInvalid and ErrJobFailed are deterministic — same request, same
|
||||||
|
// result — so they are terminal.
|
||||||
|
func Failoverable(err error) bool {
|
||||||
|
return !errors.Is(err, ErrInvalid) && !errors.Is(err, ErrJobFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenParams is the POST /v1/generate request body.
|
||||||
|
type GenParams struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Quality string `json:"quality,omitempty"`
|
||||||
|
Ratio string `json:"ratio,omitempty"`
|
||||||
|
Duration int64 `json:"duration,omitempty"`
|
||||||
|
Refs []string `json:"refs,omitempty"`
|
||||||
|
JobID string `json:"jobId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteModel mirrors one item of a vivid GET /v1/models response.
|
||||||
|
type RemoteModel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Qualities []string `json:"qualities"`
|
||||||
|
Duration *RemoteDuration `json:"duration,omitempty"`
|
||||||
|
// Per-kind reference caps; 0 = that kind is not accepted.
|
||||||
|
RefImages int64 `json:"refImages"`
|
||||||
|
RefVideos int64 `json:"refVideos"`
|
||||||
|
RefAudios int64 `json:"refAudios"`
|
||||||
|
// Combined reference duration caps in seconds; 0 = unlimited.
|
||||||
|
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||||
|
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||||
|
// Bound is set by the admin fetch-models endpoint only: true when this
|
||||||
|
// upstream id is already bound to that channel locally.
|
||||||
|
Bound bool `json:"bound,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteDuration mirrors the duration spec of a vivid model item.
|
||||||
|
type RemoteDuration struct {
|
||||||
|
Mode string `json:"mode"` // options | range
|
||||||
|
Seconds int64 `json:"seconds,omitempty"`
|
||||||
|
Min int64 `json:"min,omitempty"`
|
||||||
|
Max int64 `json:"max,omitempty"`
|
||||||
|
Options []int64 `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is stateless; per-call baseURL/key come from the channel row.
|
||||||
|
type Client struct {
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{HTTP: &http.Client{Timeout: 30 * time.Minute}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) http() *http.Client {
|
||||||
|
if c != nil && c.HTTP != nil {
|
||||||
|
return c.HTTP
|
||||||
|
}
|
||||||
|
return NewClient().HTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxStreamFailures caps reconnect attempts after a jobId is known — each
|
||||||
|
// reconnect re-opens /v1/generate with {jobId} and never double-charges.
|
||||||
|
const maxStreamFailures = 3
|
||||||
|
|
||||||
|
// Generate submits a vivid job and reads the NDJSON stream to a terminal
|
||||||
|
// frame, reconnecting with the reported jobId when the stream drops mid-run
|
||||||
|
// (upstream heartbeat keeps us attached; transport blips don't lose the job).
|
||||||
|
// onJobID, when non-nil, fires once the upstream assigns a job id. Returns the
|
||||||
|
// presigned result URL and the credits the upstream reports charging us.
|
||||||
|
func (c *Client) Generate(ctx context.Context, baseURL, apiKey string, p GenParams, waitSeconds int64, onJobID func(string)) (resultURL string, credits int64, err error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" || apiKey == "" {
|
||||||
|
return "", 0, ErrAuth
|
||||||
|
}
|
||||||
|
var last map[string]any
|
||||||
|
failures := 0
|
||||||
|
for {
|
||||||
|
term, ferr := c.streamOnce(ctx, baseURL, apiKey, &p, waitSeconds, onJobID)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", 0, ctx.Err()
|
||||||
|
}
|
||||||
|
if ferr != nil && !errors.Is(ferr, ErrRetryable) && !errors.Is(ferr, ErrAmbiguous) {
|
||||||
|
return "", 0, ferr
|
||||||
|
}
|
||||||
|
if ferr != nil {
|
||||||
|
failures++
|
||||||
|
} else {
|
||||||
|
failures = 0
|
||||||
|
}
|
||||||
|
if ferr == nil && term != nil {
|
||||||
|
status := strings.ToLower(strings.TrimSpace(str(term["status"])))
|
||||||
|
if status != "running" && status != "" {
|
||||||
|
last = term
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.JobID == "" {
|
||||||
|
if ferr != nil {
|
||||||
|
return "", 0, ferr
|
||||||
|
}
|
||||||
|
return "", 0, ErrAmbiguous
|
||||||
|
}
|
||||||
|
if failures >= maxStreamFailures {
|
||||||
|
if ferr != nil {
|
||||||
|
return "", 0, ferr
|
||||||
|
}
|
||||||
|
return "", 0, fmt.Errorf("%w: stream dropped repeatedly", ErrRetryable)
|
||||||
|
}
|
||||||
|
if sleepCtx(ctx, 2*time.Second) != nil {
|
||||||
|
return "", 0, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := strings.ToLower(strings.TrimSpace(str(last["status"])))
|
||||||
|
switch status {
|
||||||
|
case "succeeded", "completed", "success":
|
||||||
|
resultURL = strings.TrimSpace(str(last["result"]))
|
||||||
|
if resultURL == "" {
|
||||||
|
return "", 0, fmt.Errorf("%w: succeeded frame missing result", ErrRetryable)
|
||||||
|
}
|
||||||
|
return resultURL, int64(num(last["creditsCharged"])), nil
|
||||||
|
default:
|
||||||
|
reason := strings.TrimSpace(str(last["error"]))
|
||||||
|
if reason == "" {
|
||||||
|
reason = "upstream job " + status
|
||||||
|
}
|
||||||
|
if isCreditError(reason) {
|
||||||
|
return "", 0, fmt.Errorf("%w: %s", ErrRetryable, clip([]byte(reason), 160))
|
||||||
|
}
|
||||||
|
// failed 帧的 error 是上游给用户看的中文文案 — 透传
|
||||||
|
return "", 0, fmt.Errorf("%w: %s", ErrJobFailed, clip([]byte(reason), 160))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamOnce performs one /v1/generate call and scans the NDJSON body. It
|
||||||
|
// records the upstream jobId into p.JobID as soon as a frame carries it.
|
||||||
|
func (c *Client) streamOnce(ctx context.Context, baseURL, apiKey string, p *GenParams, waitSeconds int64, onJobID func(string)) (map[string]any, error) {
|
||||||
|
raw, _ := json.Marshal(p)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/generate", bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if waitSeconds <= 0 {
|
||||||
|
waitSeconds = 300
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Wait-Seconds", fmt.Sprintf("%d", waitSeconds))
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if p.JobID == "" {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, vividError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var last map[string]any
|
||||||
|
sc := bufio.NewScanner(resp.Body)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := bytes.TrimSpace(sc.Bytes())
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue // 25s heartbeat empty line
|
||||||
|
}
|
||||||
|
var frame map[string]any
|
||||||
|
if json.Unmarshal(line, &frame) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last = frame
|
||||||
|
if job := strings.TrimSpace(str(frame["jobId"])); job != "" && job != p.JobID {
|
||||||
|
p.JobID = job
|
||||||
|
if onJobID != nil {
|
||||||
|
onJobID(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status := strings.ToLower(strings.TrimSpace(str(frame["status"]))); status == "succeeded" || status == "failed" {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
if p.JobID == "" {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadRef runs the two-step reference upload: POST /v1/uploads {kind,mime}
|
||||||
|
// then PUT the bytes to the presigned url with the exact signed Content-Type.
|
||||||
|
// Returns the object key for refs[].
|
||||||
|
func (c *Client) UploadRef(ctx context.Context, baseURL, apiKey string, data []byte) (string, error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" || apiKey == "" {
|
||||||
|
return "", ErrAuth
|
||||||
|
}
|
||||||
|
kind, mime := RefKindMime(data)
|
||||||
|
if kind == "" {
|
||||||
|
return "", fmt.Errorf("%w: 参考素材格式不支持", ErrInvalid)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]any{"kind": kind, "mime": mime})
|
||||||
|
up, err := c.postJSON(ctx, baseURL+"/v1/uploads", apiKey, raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
putURL, key := UploadSession(up)
|
||||||
|
if putURL == "" || key == "" {
|
||||||
|
rawUp, _ := json.Marshal(up)
|
||||||
|
return "", fmt.Errorf("%w: upload session missing url/key (got %s)", ErrRetryable, clip(rawUp, 200))
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mime) // must match the signed mime exactly
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("%w: ref upload %d %s", ErrRetryable, resp.StatusCode, clip(body, 120))
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadSession extracts the presigned PUT url + object key from a
|
||||||
|
// /v1/uploads response. Implementations vary: some return the pair at top
|
||||||
|
// level, others wrap it in {"data":{...}} / {"result":{...}}, and field
|
||||||
|
// spellings differ (upload_url / presigned_url / put_url; key / file_key /
|
||||||
|
// object_key). Search top level first, then one layer deep.
|
||||||
|
func UploadSession(up map[string]any) (putURL, key string) {
|
||||||
|
// Go map 查找区分大小写,而上游实现可能返回 PascalCase 字段;按去分隔符+
|
||||||
|
// 小写归一化匹配。
|
||||||
|
urlKeys := map[string]bool{"url": true, "uploadurl": true, "presignedurl": true, "puturl": true, "signedurl": true}
|
||||||
|
keyKeys := map[string]bool{"key": true, "filekey": true, "objectkey": true, "uploadkey": true}
|
||||||
|
norm := func(s string) string { return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(s)) }
|
||||||
|
pick := func(m map[string]any) (string, string) {
|
||||||
|
var u, k string
|
||||||
|
for name, v := range m {
|
||||||
|
s := strings.TrimSpace(str(v))
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case u == "" && urlKeys[norm(name)]:
|
||||||
|
u = s
|
||||||
|
case k == "" && keyKeys[norm(name)]:
|
||||||
|
k = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u, k
|
||||||
|
}
|
||||||
|
levels := []map[string]any{up}
|
||||||
|
for _, nest := range []string{"data", "result", "upload", "session", "upload_session"} {
|
||||||
|
if sub, ok := up[nest].(map[string]any); ok {
|
||||||
|
levels = append(levels, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range levels {
|
||||||
|
u, k := pick(m)
|
||||||
|
if putURL == "" {
|
||||||
|
putURL = u
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
key = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return putURL, key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models fetches the upstream's sellable model list (GET /v1/models → bare
|
||||||
|
// array of RemoteModel).
|
||||||
|
func (c *Client) Models(ctx context.Context, baseURL, apiKey string) ([]RemoteModel, error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" || apiKey == "" {
|
||||||
|
return nil, ErrAuth
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, vividError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var items []RemoteModel
|
||||||
|
if err := json.Unmarshal(body, &items); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: bad /v1/models body", ErrRetryable)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance fetches the upstream balance (GET /v1/balance → {"balance":N}).
|
||||||
|
// Returns nil when the upstream does not implement the endpoint.
|
||||||
|
func (c *Client) Balance(ctx context.Context, baseURL, apiKey string) (*int64, error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" || apiKey == "" {
|
||||||
|
return nil, ErrAuth
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/balance", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, vividError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Balance *int64 `json:"balance"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &env) != nil || env.Balance == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return env.Balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches a presigned result URL — no Authorization header.
|
||||||
|
func (c *Client) Download(ctx context.Context, url string) ([]byte, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, "", fmt.Errorf("%w: result download %d", ErrRetryable, resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
|
||||||
|
}
|
||||||
|
// Content-Type fallback chain: only trust image/* video/* headers (upstreams
|
||||||
|
// often mislabel results text/plain or octet-stream), else sniff magic bytes.
|
||||||
|
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||||
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||||
|
ct = strings.TrimSpace(ct[:i])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ct, "image/") && !strings.HasPrefix(ct, "video/") {
|
||||||
|
ct = http.DetectContentType(data)
|
||||||
|
}
|
||||||
|
return data, ct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) postJSON(ctx context.Context, url, apiKey string, body []byte) (map[string]any, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, vividError(resp.StatusCode, raw)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: bad response %s", ErrRetryable, clip(raw, 120))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vividError maps the {"error":{"code":"NNNN","message":...}} envelope to our
|
||||||
|
// error classes; falls back to plain HTTP status mapping.
|
||||||
|
func vividError(status int, body []byte) error {
|
||||||
|
var e struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
code := ""
|
||||||
|
msg := clip(body, 160)
|
||||||
|
if json.Unmarshal(body, &e) == nil {
|
||||||
|
code = strings.TrimSpace(e.Error.Code)
|
||||||
|
if m := strings.TrimSpace(e.Error.Message); m != "" {
|
||||||
|
msg = clip([]byte(m), 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case "4011":
|
||||||
|
return fmt.Errorf("%w: %s", ErrAuth, msg)
|
||||||
|
case "4002":
|
||||||
|
return fmt.Errorf("%w: 积分不足 %s", ErrRetryable, msg)
|
||||||
|
case "4293", "5001":
|
||||||
|
return fmt.Errorf("%w: %s", ErrRetryable, msg)
|
||||||
|
case "4001":
|
||||||
|
return fmt.Errorf("%w: %s", ErrInvalid, msg)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||||
|
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
||||||
|
case status == http.StatusTooManyRequests || status >= 500:
|
||||||
|
return fmt.Errorf("%w: http %d %s", ErrRetryable, status, msg)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: http %d %s", ErrInvalid, status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefKindMime classifies a reference blob into the vivid "kind" (image /
|
||||||
|
// video / audio) plus a concrete mime. Magic bytes first (DetectContentType
|
||||||
|
// misses mov/m4a/ogg-audio), then sniffing, then a png default.
|
||||||
|
func RefKindMime(data []byte) (kind, mime string) {
|
||||||
|
n := len(data)
|
||||||
|
// ISOBMFF box: ftyp — mp4/mov/m4a share the container; the brand decides.
|
||||||
|
if n >= 12 && string(data[4:8]) == "ftyp" {
|
||||||
|
brand := string(data[8:12])
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(brand, "M4A"), strings.HasPrefix(brand, "M4B"), strings.HasPrefix(brand, "F4A"), hasBMFFHandler(data, "soun") && !hasBMFFHandler(data, "vide"):
|
||||||
|
return "audio", "audio/mp4"
|
||||||
|
case brand == "qt ":
|
||||||
|
return "video", "video/quicktime"
|
||||||
|
default:
|
||||||
|
return "video", "video/mp4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WebM/MKV EBML
|
||||||
|
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 && bytes.Contains(bytes.ToLower(data[:minInt(n, 4096)]), []byte("webm")) {
|
||||||
|
return "video", "video/webm"
|
||||||
|
}
|
||||||
|
// MP3: ID3 tag or MPEG sync
|
||||||
|
if n >= 3 && string(data[:3]) == "ID3" {
|
||||||
|
return "audio", "audio/mpeg"
|
||||||
|
}
|
||||||
|
if n >= 4 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 &&
|
||||||
|
(data[1]>>3)&3 != 1 && (data[1]>>1)&3 != 0 && data[2]>>4 != 0 && data[2]>>4 != 15 && (data[2]>>2)&3 != 3 {
|
||||||
|
return "audio", "audio/mpeg"
|
||||||
|
}
|
||||||
|
// WAV / OGG
|
||||||
|
if n >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" {
|
||||||
|
return "audio", "audio/wav"
|
||||||
|
}
|
||||||
|
if n >= 27 && string(data[:4]) == "OggS" && (bytes.Contains(data[:minInt(n, 4096)], []byte("OpusHead")) ||
|
||||||
|
bytes.Contains(data[:minInt(n, 4096)], []byte("vorbis")) || bytes.Contains(data[:minInt(n, 4096)], []byte("Speex ")) ||
|
||||||
|
bytes.Contains(data[:minInt(n, 4096)], []byte("fLaC"))) {
|
||||||
|
return "audio", "audio/ogg"
|
||||||
|
}
|
||||||
|
if m := http.DetectContentType(data); m == "image/png" || m == "image/jpeg" || m == "image/webp" {
|
||||||
|
return "image", m
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasBMFFHandler(data []byte, handler string) bool {
|
||||||
|
data = data[:minInt(len(data), 4096)]
|
||||||
|
for offset := 0; offset < len(data); {
|
||||||
|
i := bytes.Index(data[offset:], []byte("hdlr"))
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
i += offset
|
||||||
|
if i+16 <= len(data) && string(data[i+12:i+16]) == handler {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
offset = i + 4
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCreditError(s string) bool {
|
||||||
|
m := strings.ToLower(s)
|
||||||
|
return strings.Contains(m, "credit") || strings.Contains(m, "quota") ||
|
||||||
|
strings.Contains(m, "insufficient") || strings.Contains(s, "积分不足") ||
|
||||||
|
strings.Contains(s, "余额")
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case fmt.Stringer:
|
||||||
|
return t.String()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func num(v any) float64 {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return t
|
||||||
|
case int64:
|
||||||
|
return float64(t)
|
||||||
|
case json.Number:
|
||||||
|
f, _ := t.Float64()
|
||||||
|
return f
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clip(b []byte, n int) string {
|
||||||
|
s := strings.TrimSpace(string(b))
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeErr strips the upstream URL/host from a network error so a channel's
|
||||||
|
// private upstream URL never leaks into the event log / API response.
|
||||||
|
func sanitizeErr(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
||||||
|
return "request timeout"
|
||||||
|
case strings.Contains(s, "connection refused"):
|
||||||
|
return "connection refused"
|
||||||
|
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
||||||
|
return "cannot reach upstream"
|
||||||
|
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
||||||
|
return "TLS error"
|
||||||
|
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
||||||
|
return "connection reset"
|
||||||
|
}
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
return strings.ToLower(ue.Op) + " upstream failed"
|
||||||
|
}
|
||||||
|
return "upstream request failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-t.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxMediaDurationSec 时长 sanity 上限:解析出超过 24h 视为垃圾数据丢弃。
|
||||||
|
const maxMediaDurationSec = 24 * 3600
|
||||||
|
|
||||||
|
// MediaDurationSeconds 从已读片段尽力解析媒体时长(秒)。
|
||||||
|
// mp4/mov/m4a 扫描 mvhd 盒子;webm 解 EBML Duration×TimecodeScale;wav 按
|
||||||
|
// byteRate 换算。解析不出返回 false——调用方可用文件尾片段再试一次
|
||||||
|
// (未 faststart 的 mp4 moov 盒子常在文件尾)。mp3/ogg 无廉价可靠头长度,跳过。
|
||||||
|
func MediaDurationSeconds(mime string, buf []byte) (float64, bool) {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var sec float64
|
||||||
|
var ok bool
|
||||||
|
switch mime {
|
||||||
|
case "video/mp4", "video/quicktime", "audio/mp4":
|
||||||
|
sec, ok = mvhdDuration(buf)
|
||||||
|
case "video/webm":
|
||||||
|
sec, ok = webmDuration(buf)
|
||||||
|
case "audio/wav":
|
||||||
|
sec, ok = wavDuration(buf)
|
||||||
|
}
|
||||||
|
if !ok || sec <= 0 || sec > maxMediaDurationSec {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return sec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mvhdDuration 在缓冲里扫 "mvhd" 盒子并解析 duration/timescale。
|
||||||
|
// 盒子布局:[size4][type4=mvhd][version1][flags3]
|
||||||
|
// v0: creation4 mod4 timescale4 duration4 → timescale@idx+16, duration@idx+20
|
||||||
|
// v1: creation8 mod8 timescale4 duration8 → timescale@idx+24, duration@idx+28
|
||||||
|
func mvhdDuration(buf []byte) (float64, bool) {
|
||||||
|
for off := 0; ; {
|
||||||
|
i := bytes.Index(buf[off:], []byte("mvhd"))
|
||||||
|
if i < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
i += off
|
||||||
|
if ver := buf[i+4]; ver == 0 && i+24 <= len(buf) {
|
||||||
|
ts := binary.BigEndian.Uint32(buf[i+16 : i+20])
|
||||||
|
dur := binary.BigEndian.Uint32(buf[i+20 : i+24])
|
||||||
|
if ts > 0 && dur > 0 {
|
||||||
|
return float64(dur) / float64(ts), true
|
||||||
|
}
|
||||||
|
} else if ver == 1 && i+36 <= len(buf) {
|
||||||
|
ts := binary.BigEndian.Uint32(buf[i+24 : i+28])
|
||||||
|
dur := binary.BigEndian.Uint64(buf[i+28 : i+36])
|
||||||
|
if ts > 0 && dur > 0 {
|
||||||
|
return float64(dur) / float64(ts), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off = i + 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webmDuration 扫 EBML Duration 元素(0x4489,float)和 TimecodeScale
|
||||||
|
// (0x2AD7B1,uint,缺省 1,000,000ns)。秒 = duration × scale / 1e9。
|
||||||
|
func webmDuration(buf []byte) (float64, bool) {
|
||||||
|
scale := uint64(1_000_000)
|
||||||
|
if i := bytes.Index(buf, []byte{0x2a, 0xd7, 0xb1}); i >= 0 {
|
||||||
|
if v, n := ebmlVint(buf[i+3:]); n > 0 && i+3+n+v <= len(buf) {
|
||||||
|
scale = 0
|
||||||
|
for _, b := range buf[i+3+n : i+3+n+v] {
|
||||||
|
scale = scale<<8 | uint64(b)
|
||||||
|
}
|
||||||
|
if scale == 0 {
|
||||||
|
scale = 1_000_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i := bytes.Index(buf, []byte{0x44, 0x89})
|
||||||
|
if i < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, n := ebmlVint(buf[i+2:])
|
||||||
|
if n == 0 || i+2+n+v > len(buf) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f := buf[i+2+n : i+2+n+v]
|
||||||
|
var dur float64
|
||||||
|
switch v {
|
||||||
|
case 4:
|
||||||
|
dur = float64(math.Float32frombits(binary.BigEndian.Uint32(f)))
|
||||||
|
case 8:
|
||||||
|
dur = math.Float64frombits(binary.BigEndian.Uint64(f))
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return dur * float64(scale) / 1e9, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ebmlVint 读 EBML 变长整数的长度值:返回 (负载长度, vint 本身字节数)。
|
||||||
|
func ebmlVint(buf []byte) (int, int) {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
for n := 1; n <= 8; n++ {
|
||||||
|
mask := byte(0x80 >> (n - 1))
|
||||||
|
if buf[0]&mask != 0 {
|
||||||
|
if len(buf) < n {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
v := int(buf[0] &^ mask)
|
||||||
|
for i := 1; i < n; i++ {
|
||||||
|
v = v<<8 | int(buf[i])
|
||||||
|
}
|
||||||
|
return v, n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// wavDuration 解析 RIFF/WAVE:byteRate@28(LE u32) + "data" 块大小换算秒数。
|
||||||
|
func wavDuration(buf []byte) (float64, bool) {
|
||||||
|
if len(buf) < 44 || string(buf[:4]) != "RIFF" || string(buf[8:12]) != "WAVE" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
rate := binary.LittleEndian.Uint32(buf[28:32])
|
||||||
|
if rate == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for off := 12; off+8 <= len(buf); {
|
||||||
|
size := int(binary.LittleEndian.Uint32(buf[off+4 : off+8]))
|
||||||
|
if string(buf[off:off+4]) == "data" {
|
||||||
|
return float64(size) / float64(rate), true
|
||||||
|
}
|
||||||
|
off += 8 + size + size&1 // 块按 2 字节对齐
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ services:
|
|||||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026}
|
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183}
|
||||||
COOKIE_SECURE: "false"
|
COOKIE_SECURE: "false"
|
||||||
|
# 渠道上游 API Key 的 AES-GCM 加密密钥——务必配置随机值;丢失/变更后已存的渠道密钥无法解密
|
||||||
|
CHANNEL_SECRET: ${CHANNEL_SECRET:-change-me-channel-secret}
|
||||||
volumes:
|
volumes:
|
||||||
- generated:/app/data/generated
|
- generated:/app/data/generated
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ services:
|
|||||||
RUSTFS_SECRET_KEY: vividai-secret-change-me
|
RUSTFS_SECRET_KEY: vividai-secret-change-me
|
||||||
CORS_ORIGINS: http://localhost:2000
|
CORS_ORIGINS: http://localhost:2000
|
||||||
COOKIE_SECURE: "false"
|
COOKIE_SECURE: "false"
|
||||||
|
# 渠道上游 API Key 的 AES-GCM 加密密钥——生产环境务必改成随机值并保持不变
|
||||||
|
CHANNEL_SECRET: change-me-channel-secret
|
||||||
volumes:
|
volumes:
|
||||||
- generated:/app/data/generated
|
- generated:/app/data/generated
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 553 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 460 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 140 KiB |
@@ -1,7 +1,8 @@
|
|||||||
# ai-gateway frontend
|
# image2api frontend
|
||||||
|
|
||||||
Vue 3 + Vite admin console for the ai-gateway backend. This replaces the old
|
Vue 3 + Vite frontend for `image2api`: user-facing generation UI (playground,
|
||||||
single-file `static/admin.html`.
|
gallery, docs, billing) plus the admin console (channels, models, users,
|
||||||
|
orders, logs, config).
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -10,27 +11,10 @@ npm install
|
|||||||
npm run dev # http://localhost:5173
|
npm run dev # http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
The dev server proxies `/admin`, `/health`, `/generated`, `/v1` to the backend.
|
The dev server proxies `/admin`, `/health`, `/images`, `/v1` to the backend.
|
||||||
Start the backend separately:
|
Start the Go backend separately (see `backend/README.md`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from repo root
|
# from ../backend
|
||||||
python app.py # http://0.0.0.0:6060
|
go build -o bin/api ./cmd/api && ./bin/api # http://127.0.0.1:6666
|
||||||
```
|
```
|
||||||
|
|
||||||
If the backend runs elsewhere, set `VITE_BACKEND` before `npm run dev`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
VITE_BACKEND=http://192.168.1.10:6060 npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build # outputs static assets to ./dist
|
|
||||||
npm run preview # serve the production build locally
|
|
||||||
```
|
|
||||||
|
|
||||||
When hosting `dist/` on a different origin than the API, set `VITE_API_BASE`
|
|
||||||
(e.g. `VITE_API_BASE=http://api-host:6060`) at build time, and add that frontend
|
|
||||||
origin to the backend's `CORS_ORIGINS` env var.
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" fill="none">
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="ring" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
|
<linearGradient id="bg" x1="8" y1="4" x2="40" y2="44" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#e879f9"/><stop offset=".5" stop-color="#8b5cf6"/><stop offset="1" stop-color="#38bdf8"/>
|
<stop stop-color="#3f3f46"/>
|
||||||
|
<stop offset="1" stop-color="#18181b"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="v" x1="14" y1="14" x2="34" y2="34" gradientUnits="userSpaceOnUse">
|
<linearGradient id="shine" x1="24" y1="3" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#f0abfc"/><stop offset="1" stop-color="#8b5cf6"/>
|
<stop stop-color="#fff" stop-opacity="0.14"/>
|
||||||
|
<stop offset="0.9" stop-color="#fff" stop-opacity="0"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<g transform="translate(24 24)">
|
<rect x="2" y="2" width="44" height="44" rx="15" fill="url(#bg)"/>
|
||||||
<circle r="18.5" fill="none" stroke="url(#ring)" stroke-width="3.6" stroke-linecap="round"
|
<rect x="2" y="2" width="44" height="22" rx="15" fill="url(#shine)"/>
|
||||||
stroke-dasharray="86 30" transform="rotate(-58)"/>
|
<path d="M22 11 Q23.4 22.6 35 24 Q23.4 25.4 22 37 Q20.6 25.4 9 24 Q20.6 22.6 22 11 Z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/>
|
||||||
<path d="M-8 -7.5 L0 9 L8 -7.5" fill="none" stroke="url(#v)" stroke-width="5.2"
|
<path d="M35.5 9 Q36.2 13.8 41 14.5 Q36.2 15.2 35.5 20 Q34.8 15.2 30 14.5 Q34.8 13.8 35.5 9 Z" fill="#fff" stroke="#fff" stroke-width="1.4" stroke-linejoin="round" opacity="0.92"/>
|
||||||
stroke-linecap="round" stroke-linejoin="round"/>
|
<circle cx="36" cy="36" r="2.6" fill="#fb923c"/>
|
||||||
<circle cx="13" cy="-13" r="3.6" fill="#38bdf8"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 934 B After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9d8c4"/><stop offset="1" stop-color="#c9a27e"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="492.00000000000006" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd8c8"/><stop offset="1" stop-color="#8aa38b"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="414" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cdd6e0"/><stop offset="1" stop-color="#7f93ad"/></linearGradient></defs><rect width="600" height="600" fill="url(#g)"/><circle cx="336" cy="192" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 396 Q150 300 300 384 T600 348 V600 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 468 Q180 396 360 468 T600 432 V600 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 517 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6c9bd"/><stop offset="1" stop-color="#c07a5f"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="558" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6c9bd"/><stop offset="1" stop-color="#c07a5f"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="656" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#dcd6cf"/><stop offset="1" stop-color="#9a9088"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="552" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d9cfe0"/><stop offset="1" stop-color="#9a86a8"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="448" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfe0dc"/><stop offset="1" stop-color="#6f9c95"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="744" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9d8c4"/><stop offset="1" stop-color="#c9a27e"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="640" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd8c8"/><stop offset="1" stop-color="#8aa38b"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="536" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cdd6e0"/><stop offset="1" stop-color="#7f93ad"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="432" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -11,7 +11,13 @@ const BASE = import.meta.env.VITE_API_BASE || ''
|
|||||||
export async function api(path, opts = {}) {
|
export async function api(path, opts = {}) {
|
||||||
const headers = { ...(opts.headers || {}) }
|
const headers = { ...(opts.headers || {}) }
|
||||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||||
const r = await fetch(`${BASE}/admin/api${path}`, { ...opts, headers })
|
// 请求不能无限挂起(比如上传走丢的网关连接)——120s 超时兜底
|
||||||
|
let signal = opts.signal
|
||||||
|
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
|
||||||
|
const t = AbortSignal.timeout(120000)
|
||||||
|
signal = signal && AbortSignal.any ? AbortSignal.any([signal, t]) : t
|
||||||
|
}
|
||||||
|
const r = await fetch(`${BASE}/admin/api${path}`, { ...opts, headers, signal })
|
||||||
let data = null
|
let data = null
|
||||||
try {
|
try {
|
||||||
data = await r.json()
|
data = await r.json()
|
||||||
@@ -47,13 +53,54 @@ export async function fetchHealth() {
|
|||||||
return r.json()
|
return r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Absolute URL for a generated artifact (works in dev via proxy too). */
|
/** Absolute URL for a generated artifact (works in dev via proxy too).
|
||||||
|
* Older canvas documents may contain "/images/..." or a full URL instead of
|
||||||
|
* the storage key; normalize all three forms instead of double-prefixing. */
|
||||||
export function generatedUrl(name) {
|
export function generatedUrl(name) {
|
||||||
return `${BASE}/images/${name}`
|
const s = String(name || '').trim()
|
||||||
|
if (!s) return ''
|
||||||
|
const marker = '/images/'
|
||||||
|
const i = s.indexOf(marker)
|
||||||
|
if (i >= 0) return /^https?:\/\//i.test(s) ? s : `${BASE}${s.slice(i)}`
|
||||||
|
if (/^https?:\/\//i.test(s)) return s
|
||||||
|
return `${BASE}/images/${s.replace(/^\/?images\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a private image (thumbnail first, original as fallback) with the
|
||||||
|
* SPA's Bearer token. Returns { blob, status } — throws on real failure. */
|
||||||
|
async function fetchImageBlob(name, signal) {
|
||||||
|
const headers = auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
|
||||||
|
let u = thumbUrl(name)
|
||||||
|
let r = await fetch(u, { headers, credentials: 'include', signal })
|
||||||
|
if (!r.ok || r.status === 404) {
|
||||||
|
u = generatedUrl(name)
|
||||||
|
r = await fetch(u, { headers, credentials: 'include', signal })
|
||||||
|
}
|
||||||
|
if (!r.ok) throw new Error(`图片加载失败 (${r.status})`)
|
||||||
|
return { blob: await r.blob(), status: r.status }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as fetchImageBlob but returns a revocable blob: URL for <img>/<video>. */
|
||||||
|
export async function loadImageBlob(name, signal) {
|
||||||
|
const { blob, status } = await fetchImageBlob(name, signal)
|
||||||
|
return { url: URL.createObjectURL(blob), status }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as fetchImageBlob but returns a data: URL (用于参考图提交/复制)。 */
|
||||||
|
export async function loadImageData(name) {
|
||||||
|
const { blob } = await fetchImageBlob(name)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader()
|
||||||
|
fr.onload = () => resolve(fr.result)
|
||||||
|
fr.onerror = reject
|
||||||
|
fr.readAsDataURL(blob)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small thumbnail URL for list views. The server falls back to the original
|
/** Small thumbnail URL for list views. The server falls back to the original
|
||||||
* when no thumbnail object exists (old images), so this is always safe. */
|
* when no thumbnail object exists (old images), so this is always safe. */
|
||||||
export function thumbUrl(name) {
|
export function thumbUrl(name) {
|
||||||
return `${BASE}/images/${name}.thumb.jpg`
|
const url = generatedUrl(name)
|
||||||
|
if (!url || url.endsWith('.thumb.jpg') || !url.includes('/images/')) return url
|
||||||
|
return `${url}.thumb.jpg`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { api, jsonBody } from '../api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
|
|
||||||
const props = defineProps({ account: { type: Object, required: true } })
|
|
||||||
const emit = defineEmits(['close', 'saved'])
|
|
||||||
|
|
||||||
// concurrency is only adjustable for custom upstreams — others are system-fixed
|
|
||||||
// (adobe is set from the plan on every quota refresh: 会员号 5 / 普号 1).
|
|
||||||
const canEditConcurrency = props.account.type === 'custom'
|
|
||||||
const fixedConcurrency =
|
|
||||||
props.account.type === 'adobe'
|
|
||||||
? (props.account.plan === 'free' ? 1 : Number(props.account.concurrency) || 5)
|
|
||||||
: props.account.type === 'grok'
|
|
||||||
? 10
|
|
||||||
: 1
|
|
||||||
|
|
||||||
const weight = ref(Number(props.account.weight) || 0)
|
|
||||||
const concurrency = ref(Number(props.account.concurrency) || 1)
|
|
||||||
const status = ref('')
|
|
||||||
const isError = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
submitting.value = true; status.value = ''; isError.value = false
|
|
||||||
const payload = { weight: Number(weight.value) || 0 }
|
|
||||||
if (canEditConcurrency) payload.concurrency = Math.max(1, Number(concurrency.value) || 1)
|
|
||||||
try {
|
|
||||||
const r = await api(`/tokens/${props.account.pool}/${props.account.id}`, jsonBody('PATCH', payload))
|
|
||||||
if (r.ok) {
|
|
||||||
status.value = '✓ 已保存'; emit('saved', payload)
|
|
||||||
setTimeout(() => emit('close'), 600)
|
|
||||||
} else {
|
|
||||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
status.value = String(e); isError.value = true
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
|
||||||
@click.self="emit('close')">
|
|
||||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
|
||||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
|
||||||
<h2 class="text-sm font-semibold">编辑账号</h2>
|
|
||||||
<button @click="emit('close')" class="text-white/40 hover:text-white">
|
|
||||||
<Icon name="close" class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-5 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="lbl">账户</label>
|
|
||||||
<input :value="account.email || account.id" disabled class="field font-mono" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="lbl">类型</label>
|
|
||||||
<input :value="account.type" disabled class="field" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="lbl">权重 <span class="text-white/35">(高的优先)</span></label>
|
|
||||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="lbl">并发数 <span class="text-white/35">{{ canEditConcurrency ? '(单账号)' : '(系统固定,不可调整)' }}</span></label>
|
|
||||||
<input v-if="canEditConcurrency" v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
|
||||||
<input v-else :value="fixedConcurrency" disabled class="field" />
|
|
||||||
</div>
|
|
||||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-400' : 'text-emerald-400'">{{ status }}</p>
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<button @click="emit('close')" class="btn-soft">取消</button>
|
|
||||||
<button @click="submit" :disabled="submitting" class="btn-primary">{{ submitting ? '保存中…' : '保存' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* mirror the 用户管理 edit modal so the two are visually identical */
|
|
||||||
.lbl {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgb(255 255 255 / 0.55);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
/* disabled inputs — readable, but visually 'cool' so the admin knows they
|
|
||||||
can't change them (matches UsersView). */
|
|
||||||
.field:disabled {
|
|
||||||
opacity: 0.65;
|
|
||||||
cursor: not-allowed;
|
|
||||||
background: rgb(255 255 255 / 0.025);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
|
||||||
import { api, jsonBody } from '../api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import SelectMenu from './SelectMenu.vue'
|
|
||||||
|
|
||||||
// 账号生成测试:固定使用某个 provider 账号跑一次生成(图像或视频)。
|
|
||||||
// 模型列表由 AccountsView 打开页面时预加载好传入,弹窗即开即用。
|
|
||||||
const props = defineProps({
|
|
||||||
account: { type: Object, required: true }, // { pool, id, email, type }
|
|
||||||
allModels: { type: Array, default: () => [] },
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['close'])
|
|
||||||
|
|
||||||
// 该账号所属 provider 的全部模型(图像 + 视频)。
|
|
||||||
const models = computed(() =>
|
|
||||||
props.allModels.filter((m) => (m.provider || '') === props.account.pool))
|
|
||||||
const selectedModel = ref('')
|
|
||||||
if (models.value.length) selectedModel.value = models.value[0].alias || models.value[0].id
|
|
||||||
|
|
||||||
const modelOptions = computed(() => models.value.map((m) => ({
|
|
||||||
value: m.alias || m.id,
|
|
||||||
label: `${m.alias || m.id}${m.type === 'video' ? ' · 视频' : ''}`,
|
|
||||||
})))
|
|
||||||
const currentModel = computed(() =>
|
|
||||||
models.value.find((m) => (m.alias || m.id) === selectedModel.value) || null)
|
|
||||||
const isVideo = computed(() => currentModel.value?.type === 'video')
|
|
||||||
|
|
||||||
const prompt = ref('a cute cat sitting on a desk, studio lighting')
|
|
||||||
|
|
||||||
const busy = ref(false)
|
|
||||||
const status = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const resultUrl = ref('')
|
|
||||||
const resultKind = ref('')
|
|
||||||
|
|
||||||
// Gateway-timeout recovery — same policy as TestModal: the backend detaches the
|
|
||||||
// render from the request, so on a 524/504 we keep polling /jobs/mine?source=admin.
|
|
||||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
|
||||||
let recoverTimer = null
|
|
||||||
let recoverJobId = ''
|
|
||||||
let recoverSubmitTs = 0
|
|
||||||
onUnmounted(() => clearTimeout(recoverTimer))
|
|
||||||
|
|
||||||
function firstDuration(m) {
|
|
||||||
const keys = Object.keys(m?.duration_prices || {})
|
|
||||||
.sort((a, b) => parseFloat(a) - parseFloat(b))
|
|
||||||
return keys[0] || '5s'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
if (!selectedModel.value) { error.value = '请选择模型'; return }
|
|
||||||
if (!prompt.value.trim()) { error.value = '请输入指令'; return }
|
|
||||||
busy.value = true
|
|
||||||
error.value = ''
|
|
||||||
status.value = isVideo.value ? '正在生成视频 (约 1–3 分钟)…' : '正在生成…'
|
|
||||||
resultUrl.value = ''
|
|
||||||
resultKind.value = ''
|
|
||||||
const m = currentModel.value
|
|
||||||
const payload = {
|
|
||||||
model: selectedModel.value,
|
|
||||||
prompt: prompt.value,
|
|
||||||
ratio: (m?.ratios && m.ratios[0]) || (isVideo.value ? '16x9' : '1:1'),
|
|
||||||
resolution: (m?.resolutions && m.resolutions[0]) || '',
|
|
||||||
account_id: props.account.id,
|
|
||||||
}
|
|
||||||
if (isVideo.value) payload.duration = firstDuration(m)
|
|
||||||
recoverSubmitTs = Date.now()
|
|
||||||
const r = await api('/test', jsonBody('POST', payload))
|
|
||||||
if (r.ok && r.data?.url) {
|
|
||||||
busy.value = false
|
|
||||||
resultUrl.value = r.data.url
|
|
||||||
resultKind.value = r.data.kind || (isVideo.value ? 'video' : 'image')
|
|
||||||
status.value = `完成 · ${r.data.provider} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s`
|
|
||||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
|
||||||
status.value = isVideo.value ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
|
||||||
recoverJobId = ''
|
|
||||||
clearTimeout(recoverTimer)
|
|
||||||
recoverTimer = setTimeout(recover, 3000)
|
|
||||||
} else {
|
|
||||||
busy.value = false
|
|
||||||
status.value = ''
|
|
||||||
error.value = r.data?.detail || `失败 (${r.status})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recover() {
|
|
||||||
const r = await api('/jobs/mine?source=admin')
|
|
||||||
if (!r.ok) { recoverTimer = setTimeout(recover, 3000); return }
|
|
||||||
const { pending, latest } = r.data || {}
|
|
||||||
if (pending) {
|
|
||||||
recoverJobId = pending.id
|
|
||||||
recoverTimer = setTimeout(recover, 3000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const mine = latest && (
|
|
||||||
(recoverJobId && latest.id === recoverJobId) ||
|
|
||||||
(!recoverJobId && latest.ts && latest.ts * 1000 >= recoverSubmitTs - 2000)
|
|
||||||
)
|
|
||||||
if (mine && latest.status === 'success' && latest.url) {
|
|
||||||
busy.value = false
|
|
||||||
resultUrl.value = latest.url
|
|
||||||
resultKind.value = latest.kind || (isVideo.value ? 'video' : 'image')
|
|
||||||
status.value = `完成 · ${(latest.elapsed_ms / 1000).toFixed(1)}s`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (mine && latest.status === 'failed') {
|
|
||||||
busy.value = false
|
|
||||||
status.value = ''
|
|
||||||
error.value = latest.error || '生成失败'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recoverTimer = setTimeout(recover, 3000)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
|
||||||
@click.self="emit('close')">
|
|
||||||
<div class="card !shadow-2xl my-12 w-full max-w-lg">
|
|
||||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="text-sm font-semibold">账号生成测试</h2>
|
|
||||||
<div class="text-xs text-white/45 font-mono truncate">{{ account.email || account.id }} · {{ account.pool }}</div>
|
|
||||||
</div>
|
|
||||||
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
|
|
||||||
<Icon name="close" class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-5 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="lbl">模型</label>
|
|
||||||
<div v-if="!models.length" class="text-xs text-amber-300">该账号所属 provider ({{ account.pool }}) 没有可用的模型</div>
|
|
||||||
<SelectMenu v-else v-model="selectedModel" :options="modelOptions" placeholder="选择模型" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="lbl">指令</label>
|
|
||||||
<textarea v-model="prompt" rows="3" class="field resize-none" placeholder="输入测试指令…"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button @click="run" :disabled="busy || !models.length" class="btn-primary w-full">
|
|
||||||
<Icon name="spark" class="w-4 h-4" /> {{ busy ? (isVideo ? '生成中…(请耐心等待)' : '生成中…') : '生成' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
|
|
||||||
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
|
|
||||||
|
|
||||||
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
|
|
||||||
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
|
|
||||||
class="max-w-full max-h-[420px] object-contain" />
|
|
||||||
<img v-else :src="resultUrl" class="max-w-full max-h-[360px] object-contain" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.lbl {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgb(255 255 255 / 0.55);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -13,22 +13,24 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
|
|||||||
<div v-if="announcement.show"
|
<div v-if="announcement.show"
|
||||||
class="fixed inset-0 z-[80] bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4"
|
class="fixed inset-0 z-[80] bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
@click.self="dismissAnnouncement">
|
@click.self="dismissAnnouncement">
|
||||||
<div class="w-full max-w-lg rounded-2xl bg-white text-slate-800 shadow-2xl overflow-hidden flex flex-col max-h-[80vh]">
|
<div class="w-full max-w-lg rounded-2xl bg-[var(--menu-bg)] backdrop-blur-xl text-[color:var(--fg)] border border-[color:var(--glass-border)] shadow-[var(--glass-shadow-lg)] overflow-hidden flex flex-col max-h-[80vh]">
|
||||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between gap-3 shrink-0">
|
<div class="px-6 py-4 border-b border-b-[color:var(--ink-soft)] flex items-center justify-between gap-3 shrink-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-7 h-7 rounded-lg bg-violet-500/15 text-violet-600 grid place-items-center">
|
<span class="w-7 h-7 rounded-lg text-white grid place-items-center"
|
||||||
|
style="background: linear-gradient(135deg, #18181b, #52525b); box-shadow: 0 4px 12px var(--accent-glow)">
|
||||||
<Icon name="spark" class="w-4 h-4" />
|
<Icon name="spark" class="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
<h2 class="text-base font-semibold">公告</h2>
|
<h2 class="text-base font-extrabold">公告</h2>
|
||||||
</div>
|
</div>
|
||||||
<button @click="dismissAnnouncement" class="text-slate-400 hover:text-slate-700 transition-colors">
|
<button @click="dismissAnnouncement" class="text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">
|
||||||
<Icon name="close" class="w-5 h-5" />
|
<Icon name="close" class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ann-body px-6 py-5 overflow-y-auto" v-html="html"></div>
|
<div class="ann-body px-6 py-5 overflow-y-auto" v-html="html"></div>
|
||||||
<div class="px-6 py-4 border-t border-slate-100 flex justify-end shrink-0">
|
<div class="px-6 py-4 border-t border-t-[color:var(--ink-soft)] flex justify-end shrink-0">
|
||||||
<button @click="dismissAnnouncement"
|
<button @click="dismissAnnouncement"
|
||||||
class="rounded-lg bg-slate-900 text-white hover:bg-slate-700 px-5 py-2 text-sm font-medium transition-colors">
|
class="rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
||||||
|
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">
|
||||||
我知道了
|
我知道了
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,21 +44,21 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
|
|||||||
.ann-fade-enter-from, .ann-fade-leave-to { opacity: 0; }
|
.ann-fade-enter-from, .ann-fade-leave-to { opacity: 0; }
|
||||||
|
|
||||||
/* Minimal markdown typography for the announcement body. */
|
/* Minimal markdown typography for the announcement body. */
|
||||||
.ann-body { font-size: 0.9rem; line-height: 1.65; color: rgb(51 65 85); }
|
.ann-body { font-size: 0.9rem; line-height: 1.65; color: var(--fg-2); }
|
||||||
.ann-body :deep(h1) { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.4em; color: rgb(15 23 42); }
|
.ann-body :deep(h1) { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.4em; color: var(--fg); }
|
||||||
.ann-body :deep(h2) { font-size: 1.1rem; font-weight: 700; margin: 0.6em 0 0.4em; color: rgb(15 23 42); }
|
.ann-body :deep(h2) { font-size: 1.1rem; font-weight: 700; margin: 0.6em 0 0.4em; color: var(--fg); }
|
||||||
.ann-body :deep(h3) { font-size: 1rem; font-weight: 600; margin: 0.6em 0 0.3em; color: rgb(15 23 42); }
|
.ann-body :deep(h3) { font-size: 1rem; font-weight: 600; margin: 0.6em 0 0.3em; color: var(--fg); }
|
||||||
.ann-body :deep(p) { margin: 0.5em 0; }
|
.ann-body :deep(p) { margin: 0.5em 0; }
|
||||||
.ann-body :deep(ul), .ann-body :deep(ol) { margin: 0.5em 0; padding-left: 1.4em; }
|
.ann-body :deep(ul), .ann-body :deep(ol) { margin: 0.5em 0; padding-left: 1.4em; }
|
||||||
.ann-body :deep(ul) { list-style: disc; }
|
.ann-body :deep(ul) { list-style: disc; }
|
||||||
.ann-body :deep(ol) { list-style: decimal; }
|
.ann-body :deep(ol) { list-style: decimal; }
|
||||||
.ann-body :deep(li) { margin: 0.2em 0; }
|
.ann-body :deep(li) { margin: 0.2em 0; }
|
||||||
.ann-body :deep(a) { color: rgb(124 58 237); text-decoration: underline; }
|
.ann-body :deep(a) { color: var(--m-orange); text-decoration: underline; }
|
||||||
.ann-body :deep(strong) { font-weight: 700; color: rgb(15 23 42); }
|
.ann-body :deep(strong) { font-weight: 700; color: var(--fg); }
|
||||||
.ann-body :deep(code) { background: rgb(241 245 249); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
|
.ann-body :deep(code) { background: rgb(24 24 27 / 0.12); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
|
||||||
.ann-body :deep(pre) { background: rgb(241 245 249); padding: 0.8em; border-radius: 0.5rem; overflow-x: auto; margin: 0.6em 0; }
|
.ann-body :deep(pre) { background: #111; color: #f5f5f5; padding: 0.8em; border-radius: 0.5rem; overflow-x: auto; margin: 0.6em 0; }
|
||||||
.ann-body :deep(pre code) { background: none; padding: 0; }
|
.ann-body :deep(pre code) { background: none; padding: 0; }
|
||||||
.ann-body :deep(blockquote) { border-left: 3px solid rgb(196 181 253); padding-left: 0.9em; color: rgb(100 116 139); margin: 0.6em 0; }
|
.ann-body :deep(blockquote) { border-left: 3px solid var(--m-orange); padding-left: 0.9em; color: var(--fg-3); margin: 0.6em 0; }
|
||||||
.ann-body :deep(hr) { border: none; border-top: 1px solid rgb(226 232 240); margin: 1em 0; }
|
.ann-body :deep(hr) { border: none; border-top: 1px solid var(--hairline); margin: 1em 0; }
|
||||||
.ann-body :deep(img) { max-width: 100%; border-radius: 0.5rem; margin: 0.5em 0; }
|
.ann-body :deep(img) { max-width: 100%; border-radius: 0.5rem; margin: 0.5em 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { api, jsonBody } from '../api'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
import SelectMenu from './SelectMenu.vue'
|
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'saved'])
|
|
||||||
|
|
||||||
// 17 ratios — the union of what our models actually support; kept in sync with
|
|
||||||
// the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it).
|
|
||||||
const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:1', '8:1', '4:5', '3:4', '2:3', '9:16', '1:3', '1:4', '1:8']
|
|
||||||
const IMG_RES = ['1K', '2K', '4K']
|
|
||||||
const VID_RES = ['720p', '1080p', '2K', '4K']
|
|
||||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
|
||||||
const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s']
|
|
||||||
|
|
||||||
const id = ref('')
|
|
||||||
const alias = ref('')
|
|
||||||
const type = ref('image')
|
|
||||||
const ratios = ref(['1:1', '16:9', '9:16'])
|
|
||||||
const maxRefs = ref(0)
|
|
||||||
const refMode = ref('none')
|
|
||||||
const weight = ref(0)
|
|
||||||
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
|
||||||
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
|
||||||
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }])))
|
|
||||||
// Duration rows shown: the presets above + any custom seconds the admin adds.
|
|
||||||
const durList = ref([...DUR_OPTS])
|
|
||||||
const customDurInput = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
// Add a custom duration (any positive integer seconds, e.g. 12 → "12s").
|
|
||||||
function addCustomDur() {
|
|
||||||
const n = parseInt(customDurInput.value, 10)
|
|
||||||
if (!(n > 0)) return
|
|
||||||
const key = n + 's'
|
|
||||||
if (!durList.value.includes(key)) {
|
|
||||||
if (!dur.value[key]) dur.value[key] = { price: '', agent: '' }
|
|
||||||
durList.value.push(key)
|
|
||||||
}
|
|
||||||
customDurInput.value = ''
|
|
||||||
}
|
|
||||||
// Custom (non-preset) durations can be removed; presets stay.
|
|
||||||
function removeDur(key) {
|
|
||||||
if (DUR_OPTS.includes(key)) return
|
|
||||||
durList.value = durList.value.filter((k) => k !== key)
|
|
||||||
delete dur.value[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVideo = computed(() => type.value === 'video')
|
|
||||||
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
|
||||||
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
|
||||||
|
|
||||||
// 首尾帧(frame) only has first+last slots → cap reference images at 2.
|
|
||||||
const refsCap = computed(() => (refMode.value === 'frame' ? 2 : 99))
|
|
||||||
watch([refMode, maxRefs], () => {
|
|
||||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleRatio(r) {
|
|
||||||
const i = ratios.value.indexOf(r)
|
|
||||||
if (i >= 0) ratios.value.splice(i, 1)
|
|
||||||
else ratios.value.push(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collect checked tiers into { tier: price } and { tier: agentPrice }
|
|
||||||
// blank price = tier not supported (skipped), matching the edit form.
|
|
||||||
function collect(tiers, allowed) {
|
|
||||||
const prices = {}, agent = {}, keys = []
|
|
||||||
for (const [k, v] of Object.entries(tiers)) {
|
|
||||||
if (allowed && !allowed.includes(k)) continue
|
|
||||||
const raw = String(v.price ?? '').trim()
|
|
||||||
if (raw === '') continue
|
|
||||||
const n = Number(raw)
|
|
||||||
if (Number.isNaN(n) || n < 0) continue
|
|
||||||
prices[k] = n; keys.push(k)
|
|
||||||
const ar = String(v.agent ?? '').trim()
|
|
||||||
if (ar !== '') { const a = Number(ar); if (!Number.isNaN(a) && a >= 0) agent[k] = a }
|
|
||||||
}
|
|
||||||
return { prices, agent, keys }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const mid = id.value.trim()
|
|
||||||
if (!mid) { error.value = '请填写模型 id'; return }
|
|
||||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
|
||||||
const r = collect(res.value, resOpts.value)
|
|
||||||
if (!r.keys.length) { error.value = '请至少勾选一个分辨率并填价格'; return }
|
|
||||||
const body = {
|
|
||||||
id: mid,
|
|
||||||
name: mid,
|
|
||||||
alias: alias.value.trim(),
|
|
||||||
type: type.value,
|
|
||||||
provider: 'custom',
|
|
||||||
prices: r.prices,
|
|
||||||
prices_agent: r.agent,
|
|
||||||
ratios: ratios.value.slice(),
|
|
||||||
max_reference_images: Number(maxRefs.value) || 0,
|
|
||||||
reference_mode: refMode.value,
|
|
||||||
weight: Number(weight.value) || 0,
|
|
||||||
image_to_image: (Number(maxRefs.value) || 0) > 0,
|
|
||||||
}
|
|
||||||
if (isVideo.value) {
|
|
||||||
body.resolutions = r.keys
|
|
||||||
const d = collect(dur.value)
|
|
||||||
if (!d.keys.length) { error.value = '视频请至少勾选一个时长并填价格'; return }
|
|
||||||
body.duration_prices = d.prices
|
|
||||||
body.duration_prices_agent = d.agent
|
|
||||||
body.durations = d.keys
|
|
||||||
}
|
|
||||||
saving.value = true; error.value = ''
|
|
||||||
try {
|
|
||||||
const resp = await api('/managed-models', jsonBody('POST', body))
|
|
||||||
if (resp.ok || resp.data?.ok || resp.status === 200) emit('saved')
|
|
||||||
else error.value = resp.data?.detail || '创建失败'
|
|
||||||
} catch (e) { error.value = String(e) }
|
|
||||||
finally { saving.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
|
||||||
@click.self="emit('close')">
|
|
||||||
<div class="card !shadow-xl mt-10 mb-10 w-full max-w-xl">
|
|
||||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
|
||||||
<h2 class="text-sm font-semibold">添加自定义模型(上游 / provider=custom)</h2>
|
|
||||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700"><Icon name="close" class="w-5 h-5" /></button>
|
|
||||||
</div>
|
|
||||||
<div class="p-5 space-y-4">
|
|
||||||
<p class="text-xs text-slate-500 leading-relaxed">
|
|
||||||
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。设了别名后,对外只用别名调用(原 id 调不到),但内部仍按 id 路由到上游,不影响。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<label class="text-xs text-slate-500 block mb-1">模型 id <span class="text-rose-500">*</span></label>
|
|
||||||
<input v-model="id" class="field font-mono text-xs h-10" placeholder="gpt-image-2" />
|
|
||||||
</div>
|
|
||||||
<div class="w-40">
|
|
||||||
<label class="text-xs text-slate-500 block mb-1">别名(选填)</label>
|
|
||||||
<input v-model="alias" class="field font-mono text-xs h-10" placeholder="对外调用名" />
|
|
||||||
</div>
|
|
||||||
<div class="w-28">
|
|
||||||
<label class="text-xs text-slate-500 block mb-1">类型</label>
|
|
||||||
<SelectMenu v-model="type" :options="[{value:'image',label:'图像'},{value:'video',label:'视频'}]" />
|
|
||||||
</div>
|
|
||||||
<div class="w-24">
|
|
||||||
<label class="text-xs text-slate-500 block mb-1">权重</label>
|
|
||||||
<input v-model.number="weight" type="number" class="field h-10" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-slate-500 block mb-1.5">比例(多选)</label>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<button v-for="r in RATIO_OPTS" :key="r" type="button" @click="toggleRatio(r)"
|
|
||||||
class="px-2 py-1 rounded text-xs ring-1 transition-colors"
|
|
||||||
:class="ratios.includes(r) ? 'bg-indigo-500/15 text-indigo-600 ring-indigo-300' : 'bg-slate-50 text-slate-500 ring-slate-200 hover:ring-slate-300'">{{ r }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-slate-500 block mb-1.5">分辨率 · 价格(填普通价 = 支持该档,<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<div v-for="r in resOpts" :key="r" class="flex items-center gap-2">
|
|
||||||
<span class="w-16 text-xs font-mono text-slate-500">{{ r }}</span>
|
|
||||||
<input v-model="res[r].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
|
||||||
<input v-model="res[r].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isVideo">
|
|
||||||
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<div v-for="d in durList" :key="d" class="flex items-center gap-2">
|
|
||||||
<span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span>
|
|
||||||
<input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
|
||||||
<input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
|
||||||
<button v-if="!DUR_OPTS.includes(d)" type="button" @click="removeDur(d)"
|
|
||||||
class="shrink-0 w-7 h-7 grid place-items-center rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50" title="删除该时长">
|
|
||||||
<Icon name="close" class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 自定义时长:输入任意秒数,Sora 类上游支持的任意时长都能加 -->
|
|
||||||
<div class="flex items-center gap-2 mt-2">
|
|
||||||
<input v-model="customDurInput" type="number" min="1" @keydown.enter.prevent="addCustomDur"
|
|
||||||
class="field !py-1 w-32" placeholder="自定义秒数" />
|
|
||||||
<button type="button" @click="addCustomDur" class="btn-soft text-xs whitespace-nowrap">+ 添加时长</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<label class="text-xs text-slate-500 block mb-1">参考图张数<span v-if="refMode==='frame'" class="text-white/40">(首尾帧最多 2)</span></label>
|
|
||||||
<input v-model.number="maxRefs" type="number" min="0" :max="refsCap" class="field h-10" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<label class="text-xs text-slate-500 block mb-1">参考模式</label>
|
|
||||||
<SelectMenu v-model="refMode" :options="[{value:'none',label:'无'},{value:'asset',label:'参考图'},{value:'frame',label:'首尾帧(视频)'}]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button @click="save" :disabled="saving" class="btn-primary w-full">{{ saving ? '创建中…' : '创建模型' }}</button>
|
|
||||||
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -28,12 +28,24 @@ const PATHS = {
|
|||||||
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
||||||
play: '<path d="m6 3 14 9-14 9V3Z"/>',
|
play: '<path d="m6 3 14 9-14 9V3Z"/>',
|
||||||
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
|
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
|
||||||
|
arrow: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
|
||||||
|
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
|
||||||
|
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
||||||
|
layers: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
|
||||||
|
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l3-3a1 1 0 0 0 0-1.4l-2.3-2.3a1 1 0 0 0-1.4 0l-3 3a1 1 0 0 0 0 1.4z"/><path d="m15.5 7.5-8 8"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||||
|
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/>',
|
||||||
|
team: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||||
|
wallet: '<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/>',
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps({ name: { type: String, required: true } })
|
defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
size: { type: [Number, String], default: null },
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
:width="size || undefined" :height="size || undefined"
|
||||||
stroke-linecap="round" stroke-linejoin="round" v-html="PATHS[name] || ''" />
|
stroke-linecap="round" stroke-linejoin="round" v-html="PATHS[name] || ''" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { api, jsonBody } from '../api'
|
|
||||||
import { parseImportInput } from '../utils/import'
|
|
||||||
import Icon from './Icon.vue'
|
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'imported'])
|
|
||||||
|
|
||||||
const input = ref('')
|
|
||||||
const weight = ref(0)
|
|
||||||
const status = ref('')
|
|
||||||
const isError = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
|
|
||||||
// type → token pool (for the post-import weight PATCH).
|
|
||||||
const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok', creativefabrica: 'creativefabrica' }
|
|
||||||
|
|
||||||
// Live preview of what the parser would extract — updates as the user types
|
|
||||||
// so they can see whether their paste was understood before clicking import.
|
|
||||||
const detected = computed(() => {
|
|
||||||
const items = parseImportInput(input.value)
|
|
||||||
const openai = items.filter((x) => x.type === 'openai').length
|
|
||||||
const adobe = items.filter((x) => x.type === 'adobe').length
|
|
||||||
const runway = items.filter((x) => x.type === 'runway').length
|
|
||||||
const leonardo = items.filter((x) => x.type === 'leonardo').length
|
|
||||||
const krea = items.filter((x) => x.type === 'krea').length
|
|
||||||
const imagine = items.filter((x) => x.type === 'imagine').length
|
|
||||||
const grok = items.filter((x) => x.type === 'grok').length
|
|
||||||
const cf = items.filter((x) => x.type === 'creativefabrica').length
|
|
||||||
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok, cf }
|
|
||||||
})
|
|
||||||
|
|
||||||
function setStatus(text, err = false) {
|
|
||||||
status.value = text || ''
|
|
||||||
isError.value = err
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doSmartImport() {
|
|
||||||
const items = parseImportInput(input.value)
|
|
||||||
if (!items.length) {
|
|
||||||
setStatus('未识别到任何 Cookie 或 JWT', true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
|
||||||
let ok = 0, fail = 0
|
|
||||||
const errs = []
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const it = items[i]
|
|
||||||
try {
|
|
||||||
const r = it.type === 'openai'
|
|
||||||
? await api('/tokens/import-chatgpt-token', jsonBody('POST', { access_token: it.value }))
|
|
||||||
: it.type === 'grok'
|
|
||||||
? await api('/tokens/import-grok-token', jsonBody('POST', { access_token: it.value }))
|
|
||||||
: it.type === 'runway'
|
|
||||||
? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value }))
|
|
||||||
: it.type === 'leonardo'
|
|
||||||
? await api('/tokens/import-leonardo-cookie', jsonBody('POST', { cookie: it.value }))
|
|
||||||
: it.type === 'krea'
|
|
||||||
? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value }))
|
|
||||||
: it.type === 'imagine'
|
|
||||||
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
|
|
||||||
: it.type === 'creativefabrica'
|
|
||||||
? await api('/tokens/import-creativefabrica-cookie', jsonBody('POST', { cookie: it.value }))
|
|
||||||
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
|
|
||||||
if (r.ok) {
|
|
||||||
ok++
|
|
||||||
// Apply the chosen weight to the freshly-imported account (best-effort).
|
|
||||||
const w = Number(weight.value) || 0
|
|
||||||
const pool = TYPE_POOL[it.type]
|
|
||||||
if (w !== 0 && pool && r.data?.id) {
|
|
||||||
try { await api(`/tokens/${pool}/${r.data.id}`, jsonBody('PATCH', { weight: w })) } catch (_) {}
|
|
||||||
}
|
|
||||||
} else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
|
|
||||||
} catch (e) {
|
|
||||||
fail++; errs.push(`${it.type}: ${e}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
submitting.value = false
|
|
||||||
// Quota isn't checked here — the server probes each token off-thread and the
|
|
||||||
// account list flips pending → active/dead on its own.
|
|
||||||
if (fail === 0) {
|
|
||||||
// Close immediately on success — the account lands in the table right away
|
|
||||||
// and the server backfills quota/email off-thread (pending → active/dead).
|
|
||||||
emit('imported')
|
|
||||||
emit('close')
|
|
||||||
} else {
|
|
||||||
setStatus(`成功 ${ok} · 失败 ${fail} · ${errs.slice(0, 3).join(' | ')}`, true)
|
|
||||||
emit('imported')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
|
||||||
@click.self="emit('close')">
|
|
||||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-2xl">
|
|
||||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
|
||||||
<h2 class="text-sm font-semibold">导入账号</h2>
|
|
||||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
|
||||||
<Icon name="close" class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-5">
|
|
||||||
<p class="text-xs text-slate-500 mb-3 leading-relaxed">自动识别:
|
|
||||||
<strong class="text-slate-700">Adobe Cookie 字符串</strong>(<code class="px-1 bg-slate-100 rounded">k=v; k=v; ...</code>)、
|
|
||||||
<strong class="text-slate-700">Cookie JSON 对象</strong>、
|
|
||||||
<strong class="text-slate-700">Cookie 数组</strong>(多 Adobe 批量)、
|
|
||||||
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)、
|
|
||||||
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
|
|
||||||
<strong class="text-slate-700">Leonardo Cookie</strong>(须含 better-auth.session_data)、
|
|
||||||
<strong class="text-slate-700">Krea Cookie</strong>(含 sb-superb-auth)、
|
|
||||||
<strong class="text-slate-700">Creative Fabrica Cookie</strong>(含 cfauth_* 或 wordpress_logged_in_)、
|
|
||||||
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)、
|
|
||||||
<strong class="text-slate-700">Grok SSO</strong>(grok.com 的 <code class="px-1 bg-slate-100 rounded">sso</code> 值,仅含 session_id,自动与 ChatGPT/Runway 区分)、
|
|
||||||
<strong class="text-slate-700">多个 JWT</strong>(换行分隔)。
|
|
||||||
全粘进来即可,无需任何前缀。
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-rose-500 mb-3 leading-relaxed bg-rose-50 rounded px-3 py-2 border border-rose-200">
|
|
||||||
📢 需要购买 Adobe 号请联系 QQ:28998306
|
|
||||||
</p>
|
|
||||||
<textarea v-model="input" rows="10"
|
|
||||||
class="field font-mono text-xs resize-none"
|
|
||||||
placeholder="直接粘 Cookie 字符串 / JWT / JSON,自动识别"></textarea>
|
|
||||||
<div v-if="input.trim()" class="mt-2 flex items-center gap-2 text-xs">
|
|
||||||
<template v-if="detected.total">
|
|
||||||
<span class="text-emerald-600">✓ 识别到 <strong class="tabular-nums">{{ detected.total }}</strong> 个账号</span>
|
|
||||||
<span v-if="detected.openai" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200">
|
|
||||||
OpenAI · <span class="tabular-nums">{{ detected.openai }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="detected.adobe" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
|
|
||||||
Adobe · <span class="tabular-nums">{{ detected.adobe }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="detected.runway" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-violet-700 bg-violet-50 ring-1 ring-violet-200">
|
|
||||||
Runway · <span class="tabular-nums">{{ detected.runway }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="detected.leonardo" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-amber-700 bg-amber-50 ring-1 ring-amber-200">
|
|
||||||
Leonardo · <span class="tabular-nums">{{ detected.leonardo }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="detected.krea" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-sky-700 bg-sky-50 ring-1 ring-sky-200">
|
|
||||||
Krea · <span class="tabular-nums">{{ detected.krea }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="detected.imagine" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-teal-700 bg-teal-50 ring-1 ring-teal-200">
|
|
||||||
Imagine · <span class="tabular-nums">{{ detected.imagine }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="detected.grok" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-slate-700 bg-slate-100 ring-1 ring-slate-300">
|
|
||||||
Grok · <span class="tabular-nums">{{ detected.grok }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="detected.cf" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-fuchsia-700 bg-fuchsia-50 ring-1 ring-fuchsia-200">
|
|
||||||
Creative Fabrica · <span class="tabular-nums">{{ detected.cf }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<label class="text-xs text-slate-500 whitespace-nowrap">权重(本批账号,高的优先)</label>
|
|
||||||
<input v-model.number="weight" type="number" class="field !w-24" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
|
|
||||||
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
|
|
||||||
</button>
|
|
||||||
<p v-if="status" class="text-xs mt-2" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||