392 lines
12 KiB
Go
392 lines
12 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Volcengine struct {
|
|
config Config
|
|
client HTTPClient
|
|
now func() time.Time
|
|
}
|
|
|
|
const (
|
|
seedream46Model = "jimeng_seedream46_cvtob"
|
|
seedream46SubmitAction = "JimengSeedream46CVToBSubmitTask"
|
|
seedream46QueryAction = "JimengSeedream46CVToBGetResult"
|
|
seedream46Version = "2024-06-06"
|
|
visualLegacyVersion = "2022-08-31"
|
|
)
|
|
|
|
type volcengineProtocol struct {
|
|
submitAction string
|
|
queryAction string
|
|
version string
|
|
}
|
|
|
|
func NewVolcengine(c Config, client HTTPClient, now func() time.Time) *Volcengine {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
if c.Region == "" {
|
|
c.Region = "cn-north-1"
|
|
}
|
|
if c.Service == "" {
|
|
c.Service = "cv"
|
|
}
|
|
return &Volcengine{c, client, now}
|
|
}
|
|
func (v *Volcengine) Submit(ctx context.Context, r Request) (Result, error) {
|
|
model := requestModel(r, v.config.Model)
|
|
protocol := volcengineProtocolForModel(model)
|
|
p := map[string]any{"req_key": model, "prompt": r.Prompt}
|
|
if len(r.InputURLs) > 0 {
|
|
p["image_urls"] = r.InputURLs
|
|
}
|
|
for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "force_single"} {
|
|
if value, exists := r.Settings[key]; exists && value != nil && value != "" {
|
|
p[key] = value
|
|
}
|
|
}
|
|
return v.call(ctx, protocol.submitAction, protocol.version, p)
|
|
}
|
|
func (v *Volcengine) Query(ctx context.Context, id string) (Result, error) {
|
|
return v.QueryModel(ctx, id, v.config.Model)
|
|
}
|
|
func (v *Volcengine) QueryModel(ctx context.Context, id, model string) (Result, error) {
|
|
if strings.TrimSpace(id) == "" {
|
|
return Result{}, errors.New("provider task id is required")
|
|
}
|
|
resolvedModel := requestModel(Request{Model: model}, v.config.Model)
|
|
protocol := volcengineProtocolForModel(resolvedModel)
|
|
queryOptions, _ := json.Marshal(map[string]any{
|
|
"return_url": true,
|
|
"logo_info": map[string]any{"add_logo": false, "position": 0, "language": 0, "opacity": 1},
|
|
})
|
|
result, err := v.call(ctx, protocol.queryAction, protocol.version, map[string]any{"req_key": resolvedModel, "task_id": id, "req_json": string(queryOptions)})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if result.TaskID == "" {
|
|
result.TaskID = id
|
|
}
|
|
return result, nil
|
|
}
|
|
func (v *Volcengine) call(ctx context.Context, action, version string, payload any) (Result, error) {
|
|
startedAt := time.Now()
|
|
operation := volcengineOperation(action)
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "encode", ElapsedMS: elapsedMilliseconds(startedAt)})
|
|
return Result{}, &ProviderError{Operation: "volcengine request"}
|
|
}
|
|
endpoint, err := url.Parse(v.config.BaseURL)
|
|
if err != nil {
|
|
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)})
|
|
return Result{}, errors.New("invalid provider base URL")
|
|
}
|
|
q := endpoint.Query()
|
|
q.Set("Action", action)
|
|
q.Set("Version", version)
|
|
endpoint.RawQuery = canonicalQuery(q)
|
|
date := v.now().UTC()
|
|
xdate := date.Format("20060102T150405Z")
|
|
short := xdate[:8]
|
|
hash := sha(body)
|
|
headers := "content-type:application/json\nhost:" + endpoint.Host + "\nx-content-sha256:" + hash + "\nx-date:" + xdate + "\n"
|
|
signed := "content-type;host;x-content-sha256;x-date"
|
|
canonicalPath := endpoint.EscapedPath()
|
|
if canonicalPath == "" {
|
|
canonicalPath = "/"
|
|
}
|
|
canonical := "POST\n" + canonicalPath + "\n" + endpoint.RawQuery + "\n" + headers + "\n" + signed + "\n" + hash
|
|
scope := short + "/" + v.config.Region + "/" + v.config.Service + "/request"
|
|
stringToSign := "HMAC-SHA256\n" + xdate + "\n" + scope + "\n" + sha([]byte(canonical))
|
|
key := hmacBytes([]byte(v.config.SecretAccessKey), short)
|
|
key = hmacBytes(key, v.config.Region)
|
|
key = hmacBytes(key, v.config.Service)
|
|
key = hmacBytes(key, "request")
|
|
signature := hex.EncodeToString(hmacBytes(key, stringToSign))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(string(body)))
|
|
if err != nil {
|
|
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: "config", ElapsedMS: elapsedMilliseconds(startedAt)})
|
|
return Result{}, &ProviderError{Operation: "volcengine request"}
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Content-Sha256", hash)
|
|
req.Header.Set("X-Date", xdate)
|
|
req.Header.Set("Authorization", "HMAC-SHA256 Credential="+v.config.AccessKeyID+"/"+scope+", SignedHeaders="+signed+", Signature="+signature)
|
|
resp, err := v.client.Do(req)
|
|
if err != nil {
|
|
logVolcengineFailure(volcengineDiagnostic{Operation: operation, ErrorClass: classifyVolcengineTransportError(err), ElapsedMS: elapsedMilliseconds(startedAt)})
|
|
return Result{}, &ProviderError{Operation: "volcengine request"}
|
|
}
|
|
defer resp.Body.Close()
|
|
limit := v.config.MaxResponseBytes
|
|
if limit <= 0 {
|
|
limit = 2 << 20
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
|
if err != nil {
|
|
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_read", ElapsedMS: elapsedMilliseconds(startedAt)})
|
|
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
|
|
}
|
|
if int64(len(raw)) > limit {
|
|
logVolcengineFailure(volcengineDiagnostic{Operation: operation, Status: resp.StatusCode, ErrorClass: "response_too_large", ElapsedMS: elapsedMilliseconds(startedAt)})
|
|
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode}
|
|
}
|
|
validJSON := json.Valid(raw)
|
|
r := map[string]any{}
|
|
if validJSON {
|
|
r = record(raw)
|
|
}
|
|
response := inspectVolcengineResponse(r, resp.Header)
|
|
diagnostic := volcengineDiagnostic{
|
|
Operation: operation,
|
|
Status: resp.StatusCode,
|
|
Code: volcengineDiagnosticCode(response.code),
|
|
CodeN: volcengineDiagnosticNumericCode(response.codeN),
|
|
RequestID: safeVolcengineRequestID(response.requestID),
|
|
ElapsedMS: elapsedMilliseconds(startedAt),
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
diagnostic.ErrorClass = "service"
|
|
logVolcengineFailure(diagnostic)
|
|
return Result{}, &ProviderError{Operation: "volcengine request", Status: resp.StatusCode, Code: diagnostic.Code}
|
|
}
|
|
if !validJSON {
|
|
diagnostic.ErrorClass = "invalid_json"
|
|
logVolcengineFailure(diagnostic)
|
|
return Result{}, &ProviderError{Operation: "volcengine request"}
|
|
}
|
|
if response.code != nil && !volcengineRequestSucceeded(response.code) {
|
|
diagnostic.ErrorClass = "service"
|
|
logVolcengineFailure(diagnostic)
|
|
return Result{}, &ProviderError{Operation: "volcengine request", Code: diagnostic.Code}
|
|
}
|
|
d := object(first(response.business["data"], response.business["Data"]))
|
|
out := []string{}
|
|
for _, value := range []any{d["image_urls"], d["image_url"], d["url"], d["result_url"], d["output"], d["outputs"]} {
|
|
collectURLs(value, &out)
|
|
}
|
|
return Result{TaskID: stringValue(response.business["task_id"], response.business["TaskId"], d["task_id"], d["TaskId"]), Status: status(first(d["status"], d["Status"], response.business["status"], response.business["Status"])), OutputURLs: out, Raw: raw}, nil
|
|
}
|
|
|
|
type volcengineResponse struct {
|
|
business map[string]any
|
|
code any
|
|
codeN any
|
|
requestID any
|
|
}
|
|
|
|
func inspectVolcengineResponse(root map[string]any, headers http.Header) volcengineResponse {
|
|
business := root
|
|
if wrapped := object(first(root["Result"], root["result"])); len(wrapped) > 0 {
|
|
business = wrapped
|
|
}
|
|
metadata := object(first(root["ResponseMetadata"], root["response_metadata"]))
|
|
gatewayError := object(first(metadata["Error"], metadata["error"]))
|
|
return volcengineResponse{
|
|
business: business,
|
|
code: first(
|
|
business["code"], business["Code"],
|
|
root["code"], root["Code"],
|
|
gatewayError["Code"], gatewayError["code"],
|
|
),
|
|
codeN: first(
|
|
gatewayError["CodeN"], gatewayError["codeN"], gatewayError["code_n"],
|
|
business["code_n"], business["codeN"], business["CodeN"],
|
|
root["code_n"], root["codeN"], root["CodeN"],
|
|
),
|
|
requestID: first(
|
|
business["request_id"], business["requestId"], business["RequestId"], business["RequestID"],
|
|
root["request_id"], root["requestId"], root["RequestId"], root["RequestID"],
|
|
metadata["RequestId"], metadata["RequestID"], metadata["request_id"], metadata["requestId"],
|
|
headers.Get("X-Tt-Logid"), headers.Get("X-Request-Id"),
|
|
),
|
|
}
|
|
}
|
|
|
|
func volcengineProtocolForModel(model string) volcengineProtocol {
|
|
if strings.TrimSpace(model) == seedream46Model {
|
|
return volcengineProtocol{
|
|
submitAction: seedream46SubmitAction,
|
|
queryAction: seedream46QueryAction,
|
|
version: seedream46Version,
|
|
}
|
|
}
|
|
return volcengineProtocol{
|
|
submitAction: "CVSync2AsyncSubmitTask",
|
|
queryAction: "CVSync2AsyncGetResult",
|
|
version: visualLegacyVersion,
|
|
}
|
|
}
|
|
|
|
func volcengineRequestSucceeded(value any) bool {
|
|
return volcengineDiagnosticCode(value) == "10000"
|
|
}
|
|
|
|
type volcengineDiagnostic struct {
|
|
Operation string
|
|
Status int
|
|
Code string
|
|
CodeN string
|
|
RequestID string
|
|
ErrorClass string
|
|
ElapsedMS int64
|
|
}
|
|
|
|
func logVolcengineFailure(diagnostic volcengineDiagnostic) {
|
|
log.Printf(
|
|
"zhinian-api Volcengine operation failed operation=%s status=%d code=%q codeN=%q requestId=%q errorClass=%s elapsedMs=%d",
|
|
diagnostic.Operation,
|
|
diagnostic.Status,
|
|
diagnostic.Code,
|
|
diagnostic.CodeN,
|
|
diagnostic.RequestID,
|
|
diagnostic.ErrorClass,
|
|
diagnostic.ElapsedMS,
|
|
)
|
|
}
|
|
|
|
func volcengineOperation(action string) string {
|
|
switch action {
|
|
case "CVSync2AsyncSubmitTask", seedream46SubmitAction:
|
|
return "submit"
|
|
case "CVSync2AsyncGetResult", seedream46QueryAction:
|
|
return "query"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func volcengineDiagnosticCode(value any) string {
|
|
code := volcengineDiagnosticValue(value)
|
|
if len(code) == 0 || len(code) > 64 {
|
|
return ""
|
|
}
|
|
for _, character := range code {
|
|
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
|
|
continue
|
|
}
|
|
return ""
|
|
}
|
|
return code
|
|
}
|
|
|
|
func volcengineDiagnosticNumericCode(value any) string {
|
|
code := volcengineDiagnosticValue(value)
|
|
if len(code) == 0 || len(code) > 32 {
|
|
return ""
|
|
}
|
|
for _, character := range code {
|
|
if character < '0' || character > '9' {
|
|
return ""
|
|
}
|
|
}
|
|
return code
|
|
}
|
|
|
|
func volcengineDiagnosticValue(value any) string {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
return strconv.FormatFloat(typed, 'f', -1, 64)
|
|
case float32:
|
|
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case int32:
|
|
return strconv.FormatInt(int64(typed), 10)
|
|
case int64:
|
|
return strconv.FormatInt(typed, 10)
|
|
case json.Number:
|
|
return string(typed)
|
|
case string:
|
|
return strings.TrimSpace(typed)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func safeVolcengineRequestID(value any) string {
|
|
requestID := stringValue(value)
|
|
if len(requestID) == 0 || len(requestID) > 128 {
|
|
return ""
|
|
}
|
|
for _, character := range requestID {
|
|
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || strings.ContainsRune("-_.:", character) {
|
|
continue
|
|
}
|
|
return ""
|
|
}
|
|
return requestID
|
|
}
|
|
|
|
func classifyVolcengineTransportError(err error) string {
|
|
if errors.Is(err, context.Canceled) {
|
|
return "canceled"
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return "timeout"
|
|
}
|
|
var networkError net.Error
|
|
if errors.As(err, &networkError) && networkError.Timeout() {
|
|
return "timeout"
|
|
}
|
|
var dnsError *net.DNSError
|
|
if errors.As(err, &dnsError) {
|
|
return "dns"
|
|
}
|
|
var operationError *net.OpError
|
|
if errors.As(err, &operationError) && operationError.Op == "dial" {
|
|
return "connect"
|
|
}
|
|
return "transport"
|
|
}
|
|
|
|
func elapsedMilliseconds(startedAt time.Time) int64 {
|
|
elapsed := time.Since(startedAt).Milliseconds()
|
|
if elapsed < 0 {
|
|
return 0
|
|
}
|
|
return elapsed
|
|
}
|
|
|
|
func sha(b []byte) string { x := sha256.Sum256(b); return hex.EncodeToString(x[:]) }
|
|
func hmacBytes(k []byte, s string) []byte {
|
|
h := hmac.New(sha256.New, k)
|
|
_, _ = h.Write([]byte(s))
|
|
return h.Sum(nil)
|
|
}
|
|
func canonicalQuery(q url.Values) string {
|
|
keys := make([]string, 0, len(q))
|
|
for k := range q {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := []string{}
|
|
for _, k := range keys {
|
|
for _, v := range q[k] {
|
|
parts = append(parts, url.QueryEscape(k)+"="+url.QueryEscape(v))
|
|
}
|
|
}
|
|
return strings.Join(parts, "&")
|
|
}
|