画图台(并发出图):
- 不再锁定 UI:点「生成」开独立任务,可连续多次并发
- 结果网格一行5个、最多10张,进行中/成功/失败状态回显,刷新保留进行中
- 生图张数 1/2/3/4,各自独立计费出卡
- 点图=参考图(单张替换/多张替换末位);首尾帧模型点视频=抓末帧设为首帧,否则放大
- /logs 新增 statuses=pending,success 服务端过滤(status IN 专用 SQL)
品牌定制(设置→网站):
- 自定义 Logo 图片 + 子标题(公开页头部 + 管理侧栏)
- 邮件验证码标题改用站点名:{title} 邮箱验证码
提示词复制:
- 去掉复制按钮,点提示词文字即复制(预览/后台日志/图片管理/画图记录),统一弹「指令已复制」
- 新增 utils/clipboard.js:execCommand 回退,非安全上下文(http/IP)也能复制
用户管理:列表加「备注」列,新建/编辑可填改备注(默认空)
provider 修复:
- grok 401 正确判死封号(markTokenFailure 漏了 grok 池)
- grok 视频支持 15s
- custom 上游报错去敏感(抹掉上游 URL/IP,改英文短描述)
- custom 去掉额度耗尽锁定:429/欠费当临时错误,账号保持 active
UI/其它:
- 展示位弹窗浅色主题适配(tab 选中高亮、输入框边框)— 主题变量 + 中心补丁
- 自定义模型:时长可填任意秒数 + 15s 预设
- 首页设置/卡密弹窗去固定高度与滚动条
- 顶部菜单「记录」→「图片」
- 下线 Flow provider(代码移除)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
9.9 KiB
Go
328 lines
9.9 KiB
Go
// 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/base64"
|
|
"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: 10 * 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) ([]byte, 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)
|
|
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}
|
|
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 imageBytesFromResponse(ctx, body)
|
|
}
|
|
|
|
// GenerateVideo drives the upstream Sora-style async video API:
|
|
// POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content.
|
|
// 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, downloadResult bool) ([]byte, string, error) {
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if baseURL == "" || apiKey == "" {
|
|
return nil, "", ErrAuth
|
|
}
|
|
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) 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).
|
|
func imageBytesFromResponse(ctx context.Context, body []byte) ([]byte, error) {
|
|
var out struct {
|
|
Data []struct {
|
|
B64JSON string `json:"b64_json"`
|
|
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))
|
|
}
|
|
d := out.Data[0]
|
|
if d.B64JSON != "" {
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(d.B64JSON))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: bad b64: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
if d.URL != "" {
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil)
|
|
resp, err := httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
|
}
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
return nil, fmt.Errorf("%w: image response had no b64/url", ErrTemporaryUpstream)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|