Compare commits

...
61 Commits
Author SHA1 Message Date
chiyiandDevin f3b9e570c2 feat(models): 对齐 vivid 新改动——模型删除改软删,重导不丢计数/定价
- model_configs 加 deleted_at(gorm 软删),List/Get 自动过滤,
  GetAny/Unscoped 用于复活与孤儿清理路径
- 渠道同步/删渠道/管理端删除模型的孤儿清理全部走软删
- 导入时若命中软删行:UpdateAny + deleted_at=NULL 复活,
  generation_count 与原定价保留(vivid a42f32c 同语义)
- provider 时代残留仍物理删除(DeleteHard),不留软删垃圾
- OrphanModelIDs 跳过已软删行,避免重复清扫
- btn-primary 禁用态改浅灰填充(vivid d636105)

核对过已等价的:vivid 僵尸任务兜底(我们的 ResumeStaleJob+
PurgeStale+MarkRefunded 同语义)、产物 MIME 魔数嗅探、
retention 按创建时间、渠道同步弹窗(开关+别名)均已覆盖。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 21:48:45 +08:00
chiyiandDevin 76510d34c9 feat(protocol): /v1 协议去除 fixed 时长模式,统一 options/range
- /v1/models:单值时长也输出 {"mode":"options","options":[N]},不再发 fixed
- 导入/同步校验只接受 options / range,fixed 分支删除,报错「时长模式仅支持 options / range」
- 渠道模型预览的 fixed 展示分支去掉
- 内部存储不变(durations 数组),历史模型与下单逻辑不受影响

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 21:16:22 +08:00
chiyiandDevin 1753e03898 docs: 重写 README 界面预览,换新 UI 的 1920×1080 截图
- hero 图换成新首页;预览网格改为画图台/概览/模型/渠道/用户/日志六张
- 删除账号池时代的 accounts.png,新增 channels.png、users.png
- 去掉"深/浅双主题"描述(现为固定浅色),用户端补"无限画布"
- README.en.md 补上此前缺失的截图区,与中文版对齐

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:32:31 +08:00
chiyiandDevin d37ca69f77 fix(ui): 全局 rescue 覆盖导致操作按钮一直是灰底
.theme-text .act 全局规则强制 surface-2 灰底,优先级高于
组件 scoped 样式。摘出 .act 单独给白底灰边,删除按钮白底粉边。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:25:02 +08:00
chiyiandDevin 0373aa2d03 fix(ui): 后台卡片统一纯白——半透明底叠米色页底发灰
.card 原用 90% 透明度的 --surface,叠在暖色页底上让
统计卡、工具栏、表格看起来都是灰的。改 --surface-solid 纯白。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:15:37 +08:00
chiyiandDevin e8e5527e63 style(ui): 操作按钮白底灰边,危险按钮悬停淡红
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:12:31 +08:00
chiyiandDevin 58ee41a4f8 fix(users): 操作列加宽防挤压 + 吸底格补行分隔线
- 操作列 w-24→w-36,三个按钮不再贴边被切
- 吸底单元格补 inset 底边框,行分隔线延伸到最右缘
- 吸底表头补同款底边框

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:10:44 +08:00
chiyiandDevin c60ba294e1 style(ui): 表头灰带去除,表格统一白底
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:07:00 +08:00
chiyiandDevin edd5253529 style(ui): 操作按钮改幽灵样式——平时仅图标,悬停出底
- .act/.op 去底色边框,悬停浅灰底,危险按钮悬停淡红底+红字
- 用户/模型/兑换码/违禁词/并发分组/渠道六页统一

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:06:06 +08:00
chiyiandDevin a9640ff059 feat(users): 彩色头像 + 操作列吸底 + 滚轮横滚
- 头像按用户名哈希出淡彩底色(9 色轮换),不再全灰
- 操作列 position:sticky 吸在右缘,横向滚动时按钮始终可见
- 鼠标纵向滚轮映射为表格横向滚动(滚到尽头放行给页面)
- 最小宽收窄 1280→1150,1440 视口基本放下

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:59:37 +08:00
chiyiandDevin e509584a77 fix(users): 表格列宽放宽——内容不截断,超出横向滚动
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:54:05 +08:00
chiyiandDevin ab9b63f5cb feat(ui): 用户表重构 + 全站表头加高加大
- 用户列:头像块 + 用户名/邮箱/备注三行合并(vivid 同款)
- 最近登录与登录 IP 合并为两行单元格,注册时间单行日期
- 15 列平铺收敛为 12 列,最小宽 1500→1060,1440 视口免横滚
- 全站表头 11px→13px、上下留白 16px,行更高更舒展
- 表体基线字号统一 13px
- 账户信息卡片标签与值间距加大

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:52:30 +08:00
chiyiandDevin aeb94dd11c fix(settings): 账户信息标签与值间距 4px → 9px,加大值字号与行高
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:46:43 +08:00
chiyiandDevin b700294ad3 style(ui): 表格配色收敛到全局墨色基调
- 统计卡:大数字统一墨色、标签统一次要灰,去掉绿/红/橙彩虹数字
- 所有语义色文字 300/200 浅色加深为 600/500(淡底深字,适配亮色主题)
- 徽章/胶囊 ring 与 border 统一为 500/25 细描边
- 滑动开关开态由绿改墨黑(对齐 vivid switch.on)
- .fp-* 徽章改为 vivid 同款淡底深字
- 跳过媒体覆盖层胶囊、暗色代码块等本就属于暗底的元素

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:44:57 +08:00
chiyiandDevin df6ef5ad22 style(ui): 全站空占位统一为图标块 + 文案
- 新增全局 .empty/.empty-ico/.empty-text/.empty-sub
- 渠道、并发分组、违禁词、触发记录、兑换码、订单、邀请日志、
  文档模型表、作品管理的空态全部改为图标块样式
- 渠道表单状态已另改滑动开关

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:37:27 +08:00
chiyiandDevin 682960de26 fix(channel): 渠道表单状态改为滑动开关,一眼可辨可切换
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:33:09 +08:00
chiyiandDevin 71c0371c31 style(ui): 数据表格统一细节——表头规格、选中行高亮
- 全站表格表头统一为 11px/700/0.07em 次要色,上下 11px
- 多选表格(用户/违禁词/兑换码)选中行加灰底 + 左缘黑条高亮
- 未显式设色的 td 默认次要文本色

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:32:22 +08:00
chiyiandDevin c78b01d988 feat(ui): 设置页重设计 + 全站表格配色统一为亮色变量
- 设置页:移除暗色残留,统一页头/卡片/图标块/按钮样式
- 模型管理:照 vivid 重排——模型名等宽显示,子行展示渠道名+权重,
  定价胶囊「档位+普通价+代理价」,配置列统一灰色等宽标签,
  操作列加宽修复删除按钮被挤
- 后端 ModelsView 返回每个模型绑定的渠道名列表(channel_models JOIN)
- 全站 scoped 样式里的硬编码白值(rgb(255 255 255/white)批量替换为
  主题变量,修复亮色模式下表头/开关/操作按钮不可见(如渠道表头)
- 关于页:联系卡片统一等高布局,链接/非链接卡片对齐一致

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:28:47 +08:00
chiyiandDevin 6c6e5add03 feat(invite): 邀请好友页重设计
页头与站点统一;深色邀请卡(链接+复制按钮、虚线邀请码、大号统计);
三步流程说明;邀请记录表保持不变。修复标题发灰、复制按钮文字不可见。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:04:21 +08:00
chiyiandDevin 971d47c661 fix(playground): 画图页选项选中态不可见 — 补 chip-on 样式,加深模式切换容器
chip-on 一直没有样式,导致比例/画质/时长/张数选中项反而是白底;
补墨黑底白字选中态。生图/生视频切换器容器加深,选中项加描边与阴影。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:03:02 +08:00
chiyiandDevin c49125b38f feat(user): 创作记录/生成日志/订单页统一页头与工具栏
页头改为标题+说明+圆形刷新按钮;类型/状态筛选改下拉;
全选/下载/删除/去画图等操作收进工具栏右侧,样式与后台一致。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:01:34 +08:00
chiyiandDevin af7e09cf21 feat(admin): 刷新改为独立的圆形图标按钮(42px),主操作按钮单独一行
- 12 个后台页的刷新统一为 .refresh-btn,固定在页头最右,悬停旋转
- 渠道管理「添加渠道」、并发分组「新增分组」移到页头下方单独一行
- 渠道空状态文案同步

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:59:57 +08:00
chiyiandDevin 0fe1706ef9 fix(admin): 违禁词页添加/批量导入按钮靠右排列
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:56:36 +08:00
chiyiandDevin 3d5be149e3 fix(admin): 工具栏按钮尺寸统一(同宽同高同字重),违禁词页添加按钮贴近输入框
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:55:13 +08:00
chiyiandDevin 98d1124f66 feat(admin): 所有后台页统一页头(标题+说明+刷新+主操作),新默认 logo 与站名字体
- 13 个后台页统一 page-head:左标题与一行说明,右侧刷新与主操作,刷新位置一致
- 渠道/并发/违禁词/订单/邀请日志/作品/首页内容/系统配置补齐或改写标题
- 违禁词与订单页改用统一 toolbar,订单筛选胶囊改下拉
- 站名字体改站酷快乐体(英文数字 5KB 子集 + 汉字按需加载)并加粗描边
- 默认 logo 重画:墨黑圆角方块 + 白色星芒 + 暖橙点,favicon 同步

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:53:26 +08:00
chiyiandDevin d49207b8f2 feat(brand): 站名改用圆润可爱字体(Fredoka + 站酷快乐体)
自托管字体子集:Fredoka(ASCII, 45KB) 与站酷快乐体
(GB2312 一级 3755 字, 451KB, unicode-range 限定,仅站名含汉字时下载)。
导航、首页页脚、后台侧边栏的站名统一使用。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:46:47 +08:00
chiyiandDevin 539c251f9f feat(logs): 日志页搜索合并为单个搜索框
后端新增 /logs?kw=:命中提示词/模型/错误,或命中用户
(名称/邮箱/ID) 的记录均保留;前端两个搜索框合并为一个。
首页导航恢复 ✦ 品牌小星标,与其他页一致。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:43:34 +08:00
chiyiandDevin cce126cd52 feat(admin): 表格页整体美化 — 筛选改下拉、统一工具栏、页头放刷新
- 全站卡片/表格/按钮/输入框/徽标共用样式换成墨黑中性风
- 用户/日志/模型/兑换码/用户日志的筛选胶囊改为下拉(SelectMenu 面板改亮色)
- 统一 toolbar:控件同高 36px,搜索框带图标且限宽
- 刷新按钮移到页头独立一行(统计卡上方),新增页面标题
- 修复浅色模式下 bg-white/ 规则误伤 hover:bg-white/ 导致行底色偏灰
- 用户表加最小宽度,修复邮箱列被挤成 0 宽

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:40:37 +08:00
chiyiandDevin 6372b7cb8b fix(ui): 切换页面时整体抖动 — 固定滚动条槽位,去掉切页位移动画
页面有/无滚动条时视口宽度变化会让居中内容和固定导航横向跳动;
html 与后台 main 加 scrollbar-gutter: stable。
同时去掉切页淡入里的 translateY,只保留透明度过渡。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:32:59 +08:00
chiyiandDevin 3a18406b23 feat(admin): 后台概览与外壳整体美化
- 概览页重写:合并成一条 KPI 带 + 资源条,趋势图放大,
  图表改墨黑单色,运营/分析卡片统一白底圆角
- 后台外壳:去掉重复的 ADMIN 顶栏,侧边栏选中态改轻量灰底+细指示条
- 数据逻辑与接口调用保持不变

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:31:29 +08:00
chiyiandDevin f65ce5c765 feat(layout): 去掉各页顶部布局级路由标题(图片/生图/文档/关于等)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:28:29 +08:00
chiyiandDevin 5e42db52b9 feat(nav): 余额与后台入口并入用户下拉,下拉重新设计
顶栏右侧只留头像+用户名胶囊;下拉含用户信息头、
余额卡(点击去充值)、带图标的菜单项、管理后台、退出登录。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:26:18 +08:00
chiyiandDevin f6e916c985 feat(canvas): 顶栏改悬浮岛 + 左侧工具坞,舞台铺满整屏
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:21:34 +08:00
chiyiandDevin 509b0d41c6 feat(canvas): 画布视觉精修
笔记改白色卡片,连接点仅悬停/选中显示,连线改灰色虚线,
节点边框与阴影更轻,输入框去灰渐变与光晕,顶栏按钮改描边款。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:20:22 +08:00
chiyiandDevin 4d31219368 feat(ui): 全站统一首页墨黑中性风格,固定亮色并移除光暗切换
- 全站紫/青/粉写死色值与 CSS 变量改为墨黑中性色板
- 移除 aurora 光斑/星点装饰,登录弹窗改纯白卡片
- theme.js 固定亮色并清除历史 dark 选择,删除前台/后台/画布切换按钮
- 模型页空状态文案对齐「保存渠道即自动同步」

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:17:55 +08:00
chiyiandDevin 8b85d5623a feat(showcase): 去掉 Bento 灵感类型,只保留 hero / work
后端校验、分组、后台列表,以及后台页面与首页取图都只认
hero 和 work;启动时清掉遗留的 bento 记录。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:57:12 +08:00
chiyiandDevin 2fa0324a66 feat(home): 去紫色,改中性暖白底 + 墨黑主色
整页底色 #fafaf8,按钮/CTA/导航激活态改墨黑,
标题渐变改黑灰,占位图换低饱和纸感色板,
去掉彩色光晕与漂移光斑。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:50:23 +08:00
chiyiandDevin 5ddce37ee6 feat(home): 整体美化 — 全页统一淡紫底、hero 柔光浮动、修能力矩阵留白
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:44:00 +08:00
chiyiandDevin c5cc2e9086 feat(home): 底色紫调再调淡一档
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:35:06 +08:00
chiyiandDevin a1f61d548d feat(home): 页面底色改紫色渐变铺满
整页背景由近白 #fafafb 换为薰衣草紫渐变
(#ede7fd → #d8defc),与卡片和渐变描边更统一。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:33:57 +08:00
chiyiandDevin 89a9d76b3e feat(home): 内容版心 1200 → 1400 铺满感
hero/能力/作品墙/CTA/页脚统一放宽到 1400px:
1440 视宽下内容贴边更饱满,1920 下仍居中留白均衡。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:31:49 +08:00
chiyiandDevin 8fd49db8c2 feat(home): 首页展示位空数据时渲染内置渐变图兜底
/showcase 返回空时 hero 拼贴与作品墙不再留白——
用 public/showcase 下 11 张内置 SVG 渐变图填充;
接口有数据时仍优先使用后台配置的展示图。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:08:07 +08:00
chiyiandDevin 466507b388 feat(home): 逐像素移植 vivid 旧版首页 + 悬浮胶囊导航
整页按 vivid commit 8b16666 的 HomeView 复刻:
- Hero 左文右图:模型在线徽章、「让想象/即刻成画」渐变标题、
  渐变描边魔法输入框、提示词胶囊、三项统计
- 右侧四格作品拼贴(带错位旋转/悬停浮层/shimmer 占位)
- 01·能力矩阵(bento 网格)、02·灵感作品墙(编号卡+引导卡)
- 紫色 CTA 横幅 + 分栏页脚(社交图标接 site.contact)
- 顶栏改 vivid 胶囊:999px 毛玻璃、滚动展开通栏、
  游客态单个渐变「登录/注册」按钮
- 页面局部覆盖 vivid 原版色板(--bg #fafafb、accent #6d4df2),
  隐藏 aurora 背景
- Icon 组件补 size prop 与缺失图标,修复 SVG 无尺寸撑爆布局

数据接本站 /managed-models(sellable) 与 /showcase(hero→拼贴,work→作品墙)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:58:17 +08:00
chiyiandDevin 4632b1ab04 feat(channel): 对齐 vivid 最新渠道/模型管理设计
渠道管理(对齐 9db9971/9f6eeca/b607a8d/268a8f7/7f8a87e):
- 列表新增「运行中」(Redis 并发槽位实时计数,与调度同源)和「失败数」
  (event_logs 按渠道聚合)两列
- 行内「同步上游模型」恢复弹窗:默认全开、右侧开关、彩点+单行别名,
  底部「同步模型」批量导入;编辑表单去掉获取模型列表按钮(保存已自动
  全量同步);去掉勾选即解绑语义与 unbind-model 端点
- 导入加固:别名与上游同名报错、同批别名复用拒绝、本地类型冲突拒绝、
  单次上限 50

模型管理(对齐 5545195):
- 模型表单/CreateModel 补齐 max_reference_videos/audios 与
  max_ref_video/audio_seconds 透传

产物(对齐 2253e58):
- 下载结果仅信任 image/*/video/* 响应头,text/plain 等乱报一律按魔数
  嗅探定 MIME

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 19:14:25 +08:00
chiyiandDevin 72d4eec203 feat(channel): 同步 vivid 最新渠道设计 — 手动全量同步端点/并发0=不限/同名别名报错
对齐 vivid 6039423/5802749/a1f808f/cfb6d49/aba8474/b01636f:
- 新增 POST /channels/sync-models:已保存渠道手动全量同步,上游不可达/
  空清单直接报错(保存路径仍降级为告警);渠道行按钮不再弹勾选框,
  表单内对已保存渠道同样直同步,新渠道保持预览+勾选即绑定
- 渠道并发 0 = 不限制(原 0=默认5),上限放开到 int32,槽位 Lua 本就
  max=0 放行
- 别名与上游模型同名提交即报错「留空即同名」,前后端双校验
- 渠道列表去掉上游地址与模型两列(同 vivid),状态徽章悬浮显示失败原因

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:43:30 +08:00
chiyiandDevin 5226e11cfc docs: 清理 OpenAI 时代残留,文档改述 vivid 协议 + 渠道架构
- README/README.en 重写:同构网关 + vivid 四端点 + 渠道同步/调度,
  明确不支持 OpenAI 协议上游;移除账号池/七大平台/OpenAI 兼容描述
- backend/frontend README、.env.example 更新(补 RUSTFS/COOKIE_SECURE/
  CHANNEL_SECRET)
- docker-compose 增加 CHANNEL_SECRET(渠道 key AES-GCM 密钥)
- 清掉代码注释/文档示例里的 OpenAI/gpt-image-2 残留

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:43:24 +08:00
chiyiandDevin b76e6ab717 feat(model): 同步 vivid 最新变更 — 去模型开关,配价即上架
对齐 vivid a338edb / 7fb5c57 / 4f454b6:
- model_configs.enabled 列删除:上架唯一门槛 = 绑定了启用渠道 + 全部档位
  有价(视频另需时长有价)。/v1/models、/api/models、video-presets、
  managed-models 全部改按 sellable 透出
- 保存渠道即全量同步上游模型(消失解绑/孤儿删除/失败告警不删库)
- v1 job 持久化 upstream jobId,重启后维护循环按 jobId 续等不丢单

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 22:01:00 +08:00
chiyiandDevin abd7b5c23b feat(channels): 拉模型改为勾选即绑定,启动清扫遗留模型
- fetch-models 返回 bound 标记,渠道页勾选=立即导入绑定,
  取消=解绑(无其他渠道接入时本地模型行一并删除)
- 新增 POST /channels/unbind-model
- 启动时 PurgeOrphanModels 清掉 provider 时代的无绑定模型行
- 模型管理页移除「渠道导入模型」按钮

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 21:35:30 +08:00
chiyiandDevin b4efe6b192 feat!: 账号池改渠道网关,对外协议换成 vivid /v1
- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
  并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
  uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
  站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
  POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
  错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
  概览/文档/测试弹窗适配

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 21:14:18 +08:00
chiyiandDevin ecdbc0b281 feat(models): 视频分辨率档位增加 768p,编辑弹窗支持增删档位
- CustomModelModal:VID_RES/ALL_RES 加入 768p
- ModelFormModal:provider=custom 的模型编辑时渲染 候选档位 ∪ 已存档位
  完整价格网格,填价即开通、留空即下线;PATCH 的 resolutions/durations
  改发"有价键集合"(与新建语义一致),非 custom 模型行为不变
- 摘要面板仍只显示已开通档位,提示文案区分 catalog / custom

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 18:24:32 +08:00
chiyiandDevin b17cc3e9c8 fix(upstream): vivid 上传会话宽容解析 + 错误回显原始响应
vivid 协议各实现的 /v1/uploads 响应形状不一(顶层 / data 包裹、
url|upload_url|presigned_url、key|file_key|object_key)。之前只认顶层
url+key,遇到包装或别名就报 "upload session missing url/key" 且看不
到上游实际返回。现在逐层(含嵌套 data/result/upload/session)搜寻两
个字段,仍缺失时把原始 JSON 截断进错误信息便于定位。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 18:15:47 +08:00
chiyiandDevin effe76d83f style(admin): 管理台全局样式升级——卡片高光、表格统一润色
- .card 圆角 xl→2xl,顶部加内高光(亮色 sheen/暗色 rim-light)
- 卡片内表格:thead 统一紫罗兰渐变底色,tbody 行 hover 统一紫光
  洗色 + 首列内描边,不再依赖各页散落的 hover:bg-white 工具类
- AdminLayout:顶栏 Admin 标签改紫→青渐变胶囊,侧栏 logo 副标
  改渐变文字

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 17:24:47 +08:00
chiyiandDevin e7a4613bac fix(upstream): vivid 协议的 quality 原样透传分辨率档位
vivid 的 quality 就是模型自己的档位字符串——图像 1K/2K/4K、视频
720p/1080p。之前映射成 OpenAI 的 low/medium/high 导致视频上游收到
"1K" 报 4001 "该模型支持 720p"。现在原样透传,空值才默认 1K。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 16:59:13 +08:00
chiyiandDevin 1fee3e5461 feat(canvas): 生成输入框支持视频/音频参考文件
- 参考上限改为模型三类字段合计(图+视+音),不再被 min(8)
  截断;custom 模型的 9图+3视频+0音频 显示为 0/12
- 文件选择器按模型能力放开 accept;按类分别计数和限数
  (超图片/视频/音频各自上限时给出对应提示)
- 非图片参考在缩略位显示 🎬/🔊;单文件上限放宽到 20MB
  与后端一致

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:22:42 +08:00
chiyiandDevin 0732dfddef fix(playground): 图片参考上限回落用模型字段而非总数
maxImages 在没有 preset 时回落到 maxRefs(图+视+音合计),custom
模型显示成了总数。改成 preset → model.max_reference_images → maxRefs。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:16:43 +08:00
chiyiandDevin ab8ddc8354 fix(playground): custom 上游的 seedance* 模型不再吃 seedance 默认上限
isSeedanceModel 的正则把 custom provider 的自定义命名也算成了 leonardo
seedance,导致音频上限兜底成 3、总张数被重复相加。provider=custom
也排除出 seedance 判定(videoCaps 同步)。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:14:58 +08:00
chiyiandDevin e69455fbfc fix(playground): 参考文件徽标读取 custom 模型的分类上限
- max_reference_videos/audios 回落链改成 preset → 模型字段 → null;
  null 兜底只对 seedance 系默认 3/3,custom 模型未声明=0
- custom 模型的总参考上限 = 图+视+音相加(它的
  max_reference_images 只是图片类),不再错算成单类
- 徽标只显示上限 >0 的类别

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:12:10 +08:00
chiyiandDevin 1be1b00511 feat(upstream): custom 模型按媒体分类配置参考文件上限
- ModelConfig 新增 max_reference_videos / max_reference_audios
  (配合既有 max_reference_images);admin 读写接口透传
- custom 模型表单去掉「参考模式」下拉,改为参考图/参考视频/
  参考音频三个数量输入;合计张数走 totalRefLimit 校验
- vivid 上游 /v1/uploads 按文件魔数自动选 kind(image/video/
  audio)与 mime;openai 协议上游遇视频/音频参考自动跳过换号
- videoCaps 让 custom 模型的分类上限在画图台/文档徽标生效
- reference_mode 请求级 override 放行 "media"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 14:58:34 +08:00
chiyiandDevin babfc71f13 feat(upstream): 自定义上游支持 Vivid 协议(/v1/generate NDJSON)
- custom 上游新增 protocol 选项(openai|vivid);vivid 走
  POST /v1/generate NDJSON 流:跳过心跳空行,取末行终态,
  断流携 jobId 重连不重复扣费;参考图先 POST /v1/uploads
  拿预签名再 PUT 直传;错误码 4011/4002/4293/5001/4001 分类
- 添加上游弹窗加协议二选一;账号 meta 存 protocol 并可回显
- 附 vivid 链路单测(成功流/URL 透传/失败帧/错误码/两步上传)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 12:18:26 +08:00
chiyiandDevin ae49b95e0f fix(adobe): 优先使用 cookie 里的 sherlockToken 作 arp,补齐会话一致性
- ExchangeCookie 提取 cookie 中 sherlockToken/forter/arkose/bfp/sid 重组
  arp 并按 token 缓存;无 ims_sid 的游客 token 直接拒绝,不再入池
- getARPSessionID 复用缓存 arp 并刷新 ftr 时间戳;sid/devHex/bfp 按
  token 保持稳定,不再每次重随机
- 新增 ARPFromCookie/refreshARPTimestamp 及形状单测

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 12:18:05 +08:00
chiyi 33b39006c0 chore(gitignore): 忽略本地调试/探针敏感文件(_tmp_*,_arp_*,live probe) 2026-10-14 12:17:50 +08:00
136 changed files with 7503 additions and 26500 deletions
+5
View File
@@ -64,3 +64,8 @@ Thumbs.db
# fast-deploy build artifact
/frontend/dist.tar.gz
# ad-hoc probe/test scratch files (cookies, tokens, live probes)
_tmp_*
_arp_*.txt
zz_live_probe_test.go
+99 -234
View File
@@ -1,305 +1,170 @@
<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>
**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>
[简体中文](README.md) | **English**
[![Online Demo](https://img.shields.io/badge/Live%20Demo-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run)
[![Online Demo](https://img.shields.io/badge/demo-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-deployment)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-compatible-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-your--proxy-lightgrey)](#-deployment)
[![Providers](https://img.shields.io/badge/providers-7-orange)](#-supported-models--providers)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-deployment)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-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/>
<img src="docs/screenshots/playground.png" alt="image2api — playground" width="860" />
<img src="docs/screenshots/home.png" alt="image2api — landing" width="860" />
</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
<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>
<table>
<tr>
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="Dashboard" /></td>
<td width="50%"><img src="docs/screenshots/models.png" alt="Models" /></td>
<td width="33%"><img src="docs/screenshots/playground.png" alt="Studio" /></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>
<td align="center"><b>📊 Dashboard</b><br/><sub>Users / volume / provider health / 24h trend</sub></td>
<td align="center"><b>🧩 Model management</b><br/><sub>Per-model capabilities, pricing & weight</sub></td>
<td align="center"><b>🎨 Studio</b><br/><sub>image + video · reference uploads · live pricing</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>
<td width="50%"><img src="docs/screenshots/accounts.png" alt="Accounts" /></td>
<td width="50%"><img src="docs/screenshots/logs.png" alt="Logs" /></td>
<td width="33%"><img src="docs/screenshots/channels.png" alt="Channels" /></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>
<td align="center"><b>🔑 Account pools</b><br/><sub>Multi-account pools · weight / concurrency · CRUD</sub></td>
<td align="center"><b>📜 Call logs</b><br/><sub>Success / failure / in-progress · prompts & latency</sub></td>
<td align="center"><b>🔌 Channels</b><br/><sub>save-to-sync upstream models · live balance · failover</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>
</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
#### 🎨 Generation
- Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference)
- Multiple resolutions (images 1K / 2K / 4K · videos 720p / 1080p), aspect ratios and video durations — configured and priced per model
- 7 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes
- **Model aliases**: one model can expose multiple public ids — API calls with any alias resolve to it
- **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
- Image + video in one place, with multi-modal references (images / first-last frames / video / audio) per model capability
- Multiple resolutions (1K/2K/4K images · 720p/1080p/768p video), aspect ratios, discrete or per-second video durations — all per-model configurable and priced
- **Priced = listed**: no enable switch — a model goes public once every tier is priced and an enabled channel serves it
- **Model aliases**: one upstream model can map to several public ids
- Optional **de-AI** post-processing on generated images (per-tier surcharge)
#### 🔌 OpenAI Compatible
- 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)
- **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
- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table
#### 🔁 Channel routing
- **Save = full sync**: storing a channel pulls the upstream `/v1/models`, binds everything, unbinds what disappeared and deletes orphans
- **Weight + concurrency-aware** dispatch with failover; upstream 4xx rejections pass through in Chinese, transient errors move to the next channel
- Channel keys stored AES-GCM encrypted (`CHANNEL_SECRET`); upstream balance probed live
#### 🔁 Account Pools + Smart Failover
- Round-robin scheduling across the pool; one bad account doesn't break the whole
- **Out of quota → switch** · **auth expired → refresh & retry / kill** · **transient → retry same account ×3** · **bad params → fail fast**
- **Pre-deducted credits**: atomic debit before generation, auto-refunded on failure, no over-spend under concurrency
#### 🔌 vivid-protocol API
- `POST /v1/generate` — NDJSON stream: first frame returns `jobId`, ~25s blank-line heartbeats, terminal `succeeded`/`failed`
- `POST /v1/uploads` — presigned PUT direct-to-storage for reference files
- `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
- Single-use rotating tokens (Krea / Imagine) are **renewed proactively 10 minutes before expiry**; new tokens persisted automatically
- Adobe cookies exchanged for fresh tokens on a schedule; bare JWTs killed on expiry
- Daily quota recovered at each provider's reset time, then re-probed for the real balance
#### 💳 Billing & operations
- Credits (pre-charge + auto-refund), per model/tier/duration pricing; de-AI surcharge per tier
- **Reseller pricing**: reseller role + per-model reseller prices (API calls included)
- **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
- Credit-based (**pre-deduct + refund on failure**), priced per model / resolution / duration; de-AI fingerprint adds a per-tier surcharge
- **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
- **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
- **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)
#### 🛠️ Admin console
- Dashboard (trends / DAU / top failures / top spenders)
- **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
- **Banned words**: prompt hits rejected, per-word / per-user trigger stats
#### 🖥️ User Frontend (Vue 3)
- 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
**🧰 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).
#### 🛠️ Admin Console
- 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
## 🔌 Channel architecture
**🧰 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).
## 🤖 Supported Models / Providers
| 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
```
caller ──vivid protocol──▶ image2api ──vivid protocol──▶ upstream vividai A (weight w, conc c)
(this host) ├─ upstream vividai B
└─ …any number
```
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
> 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
# 1. Create an empty database (the backend auto-migrates on start)
createdb vivid_ai
After deploy: admin → Channels → add channel (upstream URL + `vk_` key) → models auto-sync → price them in 模型管理 → done.
# 2. Configure and build the backend from source
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
## 🧱 Stack
# 3. Build the frontend (output in frontend/dist)
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 |
| Layer | Tech |
|---|---|
| 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 |
| Infrastructure | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
| Infra | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
## 📦 Repository Layout
```
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
## 💬 Contact
| | |
|---|---|
| 🌐 Website | **[vividai.run](https://vividai.run)** |
| 👥 QQ Group | **1106849765** · [Join](https://qm.qq.com/q/976LeMFoHu) |
| 🐧 QQ | **1114639355** · [Add](https://qm.qq.com/q/ItgCcNA7ac) |
| 🛒 Shop | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
| 🌐 Site | **[vividai.run](https://vividai.run)** |
| 👥 QQ group | **1106849765** · [join](https://qm.qq.com/q/976LeMFoHu) |
| 🐧 QQ | **1114639355** · [add](https://qm.qq.com/q/ItgCcNA7ac) |
| ✉️ 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: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](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
This project (frontend + backend) is open-source under the [MIT](LICENSE) license — free to use, modify, commercialize and redistribute.
<div align="center">
If this project helps you, a ⭐ Star is much appreciated!
</div>
MIT — see [LICENSE](LICENSE).
+94 -141
View File
@@ -1,10 +1,10 @@
<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>
**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合七大平台,开箱即用的运营系统**
**vivid 协议 AI 生图 / 生视频网关 —— 渠道化接入同构上游,开箱即用的运营系统**
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
@@ -15,17 +15,14 @@
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-部署)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-兼容-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-反代自理-lightgrey)](#-部署)
[![Providers](https://img.shields.io/badge/供应商-7%20平台-orange)](#-支持的模型--供应商)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-部署)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-license)
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-openai-兼容-api) · [交流群](#-交流--联系)
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-vivid-协议-api) · [交流群](#-交流--联系)
<br/>
<img src="docs/screenshots/playground.png" alt="image2api — 画图台" width="860" />
<img src="docs/screenshots/home.png" alt="image2api — 首页" width="860" />
</div>
@@ -36,46 +33,49 @@
- [简介](#-简介)
- [界面预览](#-界面预览)
- [核心功能](#-核心功能)
- [支持的模型 / 供应商](#-支持的模型--供应商)
- [OpenAI 兼容 API](#-openai-兼容-api)
- [渠道架构](#-渠道架构)
- [vivid 协议 API](#-vivid-协议-api)
- [部署](#-部署)
- [技术栈](#-技术栈)
- [仓库结构](#-仓库结构)
- [Roadmap](#-roadmap)
- [交流 / 联系](#-交流--联系)
- [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,可自由二开 / 自部署。
**一句话亮点** 🔌 OpenAI 兼容 · 🤖 7 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 在线充值(易支付)+ 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
**一句话亮点** 🔁 vivid 协议渠道组网 · 📦 保存渠道即全量同步模型 · 💳 积分 + 充值 + 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
## 🖼️ 界面预览
<div align="center">
<sub>🎨 现代化画图前端 · 深 / 浅双主题管理后台 · 数据驱动的运营看板</sub>
<sub>✨ Vivid 风格浅色界面 · 渠道化调度 · 数据驱动的运营看板</sub>
</div>
<table>
<tr>
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
<td width="50%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
<td width="33%"><img src="docs/screenshots/playground.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>
<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>
<td width="50%"><img src="docs/screenshots/accounts.png" alt="账号池管理" /></td>
<td width="50%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
<td width="33%"><img src="docs/screenshots/channels.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>
<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>
</table>
@@ -83,117 +83,98 @@
## 🚀 核心功能
#### 🎨 生成能力
- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考)
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p)、多宽高比、视频多时长,按模型独立配置与定价
- 7 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码
- **模型别名**:同一模型可配多个对外 id,API 调用任意别名均可命中
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识
- 生图 + 生视频一站式,支持**参考图 / 首末帧 / 视频 / 音频**等多模态参考(按模型能力声明下发)
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p / 768p)、多宽高比、视频多时长(离散档或区间档按秒计价),按模型独立配置与定价
- **配价即上架**:模型无启停开关,全部档位有价 + 绑定了启用渠道即对外可见;不定价 = 隐藏
- **模型别名**:同一上游模型可配多个对外 id,API 调用任意别名均可命中
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理,按画质档位加收积分(后台可改价、可整体关闭)
#### 🔌 OpenAI 兼容
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` · 余额 `/v1/user/balance`(剩余/累计已用)
- **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么
#### 🔁 渠道调度
- **保存渠道即全量同步**:填上游 `base_url` + `vk_` key 保存,自动拉取上游 `/v1/models` 全部入库绑定;上游消失的模型自动解绑,无其他渠道的孤儿模型自动清理
- **权重 + 并发感知调度**:渠道可配权重(加权随机)与并发上限,逐渠道尝试、失败自动故障转移
- **错误分类**:上游 4xx(参数/额度)直接透传中文拒绝理由、不重试;网络/5xx 换渠道重试
- 渠道 API Key 以 AES-GCM 加密落库(`CHANNEL_SECRET`),上游余额实时探测
#### 🔁 多账号池 + 智能故障转移
- 账号池调度,单账号出错不影响整体
- **权重优先 + 并发感知**:按账号权重从高到低调度,某账号并发满了才轮到下一个;同权重组内 round-robin 均摊。每账号并发数可配(上游账号),其余系统固定
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错**
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
#### 🔗 自定义上游聚合(OpenAI 兼容)
- 把任意 **OpenAI 兼容的 v1 端点**当成一个账号接入(填 `base_url` + `key`),无需写代码
- **按 model id 自动路由**:上游声明支持哪些 id,生成该 id 时即走对应上游(可覆盖内置 provider);id 留空 = 全部
- 模型管理里自由新建自定义模型(id / 类型 / 比例 / 分辨率·价 / 时长·价 / 参考图),按本地价计费
- 调用**直连不走代理**;上游可配权重与并发,与内置池统一调度
#### 🔐 Token 自动保活
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
- 每日额度按平台重置时间自动恢复 + 重新探测真实余额
#### 🔌 vivid 协议 API
- `POST /v1/generate` —— NDJSON 流式生成:首行回执 `jobId`,约 25s 空行心跳,终帧 `succeeded`/`failed`
- `POST /v1/uploads` —— 预签名 PUT 直传参考文件到对象存储
- `GET /v1/models` —— 裸数组,含能力规格(清晰度档位 / 比例 / 时长 / 参考文件上限)
- `GET /v1/balance` —— 余额查询
- **jobId 断流重连**:断流后仅带 `jobId` 重发同一请求即可续等,不重复下单、不重复扣费;进程重启后带 `jobId` 的任务也会自动续等
- 统一错误包 `{"error":{"code","message"}}`(4001 参数拒绝 / 4002 积分不足 / 4011 未授权 / 4293 并发上限 / 5001 服务异常);API Key 为 `vk_` + 48 hex
#### 💳 计费与运营
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账;累计充值可查
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
- **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组
- 三级角色:普通用户 / 代理 / 管理员(唯一)
- 三级角色:普通用户 / 代理 / 管理员
#### 🖥️ 用户前台(Vue 3)
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
- 作品画廊**多选批量操作**:全选本页 / 批量删除(视频连带首尾帧图) / 批量下载(多个自动打包 zip,并发拉取)
- 预览大图内置**复制原图 / 下载 / 关闭**按钮,卡片一键复制原图
- **充值 · 订单**(充值记录 / 未付可继续支付) · API 文档 · API Key 管理 · 邀请 · 关于,亮 / 暗主题
- **站内公告**:登录后自动弹出 Markdown 公告,内容更新即重推
- 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip)
- **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 无限画布
- **站内公告**:登录后自动弹出 Markdown 公告
#### 🛠️ 管理后台
- 概览看板(趋势 / DAU / 失败 Top / 消费榜)
- 模型管理(普通价 + 代理价 + 别名) · 账号管理(批量导入 / 去重 / 额度) · **并发分组** · **订单管理**(筛选 / 搜索 / 分页) · 全站日志 · 用户管理(设为代理 / 分配并发组 / 看累计充值 / 违禁触发次数) · CDK · 图片管理(多选批量删除 / zip 打包下载) · 展示位 · **站点公告** · 站点配置(含易支付、去AI特征开关与附加价格)
- **违禁词管理**:后台增删违禁词(分页 + 多选批量删除),提示词命中即拦截(画图台 + API,不区分大小写),按词 / 按用户统计触发次数
- **渠道管理**(保存即同步模型 / 上游余额 / 启用开关 / 并发 / 权重)· 模型管理(普通价 + 代理价 + 别名,上架状态只读)· **并发分组** · **订单管理** · 全站日志 · 用户管理(设为代理 / 分配并发组)· CDK · 图片管理 · 展示位 · **站点公告** · 站点配置(易支付、去AI特征等)
- **违禁词管理**:提示词命中即拦截,按词 / 按用户统计触发次数
**🧰 工程亮点**:tls-client(Chrome JA3/JA4 指纹)稳定穿透 Cloudflare · 媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 自愈式维护轮询(恢复额度 / 刷新凭据 / 清理僵死任务并退款) · 一条命令 Docker 部署(TLS 交给你的反代)。
**🧰 工程亮点**:媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 预签名直传参考文件 · 自愈式维护轮询(僵死任务续等 / 退款、过期文件清理)· 一条命令 Docker 部署(TLS 交给你的反代)。
## 🤖 支持的模型 / 供应商
## 🔌 渠道架构
| 供应商 | 模型(示例) | 类型 |
|---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
| **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
```
调用方 ──vivid 协议──▶ image2api ──vivid 协议──▶ 上游 vividai 实例 A (渠道, 权重 w, 并发 c)
(本服务) ├─ 上游 vividai 实例 B
└─ …任意多个
```
图片返回 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 由你自己的反向代理处理(本项目不内置证书签发)。
**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
# 1. 创建空库(后端启动自动建表)
createdb vivid_ai
# 2. 配置并从源码构建后端
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
# 2. 配置并从源码构建后端 —— 完整环境变量见 backend/.env.example
cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666
# 3. 构建前端(产物 frontend/dist)
@@ -212,7 +193,6 @@ server {
index index.html;
client_max_body_size 50m;
proxy_read_timeout 600s; # 视频生成耗时长
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; }
@@ -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 |
| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx |
@@ -236,54 +216,34 @@ server {
```
backend/ 后端源码(Go)
├── cmd/
│ ├── api/ 服务入口(main)
│ └── marklabel/ 运维小工具(按需标记账号)
├── cmd/api/ 服务入口(main)
├── internal/
│ ├── bootstrap/ 应用装配、定时维护任务启动
│ ├── config/ 环境变量配置加载
│ ├── http/
│ │ ├── handler/ HTTP 处理器(v1 兼容接口、后台、鉴权…)
│ │ ├── handler/ HTTP 处理器(/v1 vivid 协议、渠道管理、鉴权…)
│ │ ├── middleware/ 鉴权 / 请求 ID 等中间件
│ │ └── router/ 路由注册
│ ├── model/ GORM 数据模型
│ ├── provider/ 各上游供应商客户端
│ │ ├── adobe/ Adobe Firefly(tls-client 指纹)
│ │ ├── chatgpt/ OpenAI(含 PoW / turnstile)
│ │ ├── runway/ Runway 视频 + Nano Banana 图像
│ │ ├── grok/ Grok(console.x.ai DPoP,视频 / 图像;账号态走 grok.com)
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
│ │ ├── imagine/ Imagine.art
│ │ ├── custom/ 自定义上游(OpenAI 兼容 v1,按 id 路由,直连不走代理)
│ │ └── epay/ 易支付(mapi 下单 + 回调 MD5 验签,积分充值)
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK / 订单 / 并发组…)
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
│ └── storage/ RustFS / S3 媒体存储
├── Dockerfile 多阶段构建(源码编译 → 精简运行镜像)
│ ├── model/ GORM 数据模型(渠道 / 模型 / 用户 / 事件…)
│ ├── upstream/ vivid 协议上游客户端(NDJSON / 上传 / 模型 / 余额)
│ ├── repo/ 数据访问层
│ ├── service/ 业务逻辑(渠道调度、计费、维护、v1 job 生命周期)
│ └── storage/ RustFS / S3 媒体存储(预签名 PUT/GET)
├── Dockerfile 多阶段构建
└── .env.example 后端环境变量模板
frontend/ 前端源码(Vue 3 + Vite)
├── src/
│ ├── views/ 页面(画图台 / 账号 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
│ ├── views/ 页面(画图台 / 渠道 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
│ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…)
│ ├── layouts/ 公共 / 后台布局
│ ├── utils/ 工具函数
│ └── api.js · auth.js … 接口封装、鉴权、主题、积分等
├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代
└── default.conf.template Nginx 站点模板(反代 + 缓存策略)
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)** |
| ✉️ 邮箱 | vividairun@gmail.com |
## ⭐ Star History
<!-- 建好 GitHub 仓库后,取消下面这行注释并把 OWNER 换成你的用户名即可显示趋势图: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](https://star-history.com/#OWNER/image2api&Date) -->
觉得有用就点个 ⭐ 吧 —— 建好仓库后取消上方注释即可展示 Star 趋势图。
## 📄 License
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
+18 -4
View File
@@ -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.
APP_ENV=development
APP_TITLE=Vivid AI
APP_TITLE=image2api
HTTP_ADDR=:6666
# PostgreSQL (database must already exist; tables auto-migrate on boot)
@@ -13,8 +13,22 @@ REDIS_ADDR=127.0.0.1:6379
REDIS_PASSWORD=
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
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Optional: override generated media root (defaults to ../../ai-gateway/data/generated)
# GENERATED_ROOT=
# Set to true when serving the site over HTTPS (session cookie Secure flag)
COOKIE_SECURE=false
# SESSION_COOKIE_NAME=vivid_session
# GENERATED_ROOT= # optional: override generated media root
+19 -47
View File
@@ -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
- GORM
- PostgreSQL
- Redis
## Architecture sketch
## Current scope
This is an in-progress rewrite. The current skeleton already includes:
- app bootstrap
- PostgreSQL and Redis initialization
- GORM auto-migrations
- session storage in Redis
- 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`
- `internal/upstream` — vivid-protocol client (NDJSON generate + jobId resume,
`/v1/uploads`, `/v1/models`, `/v1/balance`, error classification)
- `internal/service/channel.go` — channel CRUD, upstream model sync (save =
full sync), AES-GCM key storage
- `internal/service/v1.go` — channel-weighted dispatch, billing, job lifecycle,
stale-job resume
- `internal/http/handler/v1.go` — public vivid endpoints
(`/v1/generate` `/v1/uploads` `/v1/models` `/v1/balance`)
## 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
$env:POSTGRES_DSN="host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai"
$env:REDIS_ADDR="127.0.0.1:6379"
$env:HTTP_ADDR=":6061"
```bash
cp .env.example .env # fill in values
go build -o bin/api ./cmd/api && ./bin/api
```
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.
-45
View File
@@ -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)
}
+35 -68
View File
@@ -5,25 +5,16 @@ import (
"fmt"
"log"
"os"
"strconv"
"time"
"backend/internal/config"
"backend/internal/http/handler"
"backend/internal/http/router"
"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/service"
"backend/internal/storage"
"backend/internal/upstream"
"github.com/gin-gonic/gin"
"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)
}
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成 gorm.ErrDuplicatedKey,
// 否则各 import-*(krea/adobe/leonardo/runway)里的 errors.Is(err, gorm.ErrDuplicatedKey)
// 兜底命不中,重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成
// gorm.ErrDuplicatedKey,否则 errors.Is(err, gorm.ErrDuplicatedKey) 兜底命不中,
// 重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
if err != nil {
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 {
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 {
return nil, fmt.Errorf("seed defaults: %w", err)
}
@@ -97,8 +96,7 @@ func NewApp(ctx context.Context) (*App, error) {
eventRepo := repo.NewEventRepository(db)
cdkRepo := repo.NewCDKRepository(db)
apiKeyRepo := repo.NewAPIKeyRepository(db)
tokenRepo := repo.NewTokenRepository(db)
refreshRepo := repo.NewRefreshProfileRepository(db)
channelRepo := repo.NewChannelRepository(db)
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
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)
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
adobeClient := adobe.NewClient("projectx_webapp", "")
chatGPTClient := chatgpt.NewClient("")
runwayClient := runway.NewClient("")
leonardoClient := leonardo.NewClient("")
kreaClient := krea.NewClient("")
imagineClient := imagine.NewClient("")
grokClient := grok.NewClient("")
// Keep grok's x-statsig-id anti-bot recipe current by reading grok's own
// headless-browser signer output. Event-driven: seed from the persisted
// recipe, capture once at startup, then re-capture only on an anti-bot 403
// (a reship made the recipe stale). No polling.
startGrokStatsigRefresh(siteRepo)
customClient := custom.NewClient()
cfClient := creativefabrica.NewClient("")
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, cfClient, rustfsClient)
upClient := upstream.NewClient()
channelSecret := cfg.ChannelSecret
if channelSecret == "" {
// Stable fallback so a restarted process can still decrypt existing
// rows when CHANNEL_SECRET isn't set.
channelSecret = cfg.PostgresDSN
}
channelSvc := service.NewChannelService(channelRepo, modelRepo, eventRepo, concSvc, upClient, channelSecret)
// Sweep provider-era model rows: anything not bound to a channel can never
// be routed under the channel scheduler.
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
log.Printf("purge orphan models: %v", err)
} else if n > 0 {
log.Printf("purged %d unbound model(s) left over from the provider era", n)
}
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo)
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo)
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, channelRepo, cdkRepo, rustfsClient, showcaseRepo)
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, channelRepo, orderRepo)
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
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)
v1Svc.SetBannedWords(bannedWordRepo)
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
@@ -163,7 +157,7 @@ func NewApp(ctx context.Context) (*App, error) {
CDK: handler.NewCDKHandler(cdkSvc),
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc),
ChannelAdmin: handler.NewChannelAdminHandler(channelSvc),
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
Announcement: handler.NewAnnouncementHandler(announcementSvc),
Payment: handler.NewPaymentHandler(paymentSvc),
@@ -171,9 +165,9 @@ func NewApp(ctx context.Context) (*App, error) {
Canvas: handler.NewCanvasHandler(canvasSvc),
})
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
// cleanup, log retention) — the Go equivalent of the Python daemon thread.
maintenanceSvc := service.NewMaintenanceService(tokenRepo, tokenSvc, eventRepo, userRepo, refreshSvc, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
// log/media retention).
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc, showcaseRepo, orderRepo)
loopCtx, loopCancel := context.WithCancel(context.Background())
go maintenanceSvc.Run(loopCtx)
@@ -186,33 +180,6 @@ func NewApp(ctx context.Context) (*App, error) {
}, 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 {
if a.maintenanceCancel != nil {
a.maintenanceCancel()
+4
View File
@@ -26,6 +26,9 @@ type Config struct {
RustFSBucket string
RustFSAccessKey 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) {
@@ -60,6 +63,7 @@ func Load() (*Config, error) {
RustFSBucket: envString("RUSTFS_BUCKET", ""),
RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""),
RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""),
ChannelSecret: envString("CHANNEL_SECRET", ""),
}
return cfg, nil
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"time"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/service"
"github.com/gin-gonic/gin"
)
@@ -99,7 +100,21 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
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()})
}
}
@@ -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})
}
+3 -3
View File
@@ -46,16 +46,16 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
// 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 +
// 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) {
grouped, err := h.showcase.Grouped(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
return
}
kindFilter := c.Query("kind") // "" | hero | bento | work
kindFilter := c.Query("kind") // "" | hero | work
flat := make([]gin.H, 0, 16)
for _, kind := range []string{"hero", "bento", "work"} {
for _, kind := range []string{"hero", "work"} {
if kindFilter != "" && kind != kindFilter {
continue
}
+89 -679
View File
@@ -3,6 +3,7 @@ package handler
import (
"bytes"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
@@ -13,6 +14,8 @@ import (
"sync"
"time"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/service"
"github.com/gin-gonic/gin"
_ "golang.org/x/image/webp"
@@ -248,7 +251,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
Resolution string `json:"resolution"`
Duration string `json:"duration"`
ReferenceImages []string `json:"reference_images"`
AccountID string `json:"account_id"`
ChannelID string `json:"channel_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
@@ -262,7 +265,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
Resolution: body.Resolution,
Duration: body.Duration,
ReferenceImages: body.ReferenceImages,
AccountID: body.AccountID,
ChannelID: body.ChannelID,
})
if err != nil {
switch {
@@ -451,161 +454,19 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
}
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"data": []gin.H{
{
"key": "gemini-veo31",
"label": "Veo31 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-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,
},
},
})
models, err := h.admin.Models(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load video presets"})
return
}
out := make([]gin.H, 0, len(models))
for _, m := range models {
if m.Type != "video" {
continue
}
out = append(out, modelPreset(m))
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
@@ -620,7 +481,7 @@ func (h *UserGenerationHandler) Catalog(c *gin.Context) {
}
func (h *UserGenerationHandler) Models(c *gin.Context) {
items, err := h.publicModels()
items, err := h.publicModels(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
return
@@ -628,537 +489,86 @@ func (h *UserGenerationHandler) Models(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
items := []gin.H{
{
"id": "gpt-image-2",
"provider": "chatgpt",
"type": "image",
// ChatGPT web backend only reliably produces 1K and honors a limited
// ratio set; size params are advisory prompt hints. Mirrors the Python
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
"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 (文生图)",
},
// modelPreset projects one model_configs row into the preset shape the studio
// (画图台) / model pickers consume. Database-driven now: providers are gone and
// every model arrives via a channel import.
func modelPreset(m model.ModelConfig) gin.H {
durations := repo.JSONStrings(m.Durations)
if len(durations) == 0 && m.DurationMin > 0 && m.DurationMax >= m.DurationMin {
// Range spec → expand integer seconds (capped so absurd ranges can't
// flood a dropdown).
for d := m.DurationMin; d <= m.DurationMax && len(durations) < 61; d++ {
durations = append(durations, fmt.Sprintf("%ds", d))
}
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())
if err != nil {
return nil, err
}
for _, item := range models {
existing[item.ID] = true
}
}
for i := range items {
items[i]["added"] = existing[items[i]["id"].(string)]
items := make([]gin.H, 0, len(models))
for _, m := range models {
row := modelPreset(m)
row["id"] = m.EffectiveName()
items = append(items, row)
}
return items, nil
}
func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
items := []gin.H{
{
"id": "gpt-image-2",
"provider": "chatgpt",
"kind": "image",
// See catalogEntries — ChatGPT only reliably does 1K and a limited
// ratio set; matches the Python reference. Keep both lists in sync.
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
"resolutions": []string{"1K"},
"description": "ChatGPT image generation",
// publicModels — GET /api/models for the public site (docs/landing pickers).
// Fully-priced models only (配价即上架), same shape as before.
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
models, err := h.admin.Models(c.Request.Context())
if err != nil {
return nil, err
}
items := make([]gin.H, 0, len(models))
for _, m := range models {
// 配价即上架:未配齐价的模型对前台不可见(绑定/配价都满足才进 /v1)。
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,
},
{
"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
}
+178 -336
View File
@@ -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
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"backend/internal/model"
"backend/internal/service"
"github.com/gin-gonic/gin"
)
// v1MaxWait 同步等待终态的上限。视频单渠道预算 30 分钟、最多换几个渠道,
// 留足余量取 2 小时(与 vivid 一致)。
const v1MaxWait = 2 * time.Hour
const v1PollInterval = time.Second
type V1Handler struct {
v1 *service.V1Service
}
@@ -22,385 +36,213 @@ func NewV1Handler(v1 *service.V1Service) *V1Handler {
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"))
if err != nil {
h.writeAuthError(c, err)
return
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
return nil, false
}
return principal, true
}
_ = principal
items, err := h.v1.ListModels(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
return
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": items,
// v1Err writes the {"error":{"code","message"}} envelope — the wire shape every
// vivid-protocol caller parses.
func v1Err(c *gin.Context, httpStatus int, code, msg string) {
c.JSON(httpStatus, gin.H{
"error": gin.H{"code": code, "message": msg},
})
}
// UserBalance — GET /v1/user/balance. 返回 API Key 所属用户的账户级余额
// (剩余 / 累计已用),与具体令牌无关。
func (h *V1Handler) UserBalance(c *gin.Context) {
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
if err != nil {
h.writeAuthError(c, err)
return
// v1Fail maps a service error to the vivid code/HTTP status pair.
func v1Fail(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrMissingAPIKey), errors.Is(err, service.ErrInvalidAPIKey):
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
case errors.Is(err, service.ErrInsufficientFunds):
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).
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
// are mapped server-side. Returns {created, data:[{b64_json}]}.
func (h *V1Handler) ImageGenerations(c *gin.Context) {
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
if err != nil {
h.writeAuthError(c, err)
// ---- POST /v1/generate ----
func (h *V1Handler) Generate(c *gin.Context) {
principal, ok := h.auth(c)
if !ok {
return
}
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"`
}
var body service.V1JobRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
return
}
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
Model: body.Model,
Prompt: body.Prompt,
N: body.N,
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[]")
var ev *model.EventLog
var err error
// 重连模式:只带 jobId → 不建新任务,回读该任务并等待终态。
if body.JobID != nil && strings.TrimSpace(*body.JobID) != "" {
ev, err = h.v1.JobForUser(c.Request.Context(), principal, *body.JobID)
} else {
var body struct {
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"`
ev, err = h.v1.CreateJob(c.Request.Context(), principal, body)
}
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 {
h.writeV1Error(c, err, nil)
v1Fail(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object.
func (h *V1Handler) GetVideo(c *gin.Context) {
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
if err != nil {
h.writeAuthError(c, err)
return
wait := 300 * time.Second
if h := strings.TrimSpace(c.GetHeader("X-Wait-Seconds")); h != "" {
if sec, serr := strconv.Atoi(h); serr == nil && sec > 0 && time.Duration(sec)*time.Second <= v1MaxWait {
wait = time.Duration(sec) * time.Second
}
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
// by proxying the stored upstream URL (downloaded on demand, never persisted).
func (h *V1Handler) GetVideoContent(c *gin.Context) {
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
if err != nil {
h.writeAuthError(c, err)
return
c.Writer.Header().Set("Content-Type", "application/x-ndjson")
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.WriteHeader(http.StatusOK)
flusher, _ := c.Writer.(http.Flusher)
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 err != nil {
h.writeV1Error(c, err, nil)
return
if flusher != nil {
flusher.Flush()
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, body)
return true
}
// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by
// proxying its stored (possibly auth-gated) upstream URL. Never persisted.
func (h *V1Handler) GetImageContent(c *gin.Context) {
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
if err != nil {
h.writeAuthError(c, err)
// 已终态:直接回终态帧关流。
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
writeFrame(ev)
return
}
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
if err != nil {
h.writeV1Error(c, err, nil)
// 连上即回执 jobId(断流后携它重发的凭据)。
if !writeFrame(ev) {
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.
func readMultipartImages(c *gin.Context, keys ...string) []string {
var out []string
form := c.Request.MultipartForm
if form == nil {
return out
}
for _, key := range keys {
for _, fh := range form.File[key] {
f, e := fh.Open()
if e != nil {
deadline := time.Now().Add(wait)
poll := time.NewTicker(v1PollInterval)
heartbeat := time.NewTicker(25 * time.Second)
defer poll.Stop()
defer heartbeat.Stop()
for {
select {
case <-c.Request.Context().Done():
return // client gone — the job keeps running; caller may reconnect with jobId
case <-poll.C:
latest, lerr := h.v1.JobForUser(c.Request.Context(), principal, ev.ID)
if lerr != nil || latest == nil {
continue
}
b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1))
f.Close()
if len(b) > 0 {
out = append(out, base64.StdEncoding.EncodeToString(b))
ev = latest
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
writeFrame(ev)
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) {
switch {
case errors.Is(err, service.ErrUnknownModel):
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrInsufficientFunds):
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrReferenceTooLarge):
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
case errors.Is(err, service.ErrNoProviderAccount):
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()})
// v1JobBody assembles one NDJSON frame (running receipt and terminal frame
// share the shape; succeeded adds result+creditsCharged, failed adds error).
func v1JobBody(v1 *service.V1Service, ev *model.EventLog) map[string]any {
body := map[string]any{"jobId": ev.ID, "status": service.JobStatus(ev)}
switch service.JobStatus(ev) {
case "succeeded":
body["result"] = v1.JobResultURL(ev)
body["creditsCharged"] = ev.Cost
case "failed":
body["error"] = ev.Error
body["refunded"] = true
}
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
// layer can build absolute, directly-downloadable output URLs. Honors
// 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.
// layer can build absolute /images URLs for the session path.
func requestBaseURL(c *gin.Context) string {
host := c.Request.Host
if host == "" {
+12 -32
View File
@@ -24,7 +24,7 @@ type Handlers struct {
CDK *handler.CDKHandler
UserTools *handler.UserToolsHandler
UserGen *handler.UserGenerationHandler
ProviderAdmin *handler.ProviderAdminHandler
ChannelAdmin *handler.ChannelAdminHandler
ConcGroups *handler.ConcurrencyGroupHandler
Announcement *handler.AnnouncementHandler
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("/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/user/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)
engine.GET("/v1/balance", handlers.V1.UserBalance)
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.
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
publicAdmin.GET("/catalog", handlers.UserGen.Catalog)
publicAdmin.GET("/models", handlers.UserGen.Models)
publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet)
// 易支付 async notify — called server-to-server by the pay platform, no auth.
@@ -147,23 +142,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/cdks", handlers.CDK.Create)
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
authed.GET("/tokens", handlers.ProviderAdmin.TokensList)
authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate)
authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken)
authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie)
authed.POST("/tokens/import-creativefabrica-cookie", handlers.ProviderAdmin.ImportCreativeFabricaCookie)
authed.POST("/tokens/import-runway-token", handlers.ProviderAdmin.ImportRunwayToken)
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("/channels", handlers.ChannelAdmin.List)
authed.POST("/channels", handlers.ChannelAdmin.Save)
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
authed.POST("/channels/sync-models", handlers.ChannelAdmin.SyncModels)
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
authed.GET("/providers", handlers.AdminRead.Providers)
authed.GET("/images", handlers.AdminRead.Images)
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
@@ -172,10 +156,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/banned-words/import", handlers.BannedWords.Import)
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
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.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
+51 -56
View File
@@ -5,6 +5,7 @@ import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type User struct {
@@ -97,15 +98,16 @@ type EventLog struct {
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
Source string `gorm:"size:32;index"`
// AccountID is the provider token/account chosen to fulfil this generation,
// stamped when the upstream call begins. Drives the accounts view's live
// in-flight count (pending events per account) and lets an abandoned-event
// purge attribute the failure back to the account it was using.
// AccountID is the channel chosen to fulfil this generation, stamped when
// the upstream call begins. (Field name kept from the pre-channel schema.)
AccountID string `gorm:"size:64;index"`
// AccountEmail denormalizes the account's email at stamp time, so log rows
// keep showing which mailbox fulfilled the generation even after the account
// is deleted or re-imported under a different ID.
// AccountEmail denormalizes the channel's display name at stamp time, so log
// rows keep showing which channel fulfilled the generation even after the
// channel is deleted or renamed.
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"`
Cost float64 `gorm:"not null;default:0"`
// 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"`
Alias string `gorm:"column:alias;size:255;not null;default:''"`
Provider string `gorm:"size:100;index;not null"`
Enabled bool `gorm:"not null;default:true"`
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
Ratios datatypes.JSON `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"`
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
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"`
// 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'"`
// 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 floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
// 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
// retention / manual clearing), so the admin "次数" is a true running total.
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
UpdatedAt time.Time
}
@@ -176,53 +189,35 @@ type CDKCode struct {
UpdatedAt time.Time
}
type TokenAccount struct {
ID string `gorm:"primaryKey;size:64"`
Pool string `gorm:"size:64;index;not null"`
Value string `gorm:"type:text"`
Status string `gorm:"size:32;index;not null"`
Fails int `gorm:"not null;default:0"`
FailTotal int `gorm:"not null;default:0"`
SuccessTotal int `gorm:"not null;default:0"`
Dead bool `gorm:"not null;default:false"`
Meta datatypes.JSONMap `gorm:"type:jsonb"`
AddedAt *time.Time
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.
// Channel is one upstream vivid-protocol instance this gateway routes
// generation to. Replaces the old per-provider token account pool: a channel
// is just a base_url + api_key plus routing knobs (enabled/weight/concurrency)
// and local usage counters. ApiKey is stored AES-GCM encrypted.
type Channel struct {
ID string `gorm:"primaryKey;size:40"`
Name string `gorm:"size:255;not null"`
BaseURL string `gorm:"size:500;not null"`
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
Enabled bool `gorm:"not null;default:true;index"`
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = unlimited.
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
UpdatedAt time.Time
}
type RefreshProfile struct {
ID string `gorm:"primaryKey;size:64"`
Name string `gorm:"size:255;not null"`
Pool string `gorm:"size:64;index;not null"`
Kind string `gorm:"size:64;index;not null"`
Cookie string `gorm:"type:text"`
Enabled bool `gorm:"not null;default:true"`
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"`
// ChannelModel binds a local model_configs row to a channel and records the
// upstream model name to send on the wire (the local row's alias stays the
// user-facing name).
type ChannelModel struct {
ChannelID string `gorm:"primaryKey;size:40"`
ModelID string `gorm:"primaryKey;size:255;index"`
UpstreamModel string `gorm:"size:255;not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -244,8 +239,8 @@ func AutoMigrateModels() []any {
&EventLog{},
&ModelConfig{},
&CDKCode{},
&TokenAccount{},
&RefreshProfile{},
&Channel{},
&ChannelModel{},
&SiteSetting{},
&StatCounter{},
&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")
}
}
-44
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
-517
View File
@@ -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
}
-262
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
-151
View File
@@ -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)
}
-325
View File
@@ -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)
}
-381
View File
@@ -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
}
}
-935
View File
@@ -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
}
-609
View File
@@ -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); }
};
})();
-528
View File
@@ -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)
}
-452
View File
@@ -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
}
-251
View File
@@ -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
}
-499
View File
@@ -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
}
-324
View File
@@ -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
}
-401
View File
@@ -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))
}
}
-412
View File
@@ -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
}
-373
View File
@@ -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
}
-181
View File
@@ -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
}
-516
View File
@@ -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
}
}
+236
View File
@@ -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
}
+76 -8
View File
@@ -24,6 +24,7 @@ type EventListFilter struct {
UserID string
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, 跨页)
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)
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)
@@ -66,8 +67,12 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m
}
if term := strings.TrimSpace(filter.Query); 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)
}
}
if filter.ExcludeSource != "" {
q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource)
}
@@ -184,7 +189,7 @@ type DashboardWindow struct {
Pending int64 `json:"pending"`
Image int64 `json:"image"`
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)
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
// 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 {
ID string `gorm:"column:id"`
UserID string `gorm:"column:user_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"`
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
@@ -446,7 +480,10 @@ type StaleEvent struct {
//
// 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 {
maxAge = 600 * time.Second
}
@@ -455,13 +492,14 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
}
cutoff := time.Now().Add(-maxAge)
videoCutoff := time.Now().Add(-videoMaxAge)
resumeFloor := time.Now().Add(-maxResumeAge)
var stale []StaleEvent
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 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
// the pending set).
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").
Scan(&stale).Error; err != nil {
return err
@@ -470,7 +508,7 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
return nil
}
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{
"status": "failed",
"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
}
// SetAccount stamps which provider account is fulfilling an in-flight event.
// Called when generation commits to a token, so the accounts view can count
// pending events per account and an abandoned-event purge can attribute back.
// SetUpstreamJob records the job id the upstream assigned (NDJSON jobId frame)
// for cross-instance debugging. Best-effort.
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 {
return r.db.WithContext(ctx).
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
}
// 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
// the accounts view's live "in-flight" column.
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
+34 -2
View File
@@ -44,6 +44,16 @@ func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.Model
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) {
items, err := r.List(ctx)
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) {
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()
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
}
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 &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) {
res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID)
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
}
-1
View File
@@ -63,7 +63,6 @@ func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.Sh
grouped := map[string][]model.ShowcaseItem{
"hero": {},
"bento": {},
"work": {},
}
for _, item := range items {
-529
View File
@@ -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
}
+56 -71
View File
@@ -19,20 +19,20 @@ type AdminReadService struct {
models *repo.ModelRepository
events *repo.EventRepository
settings *repo.SiteSettingRepository
tokens *repo.TokenRepository
channels *repo.ChannelRepository
cdks *repo.CDKRepository
store *storage.Client
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{
cfg: cfg,
users: users,
models: models,
events: events,
settings: settings,
tokens: tokens,
channels: channels,
cdks: cdks,
store: store,
showcase: showcase,
@@ -84,15 +84,20 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
if err != nil {
return nil, err
}
routable, _ := s.channels.RoutableModelIDs(ctx)
channelNames, _ := s.channels.ChannelNamesByModel(ctx)
out := make([]map[string]any, 0, len(items))
for _, item := range items {
_, bound := routable[item.ID]
out = append(out, map[string]any{
"id": item.ID,
"alias": item.Alias,
"type": item.Type,
"name": item.Name,
"provider": item.Provider,
"enabled": item.Enabled,
"channels": channelNames[item.ID],
// 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
"sellable": bound && ModelFullyPriced(&item),
"ratios": repo.JSONStrings(item.Ratios),
"prices": map[string]any(item.Prices),
"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),
"durations": repo.JSONStrings(item.Durations),
"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,
"weight": item.Weight,
"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) {
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
if excludeShowcase {
excludeFiles = s.showcaseFileList(ctx)
@@ -127,6 +147,7 @@ func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, st
UserID: userID,
UserIDs: userIDs,
Query: query,
KeywordUserIDs: kwUserIDs,
ExcludeSource: excludeSource,
Source: source,
HasFile: hasFile,
@@ -194,24 +215,24 @@ func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string,
return out, nil
}
// AccountNameMap builds a token-account id -> display label lookup (account
// email, else display name, else id) used to annotate log rows with which
// provider account fulfilled each generation (event_logs.account_id).
// AccountNameMap builds a channel id -> display name lookup used to annotate
// log rows with which channel 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) {
accounts, err := s.tokens.List(ctx)
if s.channels == nil {
return map[string]string{}, nil
}
channels, err := s.channels.List(ctx)
if err != nil {
return nil, err
}
out := make(map[string]string, len(accounts))
for _, a := range accounts {
label := strings.TrimSpace(a.AccountEmail)
out := make(map[string]string, len(channels))
for _, ch := range channels {
label := strings.TrimSpace(ch.Name)
if label == "" {
label = strings.TrimSpace(a.AccountDisplayName)
label = ch.ID
}
if label == "" {
label = a.ID
}
out[a.ID] = label
out[ch.ID] = label
}
return out, nil
}
@@ -420,66 +441,30 @@ func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *r
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) {
models, err := s.models.List(ctx)
if s.channels == nil {
return []map[string]any{}, nil
}
channels, err := s.channels.List(ctx)
if err != nil {
return nil, err
}
tokens, err := s.tokens.List(ctx)
if err != nil {
return nil, err
}
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 := make([]map[string]any, 0, len(channels))
for _, ch := range channels {
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, ch.ID)
out = append(out, map[string]any{
"name": item.Name,
"token_pool": item.Pool,
"type": item.Type,
"model_count": modelCounts[item.Name],
"tokens_total": count.active + count.disabled + count.quota,
"tokens_active": count.active,
"tokens_disabled": count.disabled,
"tokens_quota": count.quota,
"id": ch.ID,
"name": ch.Name,
"base_url": ch.BaseURL,
"enabled": ch.Enabled,
"concurrency": ch.Concurrency,
"weight": ch.Weight,
"model_count": len(modelIDs),
"success_total": ch.SuccessTotal,
"used_credits": ch.UsedCredits,
"type": "vivid",
})
}
return out, nil
+37 -62
View File
@@ -28,18 +28,18 @@ type AdminWriteService struct {
models *repo.ModelRepository
events *repo.EventRepository
apiKeys *repo.APIKeyRepository
tokens *repo.TokenRepository
channels *repo.ChannelRepository
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{
users: users,
showcase: showcase,
models: models,
events: events,
apiKeys: apiKeys,
tokens: tokens,
channels: channels,
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) {
kind := normalizedShowcaseKind(stringValue(body["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"]))
if image == "" {
@@ -333,7 +333,7 @@ func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string,
if _, ok := body["kind"]; ok {
kind := normalizedShowcaseKind(stringValue(body["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
}
@@ -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")
}
if provider == "" {
return nil, errors.New("provider required")
provider = "channel"
}
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
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),
Alias: alias,
Provider: provider,
Enabled: boolValueWithDefault(body["enabled"], true),
Ratios: jsonArray(body["ratios"]),
Prices: prices,
Resolutions: resolutions,
@@ -401,6 +400,10 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
Durations: jsonArray(body["durations"]),
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"),
Weight: intValue(body["weight"]),
CreatedAt: time.Now(),
@@ -434,17 +437,9 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
}
if _, ok := body["provider"]; ok {
provider := strings.TrimSpace(stringValue(body["provider"]))
if provider == "" {
return nil, errors.New("provider required")
}
if 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 {
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 {
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 {
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 {
patch["weight"] = intValue(body["weight"])
}
@@ -524,52 +537,14 @@ func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) err
if rows == 0 {
return ErrNotFound
}
// Clean up: strip this model id from every custom upstream account's
// supported-models list so no orphan reference is left behind.
s.removeModelFromUpstreams(ctx, modelID)
// Clean up: drop every channel binding pointing at the deleted model so no
// orphan reference is left behind.
if s.channels != nil {
_ = s.channels.DeleteBindingsByModel(ctx, modelID)
}
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) {
return s.events.DeleteAll(ctx)
}
@@ -606,7 +581,7 @@ func normalizedStatus(status string) string {
func normalizedShowcaseKind(kind string) string {
switch strings.TrimSpace(kind) {
case "hero", "bento", "work":
case "hero", "work":
return strings.TrimSpace(kind)
default:
return ""
@@ -680,8 +655,8 @@ func boolValueWithDefault(v any, fallback bool) bool {
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
// 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,
// for example, only ever has a "1K" price, so it resolves to exactly ["1K"].
// `resolutions` for images), returned in canonical 1K/2K/4K order. A model that
// only ever has a "1K" price, for example, resolves to exactly ["1K"].
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
out := []string{}
for _, r := range []string{"1K", "2K", "4K"} {
+9 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -109,8 +110,15 @@ func (s *APIKeyService) DeleteOne(ctx context.Context, userID, keyID string) err
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) {
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 {
+647
View File
@@ -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)
}
+46 -150
View File
@@ -5,47 +5,39 @@ import (
"log"
"strconv"
"strings"
"sync"
"time"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/storage"
)
// MaintenanceService runs the periodic self-healing sweep that the Python
// original did via a 60s daemon thread plus read-time lazy cleanup. Without it
// the Go token pool only ever loses capacity: tokens never re-activate after a
// quota reset, cookies never auto-renew, stale pending events permanently block
// a user's generation gate, and old media/logs accumulate unbounded.
// MaintenanceService runs the periodic self-healing sweep. Without it stale
// pending events permanently block a user's generation gate, and old media /
// logs / temp objects accumulate unbounded. Token-pool and cookie-refresh
// upkeep is gone — channels are stateless upstream credentials.
type MaintenanceService struct {
tokens *repo.TokenRepository
tokenSvc *TokenService
events *repo.EventRepository
users *repo.UserRepository
refresh *RefreshProfileService
settings *repo.SiteSettingRepository
store *storage.Client
inflight *InflightRegistry
v1 *V1Service
showcase *repo.ShowcaseRepository
orders *repo.OrderRepository
interval time.Duration
stalePending time.Duration
stalePendingVid time.Duration
maxResumeAge time.Duration
mediaPruneEvery time.Duration
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{
tokens: tokens,
tokenSvc: tokenSvc,
events: events,
users: users,
refresh: refresh,
settings: settings,
store: store,
inflight: inflight,
v1: v1,
showcase: showcase,
orders: orders,
interval: 60 * time.Second,
@@ -53,6 +45,9 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
// 10 分钟去扫会在它出片前判死并退款。
stalePendingVid: 30 * time.Minute,
// 带上游 jobId 的逾期任务先尝试续等而不是判死;超过 3 小时总时限
// (vivid 同款)才放弃走判死退款。
maxResumeAge: 3 * time.Hour,
mediaPruneEvery: 60 * time.Second,
}
}
@@ -63,10 +58,6 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
func (m *MaintenanceService) Run(ctx context.Context) {
ticker := time.NewTicker(m.interval)
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)
for {
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) {
// 0. Auto-cancel unpaid recharge orders past their 30-min TTL.
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
// auto-sync their real balance — these providers only refresh quota when
// accessed, so recovery alone would leave a stale 0/—. For krea the sync
// must first load /app (Activate) to grant the daily free balance.
if recovered, err := m.tokens.RecoverQuota(ctx); err != nil {
log.Printf("maintenance: recover_quota: %v", err)
} else if len(recovered) > 0 {
log.Printf("maintenance: recovered %d quota token(s)", len(recovered))
if m.tokenSvc != nil {
go m.syncRecoveredQuota(recovered)
// 1a. Pending events with an upstream job id that outlived their local
// goroutine (process restart) get resumed against the owning channel —
// the upstream job keeps running even while we were down.
if m.v1 != nil {
if resumable, err := m.events.ResumableStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
log.Printf("maintenance: resumable_stale: %v", err)
} else {
for _, e := range resumable {
m.v1.ResumeStaleJob(ctx, e)
}
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
// 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
// 1b. Fail long-pending events so they stop blocking the per-user gate, and
// refund the credits debited up-front for each abandoned generation (the
// 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)
} else if len(purged) > 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
// keep grinding for minutes and surface a late "success" on this
// just-abandoned event.
if m.inflight != nil && m.inflight.Cancel(e.ID) {
if m.v1 != nil && m.v1.Inflight().Cancel(e.ID) {
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 {
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)
}
// 4. Enforce the admin-configured log retention window.
// 2. Enforce the admin-configured log retention window.
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.
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
m.pruneMedia(ctx)
@@ -274,10 +159,11 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
return
}
days := m.retentionDays(ctx, "media.retention_days")
if days <= 0 {
return
}
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, "")
if err != nil {
log.Printf("maintenance: list media: %v", err)
@@ -303,10 +189,20 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
removed, skipped := 0, 0
var clearedKeys []string
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
}
if _, ok := pinned[strings.TrimLeft(o.Key, "/")]; ok {
if _, ok := pinned[key]; ok {
skipped++
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)
}
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -40,8 +40,8 @@ type UserGenerateRequest struct {
// DeAI applies 去AI特征 post-processing to the generated image (image only)
// and charges the per-tier surcharge on top of the model price.
DeAI bool
// AccountID pins an admin test to one specific provider account (账号生图测试).
AccountID string
// ChannelID pins an admin test to one specific channel (渠道生图测试).
ChannelID string
}
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"
// or "asset", the model must support references at all, and frame mode carries
// at most 2 images (first+last frame).
// validateReferenceMode mirrors the /v1 checks: the override must be "frame",
// "asset" or "media", the model must support references at all, and frame mode
// 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 {
rm = strings.TrimSpace(rm)
if rm == "" {
@@ -107,8 +108,8 @@ func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int
if supported == "" || supported == "none" {
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
}
if rm != "frame" && rm != "asset" {
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
if rm != "frame" && rm != "asset" && rm != "media" {
return fmt.Errorf("%w: reference_mode must be 'frame', 'asset' or 'media'", ErrUnsupportedParams)
}
if rm == "frame" && refCount > 2 {
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,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
AccountID: in.AccountID,
ChannelID: in.ChannelID,
})
default:
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
@@ -146,7 +147,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
AspectRatio: in.Ratio,
Resolution: in.Resolution,
ReferenceImages: in.ReferenceImages,
AccountID: in.AccountID,
ChannelID: in.ChannelID,
})
}
}
File diff suppressed because it is too large Load Diff
+83
View File
@@ -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
}
+639
View File
@@ -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
}
}
+138
View File
@@ -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
}
+2
View File
@@ -82,6 +82,8 @@ services:
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}
COOKIE_SECURE: "false"
# 渠道上游 API Key 的 AES-GCM 加密密钥——务必配置随机值;丢失/变更后已存的渠道密钥无法解密
CHANNEL_SECRET: ${CHANNEL_SECRET:-change-me-channel-secret}
volumes:
- generated:/app/data/generated
depends_on:
+2
View File
@@ -72,6 +72,8 @@ services:
RUSTFS_SECRET_KEY: vividai-secret-change-me
CORS_ORIGINS: http://localhost:2000
COOKIE_SECURE: "false"
# 渠道上游 API Key 的 AES-GCM 加密密钥——生产环境务必改成随机值并保持不变
CHANNEL_SECRET: change-me-channel-secret
volumes:
- generated:/app/data/generated
depends_on:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+8 -24
View File
@@ -1,7 +1,8 @@
# ai-gateway frontend
# image2api frontend
Vue 3 + Vite admin console for the ai-gateway backend. This replaces the old
single-file `static/admin.html`.
Vue 3 + Vite frontend for `image2api`: user-facing generation UI (playground,
gallery, docs, billing) plus the admin console (channels, models, users,
orders, logs, config).
## Develop
@@ -10,27 +11,10 @@ npm install
npm run dev # http://localhost:5173
```
The dev server proxies `/admin`, `/health`, `/generated`, `/v1` to the backend.
Start the backend separately:
The dev server proxies `/admin`, `/health`, `/images`, `/v1` to the backend.
Start the Go backend separately (see `backend/README.md`).
```bash
# from repo root
python app.py # http://0.0.0.0:6060
# from ../backend
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.
+12 -12
View File
@@ -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>
<linearGradient id="ring" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#e879f9"/><stop offset=".5" stop-color="#8b5cf6"/><stop offset="1" stop-color="#38bdf8"/>
<linearGradient id="bg" x1="8" y1="4" x2="40" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#3f3f46"/>
<stop offset="1" stop-color="#18181b"/>
</linearGradient>
<linearGradient id="v" x1="14" y1="14" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stop-color="#f0abfc"/><stop offset="1" stop-color="#8b5cf6"/>
<linearGradient id="shine" x1="24" y1="3" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0.14"/>
<stop offset="0.9" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
</defs>
<g transform="translate(24 24)">
<circle r="18.5" fill="none" stroke="url(#ring)" stroke-width="3.6" stroke-linecap="round"
stroke-dasharray="86 30" transform="rotate(-58)"/>
<path d="M-8 -7.5 L0 9 L8 -7.5" fill="none" stroke="url(#v)" stroke-width="5.2"
stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="13" cy="-13" r="3.6" fill="#38bdf8"/>
</g>
<rect x="2" y="2" width="44" height="44" rx="15" fill="url(#bg)"/>
<rect x="2" y="2" width="44" height="22" rx="15" fill="url(#shine)"/>
<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="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"/>
<circle cx="36" cy="36" r="2.6" fill="#fb923c"/>
</svg>

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

@@ -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>
@@ -17,7 +17,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
<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">
<span class="w-7 h-7 rounded-lg text-white grid place-items-center"
style="background: linear-gradient(135deg, #7c5cfc, #22d3ee); box-shadow: 0 4px 12px var(--accent-glow)">
style="background: linear-gradient(135deg, #18181b, #52525b); box-shadow: 0 4px 12px var(--accent-glow)">
<Icon name="spark" class="w-4 h-4" />
</span>
<h2 class="text-base font-extrabold">公告</h2>
@@ -30,7 +30,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
<div class="px-6 py-4 border-t border-t-[color:var(--ink-soft)] flex justify-end shrink-0">
<button @click="dismissAnnouncement"
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, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">
我知道了
</button>
</div>
@@ -55,7 +55,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
.ann-body :deep(li) { margin: 0.2em 0; }
.ann-body :deep(a) { color: var(--m-orange); text-decoration: underline; }
.ann-body :deep(strong) { font-weight: 700; color: var(--fg); }
.ann-body :deep(code) { background: rgb(124 92 252 / 0.12); 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: #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(blockquote) { border-left: 3px solid var(--m-orange); padding-left: 0.9em; color: var(--fg-3); margin: 0.6em 0; }
@@ -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-sky-500/15 text-sky-600 ring-sky-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>
+13 -1
View File
@@ -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"/>',
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"/>',
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>
<template>
<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] || ''" />
</template>
-166
View File
@@ -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-orange-700 bg-orange-50 ring-1 ring-orange-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-rose-700 bg-rose-50 ring-1 ring-rose-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>
+11 -16
View File
@@ -161,12 +161,9 @@ async function submit() {
<template>
<transition name="modal">
<div v-if="auth.loginOpen" class="fixed inset-0 z-50 grid place-items-center px-4">
<div class="absolute inset-0 bg-black/70 backdrop-blur-md" @click="closeLogin"></div>
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="closeLogin"></div>
<div class="card relative z-10 w-full max-w-[26rem]">
<!-- ambient aurora washes -->
<div class="pointer-events-none absolute -top-24 -right-16 h-56 w-56 rounded-full bg-[#8b5cf6]/30 blur-3xl"></div>
<div class="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-[#22d3ee]/25 blur-3xl"></div>
<div class="relative p-7">
<button class="absolute top-4 right-4 grid h-7 w-7 place-items-center rounded-lg text-[color:var(--fg-3)] hover:text-[color:var(--fg)] hover:bg-[var(--hover)] transition-colors"
@@ -263,11 +260,9 @@ async function submit() {
<style scoped>
.card {
border-radius: 1.2rem;
background: var(--menu-bg);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
background: #ffffff;
color: var(--fg);
border: 1px solid var(--glass-border);
border: 1px solid rgb(24 24 27 / 0.08);
box-shadow: var(--glass-shadow-lg);
overflow: hidden;
}
@@ -277,7 +272,7 @@ html.dark .card {
.logo-halo {
position: relative;
filter: drop-shadow(0 6px 14px rgb(124 92 252 / 0.4));
filter: drop-shadow(0 6px 14px rgb(24 24 27 / 0.4));
}
/* segmented tab control */
@@ -296,7 +291,7 @@ html.dark .card {
.tab:hover { color: var(--fg); }
.tab-on {
color: #fff;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
background: linear-gradient(120deg, #18181b, #27272a);
border-color: transparent;
box-shadow: 0 4px 14px var(--accent-glow);
}
@@ -317,8 +312,8 @@ html.dark .card {
}
html.dark .fld { background: rgb(255 255 255 / 0.05); }
.fld:focus {
border-color: rgb(124 92 252 / 0.65);
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
border-color: rgb(24 24 27 / 0.65);
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
}
.fld::placeholder { color: var(--fg-faint); }
/* Keep autofill on-theme in dark (otherwise it paints white/yellow). */
@@ -345,11 +340,11 @@ html.dark .fld:-webkit-autofill:focus {
display: inline-flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 800; white-space: nowrap;
color: var(--fg);
background: var(--hover); border: 1px solid rgb(124 92 252 / 0.4);
box-shadow: 0 2px 10px rgb(124 92 252 / 0.14);
background: var(--hover); border: 1px solid rgb(24 24 27 / 0.4);
box-shadow: 0 2px 10px rgb(24 24 27 / 0.14);
transition: background 0.15s, box-shadow 0.15s, transform 0.15s, opacity 0.15s;
}
.code-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px rgb(124 92 252 / 0.22); }
.code-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px rgb(24 24 27 / 0.22); }
.code-btn:active:not(:disabled) { transform: scale(0.96) translateY(1px); box-shadow: none; }
/* Disabled (sending / countdown): just dim — keep the theme text color so it
never washes out to invisible white on the light card. */
@@ -361,7 +356,7 @@ html.dark .fld:-webkit-autofill:focus {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.72rem 0; border-radius: 0.65rem; font-size: 0.9rem; font-weight: 800;
letter-spacing: 0.02em; color: white; margin-top: 0.35rem;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
border: 1px solid rgb(255 255 255 / 0.16);
box-shadow: 0 8px 22px var(--accent-glow);
transition: transform 0.13s, box-shadow 0.13s, filter 0.15s;
+24 -19
View File
@@ -1,8 +1,7 @@
<script setup>
// Vivid mark: an open spectrum ring (lens/aperture) around a play triangle,
// with a shutter dot closing the gap — image + video in one glyph.
// Transparent background so it reads on both the dark and light shells.
// Colors follow the aurora palette (violet / pink / cyan).
// Default mark: an ink squircle holding a soft white sparkle (the "idea"),
// a small companion star and a warm dot — cute, and on-theme with the
// ink-black / warm-paper palette used across the site.
// Each instance gets unique gradient ids (useId) so multiple logos on one
// page don't share/clobber defs.
import { useId } from 'vue'
@@ -10,29 +9,35 @@ import { useId } from 'vue'
defineProps({ size: { type: [Number, String], default: 40 } })
const uid = useId()
const gRing = `vring-${uid}`
const gBg = `vbg-${uid}`
const gShine = `vshine-${uid}`
</script>
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none"
xmlns="http://www.w3.org/2000/svg" class="shrink-0">
<defs>
<linearGradient :id="gRing" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#7c5cfc" />
<stop offset="0.5" stop-color="#e879f9" />
<stop offset="1" stop-color="#22d3ee" />
<linearGradient :id="gBg" x1="8" y1="4" x2="40" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#3f3f46" />
<stop offset="1" stop-color="#18181b" />
</linearGradient>
<linearGradient :id="gShine" x1="24" y1="3" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0.14" />
<stop offset="0.9" stop-color="#fff" stop-opacity="0" />
</linearGradient>
</defs>
<g transform="translate(24 24)">
<!-- open ring: dash gap leaves room for the shutter dot -->
<circle r="17.5" fill="none" :stroke="`url(#${gRing})`" stroke-width="4.4"
stroke-linecap="round" stroke-dasharray="84 32" transform="rotate(-58)" />
<!-- play triangle = image + video -->
<path d="M-3 -6.5 L8.5 0 L-3 6.5 Z" fill="#7c5cfc" stroke="#111" stroke-width="1.4"
stroke-linejoin="round" />
<!-- shutter dot -->
<circle cx="13" cy="-13" r="4.2" fill="#22d3ee" stroke="#111" stroke-width="1.6" />
</g>
<!-- squircle tile -->
<rect x="2" y="2" width="44" height="44" rx="15" :fill="`url(#${gBg})`" />
<rect x="2" y="2" width="44" height="22" rx="15" :fill="`url(#${gShine})`" />
<!-- big sparkle -->
<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" />
<!-- small companion star -->
<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" />
<!-- warm dot -->
<circle cx="36" cy="36" r="2.6" fill="#fb923c" />
</svg>
</template>
+61 -39
View File
@@ -15,6 +15,14 @@ const isEdit = computed(() => !!props.model)
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
// provider=custom 的模型没有目录约束,档位由价格表本身决定——编辑时给出
// 完整候选面板(与 CustomModelModal 的 VID_RES/IMG_RES/DUR_OPTS 保持一致),
// 留空的档位 = 不支持。已存的自定义档(如 "540p"/"12s")也会并入显示。
const CUSTOM_IMG_RES = ['1K', '2K', '4K']
const CUSTOM_VID_RES = ['720p', '768p', '1080p', '2K', '4K']
const CUSTOM_DUR = ['5s', '6s', '8s', '10s', '15s']
const bySeconds = (a, b) => parseFloat(a) - parseFloat(b)
const catalog = ref([])
const loading = ref(true)
const selectedId = ref(props.model?.id || '')
@@ -37,15 +45,28 @@ const entry = computed(() => {
return catalog.value.find((e) => e.id === selectedId.value) || null
})
const isVideo = computed(() => entry.value?.type === 'video')
const isCustom = computed(() => entry.value?.provider === 'custom')
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
// regardless of how the catalog/stored record happens to list them.
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
// regardless of how the catalog/stored record happens to list them. For
// custom models the stored list is just the currently-priced subset — merge
// in the full palette so the admin can light up a new tier (e.g. 768p) by
// typing a price, or retire one by blanking it.
const resolutions = computed(() => {
const stored = entry.value?.resolutions || []
if (!isCustom.value) return sortResolutions(stored)
const palette = isVideo.value ? CUSTOM_VID_RES : CUSTOM_IMG_RES
return sortResolutions([...new Set([...palette, ...stored])])
})
// Read-only summary shows what's actually supported (stored list), not the
// offerable palette — for custom models the palette is just the input grid.
const storedResolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。渠道导入的
// duration range 模型(duration_min/max,无固定档)也只能走 per_second。
const isPerSecond = computed(() => {
const e = entry.value
if (!e) return false
return !!e.per_second || e.duration_prices?.per_second != null
return !!e.per_second || e.duration_prices?.per_second != null || (e.type === 'video' && (e.duration_max || 0) > 0 && !(e.durations || []).length)
})
const durationTiers = computed(() => {
@@ -53,7 +74,9 @@ const durationTiers = computed(() => {
if (!e) return []
if (isPerSecond.value) return ['per_second']
const ds = e.durations || []
return ds.length ? ds : Object.keys(e.duration_prices || {})
const stored = ds.length ? ds : Object.keys(e.duration_prices || {})
if (e.provider !== 'custom') return stored
return [...new Set([...stored, ...CUSTOM_DUR])].sort(bySeconds)
})
// Dropdown options: catalog rows not already in the managed store.
@@ -71,14 +94,9 @@ function resetPrices(e) {
if (!e) return
// Both image and video price per resolution; video ALSO prices per duration
// (real video price = resolution price + duration price).
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
for (const r of resolutions.value) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
if (e.type === 'video') {
if (isPerSecond.value) {
videoPrices.value['per_second'] = ''
videoPricesAgent.value['per_second'] = ''
} else {
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
}
for (const d of durationTiers.value) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
}
}
@@ -94,23 +112,19 @@ onMounted(async () => {
const m = props.model
// Resolution prices apply to both; video additionally has duration prices.
// Agent prices are an optional overlay (blank = follows the normal price).
for (const r of (m.resolutions || [])) {
// Iterate the DISPLAYED tier lists (for custom models that's the full
// palette ∪ stored), so unpriced-but-offerable tiers get blank inputs.
for (const r of resolutions.value) {
imagePrices.value[r] = m.prices?.[r] ?? ''
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
}
if (m.type === 'video') {
if (m.duration_prices?.per_second != null) {
videoPrices.value['per_second'] = m.duration_prices.per_second
videoPricesAgent.value['per_second'] = m.duration_prices_agent?.per_second ?? ''
} else {
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
for (const d of durs) {
for (const d of durationTiers.value) {
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
}
}
}
}
})
async function save() {
@@ -132,9 +146,9 @@ async function save() {
let payload
if (e.type === 'video') {
const prices = collect(imagePrices.value, e.resolutions || [])
const prices = collect(imagePrices.value, resolutions.value)
const duration_prices = collect(videoPrices.value, durationTiers.value)
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
const prices_agent = collect(imagePricesAgent.value, resolutions.value)
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
@@ -142,20 +156,27 @@ async function save() {
type: 'video',
provider: e.provider,
ratios: e.ratios || [],
resolutions: e.resolutions || [],
// catalog models keep their declared tier list; custom models' supported
// tiers ARE the priced keys — a blanked price retires the tier.
resolutions: isCustom.value ? sortResolutions(Object.keys(prices)) : (e.resolutions || []),
prices,
durations: isPerSecond.value ? (e.durations || []) : Object.keys(duration_prices),
durations: isPerSecond.value ? (e.durations || [])
: (isCustom.value ? Object.keys(duration_prices).sort(bySeconds) : Object.keys(duration_prices)),
duration_prices,
prices_agent,
duration_prices_agent,
alias: alias.value.trim(),
max_reference_images: e.max_reference_images || 0,
max_reference_videos: e.max_reference_videos || 0,
max_reference_audios: e.max_reference_audios || 0,
max_ref_video_seconds: e.max_ref_video_seconds || 0,
max_ref_audio_seconds: e.max_ref_audio_seconds || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
} else {
const prices = collect(imagePrices.value, e.resolutions || [])
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
const prices = collect(imagePrices.value, resolutions.value)
const prices_agent = collect(imagePricesAgent.value, resolutions.value)
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
payload = {
type: 'image',
@@ -204,8 +225,8 @@ async function save() {
<SelectMenu v-if="!isEdit" v-model="selectedId" :options="addOptions"
placeholder="选择一个支持的模型" />
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
所有支持的模型都已添加。
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-600 mt-1.5">
模型经「渠道管理 → 拉取上游模型」导入。
</p>
</div>
@@ -219,12 +240,12 @@ async function save() {
<div v-if="entry" class="rounded-xl bg-white/[0.03] ring-1 ring-white/[0.06] p-3.5 space-y-2.5">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="isVideo ? 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
: 'bg-sky-500/10 text-sky-300 ring-sky-400/30'">
:class="isVideo ? 'bg-rose-500/10 text-rose-600 ring-rose-500/25'
: 'bg-sky-500/10 text-sky-600 ring-sky-500/25'">
{{ isVideo ? '生视频' : '生图' }}
</span>
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
<span class="ml-auto text-[10px] text-white/30">参数自动加载,不可改</span>
<span class="ml-auto text-[10px] text-white/30">{{ isCustom ? '档位由价格表决定 · 留空=不支持' : '参数自动加载,不可改' }}</span>
</div>
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
@@ -237,13 +258,14 @@ async function save() {
<template v-if="isVideo">
<span class="text-white/40">分辨率</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30">—</span>
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
</div>
<span class="text-white/40">时长</span>
<div class="flex flex-wrap gap-1">
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
<span v-if="!(entry.durations || []).length" class="text-white/30">—</span>
<span v-if="!(entry.durations || []).length && (entry.duration_max || 0) > 0" class="ro-chip">{{ entry.duration_min }}–{{ entry.duration_max }}s(区间)</span>
<span v-if="!(entry.durations || []).length && !(entry.duration_max || 0)" class="text-white/30">—</span>
</div>
<span class="text-white/40">参考图</span>
<div class="text-white/70">
@@ -256,8 +278,8 @@ async function save() {
<template v-else>
<span class="text-white/40">画质</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30">—</span>
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
</div>
<span class="text-white/40">参考图</span>
<div class="text-white/70">{{ entry.max_reference_images > 0 ? `${entry.max_reference_images} 张` : (entry.image_to_image ? '支持' : '不支持') }}</div>
@@ -284,7 +306,7 @@ async function save() {
</div>
<div class="relative flex-1">
<input v-model="imagePricesAgent[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-600 text-[10px]">代理</span>
</div>
</div>
<p v-if="!resolutions.length" class="text-xs text-white/35">该模型未声明分辨率档位</p>
@@ -307,7 +329,7 @@ async function save() {
</div>
<div class="relative flex-1">
<input v-model="videoPricesAgent[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-600 text-[10px]">代理</span>
</div>
</div>
</div>
@@ -325,7 +347,7 @@ async function save() {
</div>
</template>
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
<div class="flex justify-end gap-2 pt-1">
<button @click="emit('close')" class="btn-soft">取消</button>
+2 -2
View File
@@ -129,7 +129,7 @@ onUnmounted(clearTimers)
<p class="text-sm text-[color:var(--fg-3)] mt-1">二维码已失效,订单已取消</p>
<p class="text-xs text-[color:var(--fg-faint)] mt-1">如需充值请重新下单</p>
<button @click="finish" class="mt-5 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, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
</div>
<!-- pending -->
@@ -147,7 +147,7 @@ onUnmounted(clearTimers)
<!-- jump (no qrcode): the cashier opened in a new tab — just monitor -->
<template v-else>
<div class="flex flex-col items-center justify-center py-7 mb-2">
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#7c5cfc] animate-spin mb-3"></div>
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#18181b] animate-spin mb-3"></div>
<p class="text-sm font-medium text-[color:var(--fg)]">支付监控中…</p>
<p class="text-xs text-[color:var(--fg-faint)] mt-1">已打开支付页面,完成后自动到账</p>
</div>
+7 -6
View File
@@ -14,6 +14,7 @@ const props = defineProps({
mono: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
up: { type: Boolean, default: false }, // 面板向上展开(底部居中的输入框用)
compact: { type: Boolean, default: false }, // 工具栏用的紧凑尺寸
})
const emit = defineEmits(['update:modelValue'])
@@ -74,8 +75,8 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
<button type="button" @click="toggle" @keydown="onKeydown"
:aria-expanded="open"
:disabled="disabled"
class="field flex items-center justify-between gap-2 text-left h-10 !py-0 disabled:opacity-50 disabled:cursor-not-allowed"
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
class="field flex items-center justify-between gap-2 text-left !py-0 disabled:opacity-50 disabled:cursor-not-allowed"
:class="[compact ? 'h-9 !text-[13px] font-semibold min-w-[7.5rem]' : 'h-10', mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
<span class="truncate">{{ label }}</span>
<Icon name="chevron"
class="w-4 h-4 shrink-0 text-[color:var(--fg-3)] transition-transform duration-200"
@@ -91,18 +92,18 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1">
<div v-if="open" ref="panel"
class="absolute z-30 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--glass-border)]
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-[var(--glass-shadow-lg)]"
class="absolute z-30 w-full min-w-[9rem] max-h-64 overflow-auto rounded-xl border border-[rgb(24_24_27/0.08)]
bg-white p-1.5 shadow-[0_2px_6px_rgb(24_24_27/0.05),0_20px_40px_-12px_rgb(24_24_27/0.22)]"
:class="up ? 'bottom-full mb-2' : 'mt-2'">
<button v-for="(o, i) in options" :key="o.value" type="button"
:data-idx="i" @click="pick(o)" @mouseenter="active = i"
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors"
:class="[
mono ? 'font-mono' : '',
i === active ? 'bg-[var(--m-yellow)] text-[#111]' : 'text-[color:var(--fg-2)]',
i === active ? 'bg-[#f4f4f5] text-[#18181b]' : 'text-[color:var(--fg-2)]',
]">
<span class="truncate">{{ o.label }}</span>
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-[var(--m-orange)]" />
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-[#18181b]" />
</button>
<div v-if="!options.length" class="px-3 py-2 text-xs text-[color:var(--fg-3)]">无选项</div>
</div>

Some files were not shown because too many files have changed in this diff Show More