Files
infinite-canvas/handler/ai.go
T
2026-06-01 23:43:53 +08:00

400 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"strings"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/service"
)
func AIImagesGenerations(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/images/generations")
}
func AIImagesEdits(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/images/edits")
}
func AIChatCompletions(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/chat/completions")
}
func AIVideos(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/videos")
}
func AIVideo(w http.ResponseWriter, r *http.Request, id string) {
proxyAIGetRequest(w, r, "/videos/"+id)
}
func AIVideoContent(w http.ResponseWriter, r *http.Request, id string) {
proxyAIGetRequest(w, r, "/videos/"+id+"/content")
}
func proxyAIGetRequest(w http.ResponseWriter, r *http.Request, path string) {
modelName := r.URL.Query().Get("model")
if strings.TrimSpace(modelName) == "" {
modelName = "grok-imagine-video"
}
channel, err := service.SelectModelChannel(modelName)
if err != nil {
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
Fail(w, "AI 接口请求失败")
return
}
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
request, err := http.NewRequest(http.MethodGet, service.BuildModelChannelURL(channel, path), nil)
if err != nil {
Fail(w, "AI 接口请求失败")
return
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
copyAIResponse(w, request, nil)
}
func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
body, contentType, modelName, err := readAIRequest(r)
if err != nil {
log.Printf("AI proxy request read failed: %v", err)
Fail(w, "AI 接口请求失败")
return
}
user, ok := service.UserFromContext(r.Context())
if !ok {
Fail(w, "未登录或权限不足")
return
}
credits, err := service.ModelCost(modelName)
if err != nil {
log.Printf("AI proxy read model cost failed: model=%s err=%v", modelName, err)
Fail(w, "AI 接口请求失败")
return
}
credits *= readAIRequestCount(body, contentType)
channel, err := service.SelectModelChannel(modelName)
if err != nil {
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
Fail(w, "AI 接口请求失败")
return
}
body, contentType, err = adaptAIRequestForChannel(body, contentType, path, channel)
if err != nil {
log.Printf("AI proxy adapt request failed: model=%s path=%s err=%v", modelName, path, err)
Fail(w, "AI 接口请求失败")
return
}
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
if err != nil {
log.Printf("AI proxy build request failed: url=%s err=%v", service.BuildModelChannelURL(channel, path), err)
Fail(w, "AI 接口请求失败")
return
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
if err := service.ConsumeUserCredits(user.ID, modelName, credits, path); err != nil {
FailError(w, err)
return
}
copyAIResponse(w, request, func() {
if err := service.RefundUserCredits(user.ID, modelName, credits, path); err != nil {
log.Printf("AI proxy refund credits failed: user=%s model=%s credits=%d err=%v", user.ID, modelName, credits, err)
}
})
}
func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func()) {
response, err := http.DefaultClient.Do(request)
if err != nil {
log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err)
if onFailure != nil {
onFailure()
}
Fail(w, "AI 接口请求失败")
return
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
log.Printf("AI upstream error: url=%s status=%d", request.URL.String(), response.StatusCode)
if onFailure != nil {
onFailure()
}
Fail(w, aiUpstreamStatusMessage(response.StatusCode, body))
return
}
for key, values := range response.Header {
if strings.EqualFold(key, "Content-Length") {
continue
}
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}
func adaptAIRequestForChannel(body []byte, contentType string, path string, channel model.ModelChannel) ([]byte, string, error) {
if path != "/images/generations" && path != "/images/edits" {
return body, contentType, nil
}
format := strings.ToLower(strings.TrimSpace(channel.RequestOptions.ImageResponseFormat))
if format == "" {
if strings.EqualFold(strings.TrimSpace(channel.Compatibility), "sub2api") {
format = "url"
} else {
format = "b64_json"
}
}
if format != "url" && format != "b64_json" {
return body, contentType, nil
}
if strings.HasPrefix(contentType, "multipart/form-data") {
return setMultipartField(body, contentType, "response_format", format)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, "", err
}
payload["response_format"] = format
nextBody, err := json.Marshal(payload)
if err != nil {
return nil, "", err
}
return nextBody, contentType, nil
}
func setMultipartField(body []byte, contentType string, fieldName string, value string) ([]byte, string, error) {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", err
}
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
written := false
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
if part.FormName() == fieldName && part.FileName() == "" {
if err := writer.WriteField(fieldName, value); err != nil {
_ = part.Close()
return nil, "", err
}
written = true
_ = part.Close()
continue
}
header := make(textproto.MIMEHeader, len(part.Header))
for key, values := range part.Header {
copied := make([]string, len(values))
copy(copied, values)
header[key] = copied
}
target, err := writer.CreatePart(header)
if err != nil {
_ = part.Close()
return nil, "", err
}
if _, err := io.Copy(target, part); err != nil {
_ = part.Close()
return nil, "", err
}
_ = part.Close()
}
if !written {
if err := writer.WriteField(fieldName, value); err != nil {
return nil, "", err
}
}
if err := writer.Close(); err != nil {
return nil, "", err
}
return buffer.Bytes(), writer.FormDataContentType(), nil
}
func readAIRequest(r *http.Request) ([]byte, string, string, error) {
contentType := r.Header.Get("Content-Type")
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, "", "", err
}
modelName := ""
if strings.HasPrefix(contentType, "multipart/form-data") {
modelName = readMultipartModel(body, contentType)
} else {
var payload struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &payload)
modelName = payload.Model
}
if strings.TrimSpace(modelName) == "" {
return nil, "", "", errMissingModel
}
return body, contentType, modelName, nil
}
func readMultipartModel(body []byte, contentType string) string {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return ""
}
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
form, err := reader.ReadForm(32 << 20)
if err != nil {
return ""
}
defer form.RemoveAll()
if values := form.Value["model"]; len(values) > 0 {
return values[0]
}
return ""
}
func readAIRequestCount(body []byte, contentType string) int {
count := 1
if strings.HasPrefix(contentType, "multipart/form-data") {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return count
}
form, err := multipart.NewReader(bytes.NewReader(body), params["boundary"]).ReadForm(32 << 20)
if err != nil {
return count
}
defer form.RemoveAll()
if values := form.Value["n"]; len(values) > 0 {
_, _ = fmt.Sscan(values[0], &count)
}
} else {
var payload struct {
N int `json:"n"`
}
_ = json.Unmarshal(body, &payload)
count = payload.N
}
if count < 1 {
return 1
}
return count
}
var errMissingModel = &aiError{"缺少模型名称"}
func resolveAIProxyPath(baseURL string, modelName string, path string) string {
if !isArkSeedanceVideo(baseURL, modelName) {
return path
}
if path == "/videos" {
return "/contents/generations/tasks"
}
if strings.HasPrefix(path, "/videos/") && !strings.HasSuffix(path, "/content") {
return "/contents/generations/tasks/" + strings.TrimPrefix(path, "/videos/")
}
return path
}
func isArkSeedanceVideo(baseURL string, modelName string) bool {
base := strings.ToLower(baseURL)
model := strings.ToLower(modelName)
return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || strings.Contains(base, "/api/plan/v3")
}
func aiStatusMessage(statusCode int) string {
switch statusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return "AI 接口鉴权失败,请检查 API Key、套餐权限或模型权限"
case http.StatusTooManyRequests:
return "AI 接口限流或额度不足,请稍后重试或检查额度"
default:
return "AI 接口请求失败"
}
}
func aiUpstreamStatusMessage(statusCode int, body []byte) string {
base := aiStatusMessage(statusCode)
detail := aiUpstreamErrorDetail(body)
if detail == "" {
return base
}
return base + "" + detail
}
func aiUpstreamErrorDetail(body []byte) string {
text := strings.TrimSpace(string(body))
if text == "" {
return ""
}
var payload struct {
Msg string `json:"msg"`
Message string `json:"message"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &payload); err == nil {
if payload.Error.Message != "" {
if detail := friendlyUpstreamError(payload.Error.Code, payload.Error.Message); detail != "" {
return safeUpstreamText(detail)
}
if payload.Error.Code != "" {
return safeUpstreamText(payload.Error.Code + " " + payload.Error.Message)
}
return safeUpstreamText(payload.Error.Message)
}
if payload.Msg != "" {
return safeUpstreamText(payload.Msg)
}
if payload.Message != "" {
return safeUpstreamText(payload.Message)
}
}
return safeUpstreamText(text)
}
func friendlyUpstreamError(code string, message string) string {
lowerCode := strings.ToLower(strings.TrimSpace(code))
if strings.Contains(lowerCode, "inputvideosensitivecontentdetected") || strings.Contains(lowerCode, "privacyinformation") {
return strings.TrimSpace(code + " 参考视频疑似包含真人或隐私信息,火山方舟拒绝使用普通 URL 作为真人视频参考;请改用不含真人的视频、官方允许的模型产物,或已授权的 asset:// 素材。原始错误:" + message)
}
return ""
}
func safeUpstreamText(text string) string {
text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
runes := []rune(text)
if len(runes) > 300 {
return string(runes[:300]) + "..."
}
return text
}
type aiError struct {
message string
}
func (err *aiError) Error() string {
return err.message
}