mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
156 lines
5.6 KiB
Go
156 lines
5.6 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const (
|
|
SeedanceEndpointCreate GrokMediaEndpoint = "seedance_create"
|
|
SeedanceEndpointStatus GrokMediaEndpoint = "seedance_status"
|
|
SeedanceEndpointDelete GrokMediaEndpoint = "seedance_delete"
|
|
OpenAIEndpointCapabilitySeedance OpenAIEndpointCapability = "seedance"
|
|
)
|
|
|
|
func (e GrokMediaEndpoint) IsSeedance() bool {
|
|
return e == SeedanceEndpointCreate || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete
|
|
}
|
|
|
|
// SeedanceTaskKey isolates ownership and billing keys from other video providers.
|
|
func SeedanceTaskKey(id string) string { return "seedance:" + strings.TrimSpace(id) }
|
|
|
|
func ParseSeedanceRequest(body []byte) (GrokMediaRequestInfo, error) {
|
|
var info GrokMediaRequestInfo
|
|
if !gjson.ValidBytes(body) || !gjson.ParseBytes(body).IsObject() {
|
|
return info, fmt.Errorf("request body must be a JSON object")
|
|
}
|
|
model := gjson.GetBytes(body, "model")
|
|
if model.Type != gjson.String || strings.TrimSpace(model.String()) == "" {
|
|
return info, fmt.Errorf("model is required")
|
|
}
|
|
content := gjson.GetBytes(body, "content")
|
|
if !content.IsArray() || len(content.Array()) == 0 {
|
|
return info, fmt.Errorf("content must be a non-empty array")
|
|
}
|
|
info.Model = strings.TrimSpace(model.String())
|
|
var texts []string
|
|
for _, item := range content.Array() {
|
|
switch item.Get("type").String() {
|
|
case "text":
|
|
texts = append(texts, item.Get("text").String())
|
|
case "image_url":
|
|
info.InputImageURLs = append(info.InputImageURLs, item.Get("image_url.url").String())
|
|
}
|
|
}
|
|
info.Prompt = strings.Join(texts, "\n")
|
|
return info, nil
|
|
}
|
|
|
|
func buildSeedanceURL(base string, endpoint GrokMediaEndpoint, taskID string) (string, error) {
|
|
base = strings.TrimRight(base, "/")
|
|
// Accept an origin, a proxy prefix, or the full Ark API base.
|
|
if !strings.HasSuffix(base, "/api/v3") && !strings.HasSuffix(base, "/v3") {
|
|
base += "/api/v3"
|
|
}
|
|
base += "/contents/generations/tasks"
|
|
if endpoint != SeedanceEndpointCreate {
|
|
if err := validateUpstreamPathSegment("Seedance task ID", taskID); err != nil || strings.TrimSpace(taskID) == "" {
|
|
return "", fmt.Errorf("invalid Seedance task ID")
|
|
}
|
|
base += "/" + taskID
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// ForwardSeedance preserves the Ark protocol, including multimodal content and
|
|
// future fields. Only model is rewritten using the account's configured mapping.
|
|
func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Context, account *Account, endpoint GrokMediaEndpoint, taskID string, body []byte) (*OpenAIForwardResult, error) {
|
|
if !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance) || !endpoint.IsSeedance() {
|
|
return nil, fmt.Errorf("seedance requires an OpenAI API key account with a custom base URL")
|
|
}
|
|
base, err := s.validateUpstreamBaseURL(account.GetCredential("base_url"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
target, err := buildSeedanceURL(base, endpoint, strings.TrimPrefix(taskID, "seedance:"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
model, upstreamModel := "", ""
|
|
method := http.MethodGet
|
|
switch endpoint {
|
|
case SeedanceEndpointCreate:
|
|
info, parseErr := ParseSeedanceRequest(body)
|
|
if parseErr != nil {
|
|
return nil, parseErr
|
|
}
|
|
model = info.Model
|
|
upstreamModel = account.GetMappedModel(model)
|
|
body, err = sjson.SetBytes(body, "model", upstreamModel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
method = http.MethodPost
|
|
case SeedanceEndpointDelete:
|
|
method = http.MethodDelete
|
|
}
|
|
token := strings.TrimSpace(account.GetCredential("api_key"))
|
|
if token == "" {
|
|
return nil, fmt.Errorf("seedance account missing api_key")
|
|
}
|
|
started := time.Now()
|
|
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
account.ApplyHeaderOverrides(req.Header)
|
|
proxy := ""
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
proxy = account.Proxy.URL()
|
|
}
|
|
resp, err := s.httpUpstream.Do(req, proxy, account.ID, account.Concurrency)
|
|
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
responseBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Do not retry ambiguous asynchronous creates: the upstream may already have
|
|
// accepted a billable job. Preserve native error codes and response bodies.
|
|
if resp.StatusCode >= 300 {
|
|
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
|
|
return nil, fmt.Errorf("seedance upstream status %d", resp.StatusCode)
|
|
}
|
|
result := &OpenAIForwardResult{Model: model, BillingModel: model, UpstreamModel: upstreamModel, Duration: time.Since(started), ResponseHeaders: resp.Header.Clone()}
|
|
if endpoint == SeedanceEndpointCreate {
|
|
id := strings.TrimSpace(gjson.GetBytes(responseBody, "id").String())
|
|
if id == "" {
|
|
return nil, fmt.Errorf("seedance create response missing task ID")
|
|
}
|
|
result.ResponseID = SeedanceTaskKey(id)
|
|
}
|
|
if endpoint == SeedanceEndpointStatus {
|
|
result.ResponseID = taskID
|
|
result.UpstreamModel = gjson.GetBytes(responseBody, "model").String()
|
|
if gjson.GetBytes(responseBody, "status").String() == "succeeded" {
|
|
result.Usage.OutputTokens = max(0, int(gjson.GetBytes(responseBody, "usage.completion_tokens").Int()))
|
|
}
|
|
}
|
|
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
|
|
return result, nil
|
|
}
|