feat(gateway): 补齐桌面端高级媒体直连接口 #25

Merged
easyai merged 1 commits from codex/desktop-gateway-integration into main 2026-07-22 15:30:06 +08:00
37 changed files with 3336 additions and 29 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM ${API_RUNTIME_IMAGE} AS api
RUN apk add --no-cache ca-certificates tzdata wget && \
RUN apk add --no-cache ca-certificates tzdata wget ffmpeg && \
adduser -D -H -u 10001 appuser
WORKDIR /app
+467 -4
View File
@@ -4710,6 +4710,15 @@
"volces-compatible"
],
"summary": "查询 server-main 兼容视频结果",
"parameters": [
{
"type": "string",
"description": "任务 ID",
"name": "taskID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
@@ -5568,6 +5577,15 @@
"volces-compatible"
],
"summary": "查询火山内容生成任务",
"parameters": [
{
"type": "string",
"description": "任务 ID",
"name": "taskID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
@@ -5592,6 +5610,15 @@
"volces-compatible"
],
"summary": "取消火山内容生成任务",
"parameters": [
{
"type": "string",
"description": "任务 ID",
"name": "taskID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
@@ -6094,6 +6121,87 @@
}
}
},
"/api/v1/images/vectorize": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将 URL 位图转换为 SVG、EPS、PDF、DXF 或 PNG;设置 X-Async=true 时返回统一异步任务结构。可使用同一所有者历史任务的 vectorizerTaskId 复用上游 image token。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"images"
],
"summary": "图片矢量化",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"description": "图片矢量化请求",
"name": "input",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/httpapi.ImageVectorizeRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/v1/kling/v1/videos/omni-video": {
"get": {
"security": [
@@ -8167,6 +8275,87 @@
}
}
},
"/api/v1/videos/upscales": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "原生执行 Topaz 视频增强流程;设置 X-Async=true 时返回统一异步任务结构,结果在任务完成前持久化到 Gateway 文件存储。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"videos"
],
"summary": "视频超分",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"description": "视频超分请求",
"name": "input",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/httpapi.VideoUpscaleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/v1/voice_clone": {
"post": {
"security": [
@@ -8709,6 +8898,72 @@
}
}
},
"/api/workspace/token-usage/daily": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "按 IANA 时区返回连续自然日用量;API Key 仅统计当前 KeyJWT 统计当前用户。",
"produces": [
"application/json"
],
"tags": [
"workspace"
],
"summary": "查询每日 Token 与资源点用量",
"parameters": [
{
"type": "string",
"description": "开始日期 YYYY-MM-DD",
"name": "from",
"in": "query",
"required": true
},
{
"type": "string",
"description": "结束日期 YYYY-MM-DD",
"name": "to",
"in": "query",
"required": true
},
{
"type": "string",
"default": "UTC",
"description": "IANA 时区",
"name": "timezone",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.DailyTokenUsageResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/workspace/user-groups": {
"get": {
"security": [
@@ -9498,6 +9753,32 @@
}
}
},
"httpapi.DailyTokenUsageResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/store.DailyTokenUsage"
}
},
"range": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"summary": {
"$ref": "#/definitions/httpapi.dailyTokenUsageSummary"
},
"tokenDays": {
"type": "array",
"items": {
"$ref": "#/definitions/store.DailyTokenUsage"
}
}
}
},
"httpapi.ErrorEnvelope": {
"type": "object",
"properties": {
@@ -9589,6 +9870,63 @@
}
}
},
"httpapi.ImageVectorizeRequest": {
"type": "object",
"properties": {
"cleanupLevel": {
"type": "string",
"enum": [
"low",
"standard",
"strong"
],
"example": "standard"
},
"format": {
"type": "string",
"enum": [
"svg",
"eps",
"pdf",
"dxf",
"png"
],
"example": "svg"
},
"maxColors": {
"type": "integer",
"enum": [
0,
2,
4,
8,
16,
32
],
"example": 16
},
"model": {
"type": "string",
"example": "easy-image-vectorizer-1"
},
"source": {
"$ref": "#/definitions/httpapi.ImageVectorizeSource"
}
}
},
"httpapi.ImageVectorizeSource": {
"type": "object",
"properties": {
"url": {
"type": "string",
"example": "https://example.com/source.png"
},
"vectorizerTaskId": {
"type": "string",
"example": "9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"
}
}
},
"httpapi.KelingCompatibleEnvelope": {
"type": "object",
"properties": {
@@ -10528,10 +10866,24 @@
"type": "number",
"example": 0.65
},
"audio_url": {
"type": "string",
"example": "https://example.com/source-voice.mp3"
},
"customMode": {
"type": "boolean",
"example": false
},
"display_name": {
"type": "string",
"example": "EasyAI DEV acceptance"
},
"documents": {
"type": "array",
"items": {
"type": "string"
}
},
"duration": {
"type": "integer",
"example": 5
@@ -10576,10 +10928,26 @@
"type": "number",
"example": 0
},
"preview_model": {
"type": "string",
"example": "speech-2.8-hd"
},
"prompt": {
"type": "string",
"example": "A watercolor robot reading a book"
},
"prompt_audio_url": {
"type": "string",
"example": "https://example.com/prompt-voice.mp3"
},
"prompt_text": {
"type": "string",
"example": "EasyAI voice clone prompt."
},
"query": {
"type": "string",
"example": "Which document mentions EasyAI Gateway?"
},
"reasoning_effort": {
"description": "ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。",
"type": "string",
@@ -10693,6 +11061,62 @@
}
}
},
"httpapi.VideoUpscaleRequest": {
"type": "object",
"properties": {
"duration": {
"type": "number",
"example": 3
},
"model": {
"type": "string",
"example": "easy-proteus-standard-4"
},
"operation": {
"type": "string",
"enum": [
"upscale"
],
"example": "upscale"
},
"output_height": {
"type": "integer",
"example": 1080
},
"output_width": {
"type": "integer",
"example": 1920
},
"preserve_audio": {
"type": "boolean",
"example": true
},
"slow_motion_rate": {
"type": "number",
"example": 1
},
"source_frame_rate": {
"type": "number",
"example": 24
},
"source_resolution": {
"type": "string",
"example": "480p"
},
"target_frame_rate": {
"type": "number",
"example": 24
},
"target_resolution": {
"type": "string",
"example": "1080p"
},
"video_url": {
"type": "string",
"example": "https://example.com/source.mp4"
}
}
},
"httpapi.WalletAdjustmentResponse": {
"type": "object",
"properties": {
@@ -10733,6 +11157,23 @@
}
}
},
"httpapi.dailyTokenUsageSummary": {
"type": "object",
"properties": {
"cumulativeTokens": {
"type": "integer"
},
"currentStreakDays": {
"type": "integer"
},
"longestStreakDays": {
"type": "integer"
},
"peakDailyTokens": {
"type": "integer"
}
}
},
"httpapi.desktopBillingConfigResponse": {
"type": "object",
"properties": {
@@ -11997,6 +12438,32 @@
}
}
},
"store.DailyTokenUsage": {
"type": "object",
"properties": {
"cachedInputTokens": {
"type": "integer"
},
"date": {
"type": "string"
},
"inputTokens": {
"type": "integer"
},
"outputTokens": {
"type": "integer"
},
"resourcePoints": {
"type": "number"
},
"taskCount": {
"type": "integer"
},
"totalTokens": {
"type": "integer"
}
}
},
"store.FileStorageChannel": {
"type": "object",
"properties": {
@@ -12224,10 +12691,6 @@
"remoteTaskId": {
"type": "string"
},
"remoteTaskPayload": {
"type": "object",
"additionalProperties": {}
},
"request": {
"type": "object",
"additionalProperties": {}
+318 -3
View File
@@ -329,6 +329,23 @@ definitions:
additionalProperties: true
type: object
type: object
httpapi.DailyTokenUsageResponse:
properties:
items:
items:
$ref: '#/definitions/store.DailyTokenUsage'
type: array
range:
additionalProperties:
type: string
type: object
summary:
$ref: '#/definitions/httpapi.dailyTokenUsageSummary'
tokenDays:
items:
$ref: '#/definitions/store.DailyTokenUsage'
type: array
type: object
httpapi.ErrorEnvelope:
properties:
error:
@@ -393,6 +410,49 @@ definitions:
example: easyai-ai-gateway
type: string
type: object
httpapi.ImageVectorizeRequest:
properties:
cleanupLevel:
enum:
- low
- standard
- strong
example: standard
type: string
format:
enum:
- svg
- eps
- pdf
- dxf
- png
example: svg
type: string
maxColors:
enum:
- 0
- 2
- 4
- 8
- 16
- 32
example: 16
type: integer
model:
example: easy-image-vectorizer-1
type: string
source:
$ref: '#/definitions/httpapi.ImageVectorizeSource'
type: object
httpapi.ImageVectorizeSource:
properties:
url:
example: https://example.com/source.png
type: string
vectorizerTaskId:
example: 9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25
type: string
type: object
httpapi.KelingCompatibleEnvelope:
properties:
code:
@@ -1034,12 +1094,22 @@ definitions:
audio:
example: false
type: boolean
audio_url:
example: https://example.com/source-voice.mp3
type: string
audioWeight:
example: 0.65
type: number
customMode:
example: false
type: boolean
display_name:
example: EasyAI DEV acceptance
type: string
documents:
items:
type: string
type: array
duration:
example: 5
type: integer
@@ -1073,9 +1143,21 @@ definitions:
pitch:
example: 0
type: number
preview_model:
example: speech-2.8-hd
type: string
prompt:
example: A watercolor robot reading a book
type: string
prompt_audio_url:
example: https://example.com/prompt-voice.mp3
type: string
prompt_text:
example: EasyAI voice clone prompt.
type: string
query:
example: Which document mentions EasyAI Gateway?
type: string
reasoning_effort:
description: ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
enum:
@@ -1158,6 +1240,47 @@ definitions:
$ref: '#/definitions/store.GatewayUser'
type: array
type: object
httpapi.VideoUpscaleRequest:
properties:
duration:
example: 3
type: number
model:
example: easy-proteus-standard-4
type: string
operation:
enum:
- upscale
example: upscale
type: string
output_height:
example: 1080
type: integer
output_width:
example: 1920
type: integer
preserve_audio:
example: true
type: boolean
slow_motion_rate:
example: 1
type: number
source_frame_rate:
example: 24
type: number
source_resolution:
example: 480p
type: string
target_frame_rate:
example: 24
type: number
target_resolution:
example: 1080p
type: string
video_url:
example: https://example.com/source.mp4
type: string
type: object
httpapi.WalletAdjustmentResponse:
properties:
account:
@@ -1185,6 +1308,17 @@ definitions:
example: 42
type: integer
type: object
httpapi.dailyTokenUsageSummary:
properties:
cumulativeTokens:
type: integer
currentStreakDays:
type: integer
longestStreakDays:
type: integer
peakDailyTokens:
type: integer
type: object
httpapi.desktopBillingConfigResponse:
properties:
gatewayBaseUrl:
@@ -2044,6 +2178,23 @@ definitions:
secret:
type: string
type: object
store.DailyTokenUsage:
properties:
cachedInputTokens:
type: integer
date:
type: string
inputTokens:
type: integer
outputTokens:
type: integer
resourcePoints:
type: number
taskCount:
type: integer
totalTokens:
type: integer
type: object
store.FileStorageChannel:
properties:
channelKey:
@@ -2197,9 +2348,6 @@ definitions:
type: object
remoteTaskId:
type: string
remoteTaskPayload:
additionalProperties: {}
type: object
request:
additionalProperties: {}
type: object
@@ -6344,6 +6492,12 @@ paths:
- playground
/api/v1/ai/result/{taskID}:
get:
parameters:
- description: 任务 ID
in: path
name: taskID
required: true
type: string
produces:
- application/json
responses:
@@ -6897,6 +7051,12 @@ paths:
/api/v1/contents/generations/tasks/{taskID}:
delete:
description: 取消网关任务;对于已提交且保存了上游任务标识的 Volces 视频任务,同时调用火山 DELETE 接口并持久化取消状态。
parameters:
- description: 任务 ID
in: path
name: taskID
required: true
type: string
produces:
- application/json
responses:
@@ -6911,6 +7071,12 @@ paths:
tags:
- volces-compatible
get:
parameters:
- description: 任务 ID
in: path
name: taskID
required: true
type: string
produces:
- application/json
responses:
@@ -7241,6 +7407,59 @@ paths:
summary: 创建或执行 AI 任务
tags:
- tasks
/api/v1/images/vectorize:
post:
consumes:
- application/json
description: 将 URL 位图转换为 SVG、EPS、PDF、DXF 或 PNG;设置 X-Async=true 时返回统一异步任务结构。可使用同一所有者历史任务的
vectorizerTaskId 复用上游 image token。
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: 图片矢量化请求
in: body
name: input
required: true
schema:
$ref: '#/definitions/httpapi.ImageVectorizeRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 图片矢量化
tags:
- images
/api/v1/kling/v1/videos/omni-video:
get:
parameters:
@@ -8578,6 +8797,59 @@ paths:
summary: 查询 Kling Omni 视频任务
tags:
- kling-compatible
/api/v1/videos/upscales:
post:
consumes:
- application/json
description: 原生执行 Topaz 视频增强流程;设置 X-Async=true 时返回统一异步任务结构,结果在任务完成前持久化到 Gateway
文件存储。
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: 视频超分请求
in: body
name: input
required: true
schema:
$ref: '#/definitions/httpapi.VideoUpscaleRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 视频超分
tags:
- videos
/api/v1/voice_clone:
post:
consumes:
@@ -8925,6 +9197,49 @@ paths:
summary: 获取任务参数预处理日志
tags:
- tasks
/api/workspace/token-usage/daily:
get:
description: 按 IANA 时区返回连续自然日用量;API Key 仅统计当前 Key,JWT 统计当前用户。
parameters:
- description: 开始日期 YYYY-MM-DD
in: query
name: from
required: true
type: string
- description: 结束日期 YYYY-MM-DD
in: query
name: to
required: true
type: string
- default: UTC
description: IANA 时区
in: query
name: timezone
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.DailyTokenUsageResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 查询每日 Token 与资源点用量
tags:
- workspace
/api/workspace/user-groups:
get:
description: 返回当前用户关联的用户组及策略摘要,供个人中心使用。
@@ -0,0 +1,282 @@
package clients
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestVectorizerClientMultipartAndPrivateResumeState(t *testing.T) {
var receivedPath string
var receivedFields map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
if username, password, ok := r.BasicAuth(); !ok || username != "id" || password != "secret" {
t.Fatalf("unexpected vectorizer auth")
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
}
receivedFields = map[string]string{}
for key, values := range r.MultipartForm.Value {
receivedFields[key] = values[0]
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("X-Image-Token", "private-image-token")
w.Header().Set("X-Receipt", "private-receipt")
_, _ = io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg"/>`)
}))
defer server.Close()
privateState := map[string]any{}
response, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
}}).Run(context.Background(), Request{
Kind: "images.vectorize", Model: "easy-image-vectorizer-1",
Body: map[string]any{"source": map[string]any{"url": "https://example.com/input.png"}, "format": "svg", "maxColors": 16, "cleanupLevel": "strong"},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"accessKey": "id", "secretKey": "secret"}),
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error { privateState = payload; return nil },
})
if err != nil {
t.Fatalf("run vectorizer: %v", err)
}
if receivedPath != "/vectorize" || receivedFields["image.url"] == "" || receivedFields["output.file_format"] != "svg" || receivedFields["processing.max_colors"] != "16" {
t.Fatalf("unexpected multipart request: path=%s fields=%+v", receivedPath, receivedFields)
}
if privateState["imageToken"] != "private-image-token" || privateState["receipt"] != "private-receipt" {
t.Fatalf("private resume state missing: %+v", privateState)
}
serialized := strings.ToLower(toJSONForTest(response.Result))
if strings.Contains(serialized, "private-image-token") || strings.Contains(serialized, "private-receipt") {
t.Fatalf("private vectorizer state leaked into response: %s", serialized)
}
}
func TestVectorizerClientRejectsPrivateSourceBeforeProviderCall(t *testing.T) {
providerCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { providerCalls++ }))
defer server.Close()
_, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
}}).Run(context.Background(), Request{
Body: map[string]any{"source": map[string]any{"url": "https://assets.example.test/input.png"}},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
})
if err == nil || !strings.Contains(err.Error(), "blocked network") {
t.Fatalf("expected blocked source error, got %v", err)
}
if providerCalls != 0 {
t.Fatalf("provider received %d calls for blocked source", providerCalls)
}
}
func TestVectorizerClientReusesImageTokenForExtraFormat(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/download" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatal(err)
}
if r.FormValue("image.token") != "token" || r.FormValue("receipt") != "receipt" || r.FormValue("output.file_format") != "pdf" {
t.Fatalf("unexpected reuse form: %+v", r.MultipartForm.Value)
}
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("%PDF-1.7\n"))
}))
defer server.Close()
response, err := (VectorizerClient{}).Run(context.Background(), Request{
Body: map[string]any{"_vectorizer_image_token": "token", "_vectorizer_receipt": "receipt", "format": "pdf"},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
})
if err != nil {
t.Fatal(err)
}
item := response.Result["data"].([]any)[0].(map[string]any)
if item["type"] != "file" || item["mime_type"] != "application/pdf" {
t.Fatalf("unexpected pdf result: %+v", item)
}
}
func TestTopazClientUploadsPartsPollsAndValidatesOutput(t *testing.T) {
var mu sync.Mutex
called := map[string]int{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
called[r.Method+" "+r.URL.Path]++
mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/source.mp4":
_, _ = w.Write([]byte("small-video-source"))
case r.Method == http.MethodPost && r.URL.Path == "/video/":
if r.Header.Get("X-API-Key") != "topaz-key" {
t.Fatalf("missing Topaz API key")
}
_, _ = io.WriteString(w, `{"requestId":"job-1"}`)
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/accept":
_, _ = io.WriteString(w, `{"urls":["`+server.URL+`/upload/1","`+server.URL+`/upload/2"]}`)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"):
w.Header().Set("ETag", `"etag-`+strings.TrimPrefix(r.URL.Path, "/upload/")+`"`)
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/complete-upload/":
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodGet && r.URL.Path == "/video/job-1/status":
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/output.mp4"}}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
phases := []string{}
client := TopazClient{Probe: func(_ context.Context, target string) (TopazVideoMetadata, error) {
if strings.HasPrefix(target, "http") {
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24, HasAudio: true}, nil
}
return TopazVideoMetadata{Width: 320, Height: 180, Duration: 3, FrameRate: 24, FrameCount: 72, HasAudio: true}, nil
}}
response, err := client.Run(context.Background(), Request{
Model: "easy-proteus-standard-4",
Body: map[string]any{"video_url": server.URL + "/source.mp4", "output_width": 640, "output_height": 360, "preserve_audio": true},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"allowPrivateSourceDownloads": true, "pollIntervalMs": 1, "pollTimeoutMs": 100}),
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error {
phases = append(phases, payload["phase"].(string))
return nil
},
OnRemoteTaskPolled: func(_ string, payload map[string]any) error {
phases = append(phases, payload["phase"].(string))
return nil
},
})
if err != nil {
t.Fatalf("run Topaz: %v", err)
}
if response.RequestID != "job-1" || !containsStringForTest(phases, "created") || !containsStringForTest(phases, "uploaded") {
t.Fatalf("unexpected Topaz response/phases: response=%+v phases=%+v", response, phases)
}
if called["PUT /upload/1"] != 1 || called["PUT /upload/2"] != 1 || called["PATCH /video/job-1/complete-upload/"] != 1 {
t.Fatalf("upload lifecycle incomplete: %+v", called)
}
}
func TestTopazClientResumesUploadedTaskWithoutDownloadingSource(t *testing.T) {
var sourceCalls int
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/source.mp4" {
sourceCalls++
}
if r.URL.Path == "/video/job-resume/status" {
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/result.mp4"}}`)
return
}
http.NotFound(w, r)
}))
defer server.Close()
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24}, nil
}}).Run(context.Background(), Request{
RemoteTaskID: "job-resume", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 100}),
})
if err != nil {
t.Fatal(err)
}
if sourceCalls != 0 {
t.Fatalf("resume downloaded source %d times", sourceCalls)
}
}
func TestTopazClientReportsProviderFailureAndPollingTimeout(t *testing.T) {
tests := []struct {
name string
statusBody string
timeoutMS int
want string
}{
{name: "provider failure", statusBody: `{"status":"failed","message":"upstream rejected"}`, timeoutMS: 100, want: "upstream rejected"},
{name: "poll timeout", statusBody: `{"status":"processing"}`, timeoutMS: 3, want: "polling timed out"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/video/job/status" {
http.NotFound(w, r)
return
}
_, _ = io.WriteString(w, test.statusBody)
}))
defer server.Close()
_, err := (TopazClient{}).Run(context.Background(), Request{
RemoteTaskID: "job", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
Body: map[string]any{"video_url": "https://assets.example.test/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
"pollIntervalMs": 1, "pollTimeoutMs": test.timeoutMS,
}),
})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("expected %q error, got %v", test.want, err)
}
})
}
}
func TestTopazClientRejectsOversizedInputBeforeProbe(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/source.mp4" {
_, _ = io.WriteString(w, "too-large")
return
}
http.NotFound(w, r)
}))
defer server.Close()
probeCalls := 0
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
probeCalls++
return TopazVideoMetadata{}, nil
}}).Run(context.Background(), Request{
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
"allowPrivateSourceDownloads": true, "maxInputBytes": 3,
}),
})
if err == nil || !strings.Contains(err.Error(), "size limit") {
t.Fatalf("expected input size limit error, got %v", err)
}
if probeCalls != 0 {
t.Fatalf("oversized input was probed %d times", probeCalls)
}
}
func storeCandidate(baseURL, specType string, credentials map[string]any) store.RuntimeModelCandidate {
return storeCandidateWithConfig(baseURL, specType, credentials, nil)
}
func storeCandidateWithConfig(baseURL, specType string, credentials, config map[string]any) store.RuntimeModelCandidate {
return store.RuntimeModelCandidate{BaseURL: baseURL, SpecType: specType, Provider: specType, Credentials: credentials, PlatformConfig: config}
}
func toJSONForTest(value any) string {
raw, _ := json.Marshal(value)
return string(raw)
}
func containsStringForTest(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}
+617
View File
@@ -0,0 +1,617 @@
package clients
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
topazDefaultPollInterval = 15 * time.Second
topazDefaultPollTimeout = 60 * time.Minute
topazDefaultMaxInput = int64(2 << 30)
)
type TopazClient struct {
HTTPClient *http.Client
Probe func(context.Context, string) (TopazVideoMetadata, error)
}
type TopazVideoMetadata struct {
Width int
Height int
Duration float64
FrameRate float64
FrameCount int
HasAudio bool
}
type topazSource struct {
Path string
Container string
Size int64
MD5 string
Resolution map[string]any
Duration float64
FrameRate float64
FrameCount int
}
func (c TopazClient) Run(ctx context.Context, request Request) (Response, error) {
startedAt := time.Now()
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
if apiKey == "" {
return Response{}, &ClientError{Code: "missing_credentials", Message: "Topaz API key is required", Retryable: false}
}
requestID := strings.TrimSpace(request.RemoteTaskID)
payload := cloneMapAny(request.RemoteTaskPayload)
var source *topazSource
var target map[string]int
if requestID == "" || strings.TrimSpace(firstNonEmptyString(payload["phase"])) != "uploaded" {
videoURL := strings.TrimSpace(firstNonEmptyString(request.Body["video_url"], request.Body["videoUrl"]))
if videoURL == "" {
return Response{}, &ClientError{Code: "invalid_parameter", Message: "video_url is required", Param: "video_url", StatusCode: http.StatusBadRequest}
}
prepared, err := c.prepareSource(ctx, request, videoURL)
if err != nil {
return Response{}, err
}
source = &prepared
defer os.Remove(prepared.Path)
target = topazTargetResolution(request.Body, prepared.Resolution)
if requestID == "" {
created, err := c.createRequest(ctx, request, apiKey, prepared, target)
if err != nil {
return Response{}, annotateResponseError(err, "", startedAt, time.Now())
}
requestID = strings.TrimSpace(firstNonEmptyString(created["requestId"], created["request_id"], created["id"]))
if requestID == "" {
return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz create response is missing requestId", Retryable: false}
}
payload = map[string]any{"phase": "created", "targetResolution": target}
if request.OnRemoteTaskSubmitted != nil {
if err := request.OnRemoteTaskSubmitted(requestID, payload); err != nil {
return Response{}, err
}
}
}
if err := c.uploadSource(ctx, request, apiKey, requestID, prepared); err != nil {
return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
}
payload = map[string]any{"phase": "uploaded", "targetResolution": target}
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(requestID, payload); err != nil {
return Response{}, err
}
}
}
if target == nil {
target = topazTargetFromPayload(payload, request.Body)
}
completed, err := c.poll(ctx, request, apiKey, requestID, target)
if err != nil {
return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
}
outputURL := topazDownloadURL(completed)
if outputURL == "" {
return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz completed without a download URL", RequestID: requestID, Retryable: false}
}
metadata, err := c.probe(ctx, outputURL)
if err != nil {
return Response{}, &ClientError{Code: "invalid_response", Message: "cannot validate Topaz output metadata: " + err.Error(), RequestID: requestID, Retryable: true}
}
if target["width"] > 0 && target["height"] > 0 && (metadata.Width+4 < target["width"] || metadata.Height+4 < target["height"]) {
return Response{}, &ClientError{Code: "invalid_response", Message: fmt.Sprintf("Topaz output resolution %dx%d does not reach target %dx%d", metadata.Width, metadata.Height, target["width"], target["height"]), RequestID: requestID, Retryable: false}
}
finishedAt := time.Now()
resultItem := map[string]any{
"type": "video",
"url": outputURL,
"video_url": outputURL,
"width": metadata.Width,
"height": metadata.Height,
"target_resolution": fmt.Sprintf("%dx%d", target["width"], target["height"]),
"duration": metadata.Duration,
"target_frame_rate": metadata.FrameRate,
"slow_motion_rate": numericValue(request.Body["slow_motion_rate"], 1),
}
if source != nil {
resultItem["source_resolution"] = fmt.Sprintf("%vx%v", source.Resolution["width"], source.Resolution["height"])
resultItem["source_frame_rate"] = source.FrameRate
resultItem["duration"] = source.Duration
}
return Response{
Result: map[string]any{
"status": "success",
"model": request.Model,
"task_id": requestID,
"upstream_task_id": requestID,
"data": []any{resultItem},
},
RequestID: requestID,
Progress: append(providerProgress(request), Progress{Phase: "polling", Progress: 0.9, Message: "Topaz upscale completed", Payload: map[string]any{"upstreamTaskId": requestID}}),
ResponseStartedAt: startedAt,
ResponseFinishedAt: finishedAt,
ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
}, nil
}
func (c TopazClient) prepareSource(ctx context.Context, request Request, rawURL string) (topazSource, error) {
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url must be an http(s) URL", Param: "video_url", StatusCode: http.StatusBadRequest}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return topazSource{}, err
}
downloadClient := topazSourceHTTPClient(httpClient(request.HTTPClient, c.HTTPClient), boolishDefault(request.Candidate.PlatformConfig["allowPrivateSourceDownloads"], false))
resp, err := downloadClient.Do(req)
if err != nil {
return topazSource{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url download failed: " + resp.Status, Param: "video_url", StatusCode: http.StatusBadRequest}
}
limit := int64(numericValue(firstPresent(request.Candidate.PlatformConfig["maxInputBytes"], request.Candidate.PlatformConfig["max_input_bytes"]), float64(topazDefaultMaxInput)))
if limit <= 0 {
limit = topazDefaultMaxInput
}
tmp, err := os.CreateTemp("", "easyai-topaz-*"+topazContainerExtension(parsed.Path))
if err != nil {
return topazSource{}, err
}
path := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(path)
}
hash := md5.New()
written, err := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(resp.Body, limit+1))
closeErr := tmp.Close()
if err != nil || closeErr != nil || written <= 0 || written > limit {
cleanup()
if written > limit {
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video source exceeds configured size limit", Param: "video_url", StatusCode: http.StatusBadRequest}
}
return topazSource{}, firstError(err, closeErr)
}
metadata, err := c.probe(ctx, path)
if err != nil || metadata.Width <= 0 || metadata.Height <= 0 {
cleanup()
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "cannot probe source video metadata", Param: "video_url", StatusCode: http.StatusBadRequest}
}
frameRate := metadata.FrameRate
if frameRate <= 0 {
frameRate = numericValue(request.Candidate.PlatformConfig["frameRate"], 30)
}
duration := math.Max(1, metadata.Duration)
frameCount := metadata.FrameCount
if frameCount <= 0 {
frameCount = int(math.Round(duration * frameRate))
}
return topazSource{
Path: path, Container: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."), Size: written,
MD5: hex.EncodeToString(hash.Sum(nil)), Resolution: map[string]any{"width": metadata.Width, "height": metadata.Height},
Duration: duration, FrameRate: frameRate, FrameCount: frameCount,
}, nil
}
func (c TopazClient) createRequest(ctx context.Context, request Request, apiKey string, source topazSource, target map[string]int) (map[string]any, error) {
model := topazModelName(firstNonEmptyString(request.Candidate.ProviderModelName, request.Model))
scale := math.Max(float64(target["width"])/numericValue(source.Resolution["width"], 1), float64(target["height"])/numericValue(source.Resolution["height"], 1))
filter := map[string]any{"model": model}
if scale > 1 {
key := "scale"
if model == "slf-2" || model == "slhq-1" || model == "slm-1" || model == "slp-2.5" {
key = "upscaling_factor"
}
filter[key] = math.Round(scale*1000) / 1000
}
preserveAudio := boolishDefault(request.Body["preserve_audio"], true)
filters := []any{filter}
targetFrameRate := numericValue(firstPresent(request.Body["target_frame_rate"], request.Body["output_frame_rate"]), source.FrameRate)
slowMotionRate := numericValue(request.Body["slow_motion_rate"], 1)
if targetFrameRate <= 0 {
targetFrameRate = source.FrameRate
}
if slowMotionRate <= 0 {
slowMotionRate = 1
}
if targetFrameRate != source.FrameRate || slowMotionRate > 1 {
interpolationModel := strings.TrimSpace(firstNonEmptyString(request.Body["frame_interpolation_model"]))
if interpolationModel == "" {
return nil, &ClientError{Code: "invalid_parameter", Message: "frame_interpolation_model is required when output frame rate or slow motion changes", Param: "frame_interpolation_model", StatusCode: http.StatusBadRequest}
}
interpolation := map[string]any{"model": topazModelName(interpolationModel)}
if targetFrameRate > 0 {
interpolation["fps"] = targetFrameRate
}
if slowMotionRate > 1 {
interpolation["slowmo"] = slowMotionRate
}
filters = append(filters, interpolation)
}
body := map[string]any{
"source": map[string]any{"container": source.Container, "size": source.Size, "duration": source.Duration, "frameCount": source.FrameCount, "frameRate": source.FrameRate, "resolution": source.Resolution},
"filters": filters,
"output": map[string]any{"resolution": target, "frameRate": targetFrameRate, "audioCodec": "AAC", "audioTransfer": map[bool]string{true: "Copy", false: "None"}[preserveAudio], "dynamicCompressionLevel": "High", "videoEncoder": "H265", "videoProfile": "Main", "container": "mp4"},
}
return c.topazJSON(ctx, request, apiKey, http.MethodPost, "/video/", body)
}
func (c TopazClient) uploadSource(ctx context.Context, request Request, apiKey, requestID string, source topazSource) error {
accepted, err := c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/accept", nil)
if err != nil {
return err
}
urls := stringList(accepted["urls"])
if len(urls) == 0 {
return &ClientError{Code: "invalid_response", Message: "Topaz accept response has no upload URLs", RequestID: requestID}
}
file, err := os.Open(source.Path)
if err != nil {
return err
}
defer file.Close()
chunkSize := (source.Size + int64(len(urls)) - 1) / int64(len(urls))
results := make([]any, 0, len(urls))
for index, uploadURL := range urls {
remaining := source.Size - int64(index)*chunkSize
if remaining <= 0 {
break
}
length := minInt64(chunkSize, remaining)
section := io.NewSectionReader(file, int64(index)*chunkSize, length)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, section)
if err != nil {
return err
}
req.ContentLength = length
req.Header.Set("Content-Type", topazContainerContentType(source.Container))
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &ClientError{Code: "provider_failed", Message: "Topaz part upload failed: " + resp.Status, StatusCode: resp.StatusCode, Retryable: resp.StatusCode >= 500}
}
etag := strings.Trim(resp.Header.Get("ETag"), `"`)
if etag == "" {
return &ClientError{Code: "invalid_response", Message: "Topaz part upload is missing ETag", Retryable: false}
}
results = append(results, map[string]any{"partNum": index + 1, "eTag": etag})
}
_, err = c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/complete-upload/", map[string]any{"md5Hash": source.MD5, "uploadResults": results})
return err
}
func (c TopazClient) poll(ctx context.Context, request Request, apiKey, requestID string, target map[string]int) (map[string]any, error) {
interval := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollInterval, "pollIntervalMs", "poll_interval_ms")
timeout := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollTimeout, "pollTimeoutMs", "poll_timeout_ms", "timeoutMs")
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
status, err := c.topazJSON(ctx, request, apiKey, http.MethodGet, "/video/"+url.PathEscape(requestID)+"/status", nil)
if err != nil {
return nil, err
}
state := strings.ToLower(strings.TrimSpace(firstNonEmptyString(status["status"], status["state"])))
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(requestID, map[string]any{"phase": "uploaded", "status": state, "targetResolution": target}); err != nil {
return nil, err
}
}
switch state {
case "complete", "completed", "success", "succeeded":
return status, nil
case "failed", "failure", "cancelled", "canceled", "error":
return nil, &ClientError{Code: "provider_failed", Message: firstNonEmptyString(status["message"], "Topaz task failed"), RequestID: requestID, Retryable: false}
}
select {
case <-ctx.Done():
return nil, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
case <-deadline.C:
return nil, &ClientError{Code: "timeout", Message: "Topaz task polling timed out", RequestID: requestID, Retryable: true}
case <-time.After(interval):
}
}
}
func (c TopazClient) topazJSON(ctx context.Context, request Request, apiKey, method, path string, body map[string]any) (map[string]any, error) {
var reader io.Reader
if body != nil {
raw, _ := json.Marshal(body)
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, providerURL(request.Candidate.BaseURL, path), reader)
if err != nil {
return nil, err
}
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
result, decodeErr := decodeHTTPResponse(resp)
if decodeErr != nil {
return nil, decodeErr
}
return result, nil
}
func (c TopazClient) probe(ctx context.Context, target string) (TopazVideoMetadata, error) {
if c.Probe != nil {
return c.Probe(ctx, target)
}
return probeTopazVideo(ctx, target)
}
func probeTopazVideo(ctx context.Context, target string) (TopazVideoMetadata, error) {
if _, err := exec.LookPath("ffprobe"); err != nil {
return TopazVideoMetadata{}, err
}
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
output, err := exec.CommandContext(probeCtx, "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,avg_frame_rate,nb_frames", "-of", "json", target).Output()
if err != nil {
return TopazVideoMetadata{}, err
}
var decoded struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
AverageRate string `json:"avg_frame_rate"`
FrameCount string `json:"nb_frames"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &decoded); err != nil {
return TopazVideoMetadata{}, err
}
metadata := TopazVideoMetadata{}
metadata.Duration, _ = strconv.ParseFloat(decoded.Format.Duration, 64)
for _, stream := range decoded.Streams {
if stream.CodecType == "audio" {
metadata.HasAudio = true
}
if stream.CodecType != "video" || stream.Width <= 0 || stream.Height <= 0 {
continue
}
metadata.Width, metadata.Height = stream.Width, stream.Height
metadata.FrameRate = parseTopazRate(stream.AverageRate)
metadata.FrameCount, _ = strconv.Atoi(stream.FrameCount)
}
if metadata.Width <= 0 || metadata.Height <= 0 {
return TopazVideoMetadata{}, errors.New("video stream metadata is missing")
}
return metadata, nil
}
func topazTargetResolution(body map[string]any, source map[string]any) map[string]int {
width := int(numericValue(body["output_width"], 0))
height := int(numericValue(body["output_height"], 0))
if width > 0 && height > 0 {
return map[string]int{"width": width, "height": height}
}
value := strings.ToLower(strings.TrimSpace(firstNonEmptyString(body["target_resolution"], body["output_resolution"], "1080p")))
if parsedWidth, parsedHeight, ok := parseTopazSize(value); ok {
return map[string]int{"width": parsedWidth, "height": parsedHeight}
}
base := map[string][2]int{"480p": {854, 480}, "720p": {1280, 720}, "1080p": {1920, 1080}, "1440p": {2560, 1440}, "2k": {2560, 1440}, "2160p": {3840, 2160}, "4k": {3840, 2160}}
value = strings.TrimSuffix(value, "_upscale")
target, ok := base[value]
if !ok {
target = base["1080p"]
}
sourceWidth := numericValue(source["width"], 1)
sourceHeight := numericValue(source["height"], 1)
if sourceWidth == sourceHeight {
side := minInt(target[0], target[1])
return map[string]int{"width": side, "height": side}
}
if sourceWidth > sourceHeight {
return map[string]int{"width": int(math.Round(sourceWidth / sourceHeight * float64(target[1]))), "height": target[1]}
}
return map[string]int{"width": target[1], "height": int(math.Round(sourceHeight / sourceWidth * float64(target[1])))}
}
func topazTargetFromPayload(payload map[string]any, body map[string]any) map[string]int {
if raw, ok := payload["targetResolution"].(map[string]any); ok {
return map[string]int{"width": int(numericValue(raw["width"], 0)), "height": int(numericValue(raw["height"], 0))}
}
return topazTargetResolution(body, map[string]any{"width": 16, "height": 9})
}
func topazDownloadURL(result map[string]any) string {
if download, ok := result["download"].(map[string]any); ok {
return firstNonEmptyString(download["url"], download["downloadUrl"])
}
return firstNonEmptyString(result["download_url"], result["downloadUrl"], result["url"])
}
func topazModelName(model string) string {
switch strings.TrimSpace(model) {
case "easy-proteus-standard-4":
return "prob-4"
case "easy-starlight-fast-2":
return "slf-2"
case "easy-starlight-hq-1":
return "slhq-1"
case "easy-starlight-mini-1":
return "slm-1"
default:
return strings.TrimSpace(model)
}
}
func topazContainerExtension(path string) string {
ext := strings.ToLower(filepath.Ext(strings.Split(path, "?")[0]))
switch ext {
case ".mov", ".mkv", ".webm", ".mp4":
return ext
default:
return ".mp4"
}
}
func topazContainerContentType(container string) string {
switch strings.ToLower(container) {
case "mov":
return "video/quicktime"
case "mkv":
return "video/x-matroska"
case "webm":
return "video/webm"
default:
return "video/mp4"
}
}
func parseTopazRate(value string) float64 {
parts := strings.Split(value, "/")
if len(parts) == 2 {
numerator, _ := strconv.ParseFloat(parts[0], 64)
denominator, _ := strconv.ParseFloat(parts[1], 64)
if denominator > 0 {
return numerator / denominator
}
}
parsed, _ := strconv.ParseFloat(value, 64)
return parsed
}
func parseTopazSize(value string) (int, int, bool) {
parts := strings.Split(strings.ReplaceAll(value, " ", ""), "x")
if len(parts) != 2 {
return 0, 0, false
}
width, errWidth := strconv.Atoi(parts[0])
height, errHeight := strconv.Atoi(parts[1])
return width, height, errWidth == nil && errHeight == nil && width > 0 && height > 0
}
func stringList(value any) []string {
items, ok := value.([]any)
if !ok {
if values, ok := value.([]string); ok {
return values
}
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
out = append(out, text)
}
}
return out
}
func boolishDefault(value any, fallback bool) bool {
if value == nil {
return fallback
}
switch typed := value.(type) {
case bool:
return typed
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err == nil {
return parsed
}
}
return fallback
}
func firstError(values ...error) error {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func minInt64(left, right int64) int64 {
if left < right {
return left
}
return right
}
func minInt(left, right int) int {
if left < right {
return left
}
return right
}
func topazSourceHTTPClient(base *http.Client, allowPrivate bool) *http.Client {
client := *base
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if client.Timeout <= 0 {
client.Timeout = 10 * time.Minute
}
if allowPrivate {
return &client
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, errors.New("video source DNS resolution failed")
}
for _, address := range addresses {
if topazBlockedAddress(address.IP) {
return nil, errors.New("video source resolved to a blocked network")
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
var attempts []error
for _, resolved := range addresses {
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return connection, nil
}
attempts = append(attempts, dialErr)
}
return nil, errors.Join(attempts...)
}
client.Transport = transport
return &client
}
func topazBlockedAddress(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
+271
View File
@@ -0,0 +1,271 @@
package clients
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"net/url"
"strings"
"time"
)
const vectorizerMaxResponseBytes = 128 << 20
// VectorizerClient implements the Vectorizer.AI binary vectorization API.
// Gateway async semantics are supplied by the outer River-backed task runner.
type VectorizerClient struct {
HTTPClient *http.Client
LookupIP func(context.Context, string) ([]net.IPAddr, error)
}
func (c VectorizerClient) Run(ctx context.Context, request Request) (Response, error) {
startedAt := time.Now()
format := strings.ToLower(strings.TrimSpace(firstNonEmptyString(request.Body["format"], request.Body["output_format"])))
if format == "" {
format = "svg"
}
if !vectorizerFormatAllowed(format) {
return Response{}, &ClientError{Code: "invalid_parameter", Message: "vectorizer format must be svg, eps, pdf, dxf, or png", Param: "format", StatusCode: http.StatusBadRequest}
}
imageToken := strings.TrimSpace(firstNonEmptyString(request.Body["_vectorizer_image_token"]))
receipt := strings.TrimSpace(firstNonEmptyString(request.Body["_vectorizer_receipt"]))
endpoint := "vectorize"
fields := map[string]string{"output.file_format": format}
if imageToken != "" {
endpoint = "download"
fields["image.token"] = imageToken
if receipt != "" {
fields["receipt"] = receipt
}
} else {
imageURL := vectorizerImageURL(request.Body)
if imageURL == "" {
return Response{}, &ClientError{Code: "invalid_parameter", Message: "vectorizer source image URL is required", Param: "source.url", StatusCode: http.StatusBadRequest}
}
if err := c.validateSourceURL(ctx, imageURL, boolishDefault(request.Candidate.PlatformConfig["allowPrivateSourceDownloads"], false)); err != nil {
return Response{}, err
}
fields["image.url"] = imageURL
fields["mode"] = firstNonEmptyString(request.Candidate.PlatformConfig["mode"], "production")
fields["policy.retention_days"] = firstNonEmptyString(request.Candidate.PlatformConfig["retentionDays"], request.Candidate.PlatformConfig["retention_days"], "7")
fields["processing.shapes.min_area_px"] = vectorizerCleanupMinArea(request.Body)
if maxColors := vectorizerMaxColors(request.Body); maxColors > 0 {
fields["processing.max_colors"] = fmt.Sprint(maxColors)
}
}
appendVectorizerOutputFields(fields, format)
payload, contentType, err := vectorizerMultipartBody(fields)
if err != nil {
return Response{}, err
}
url := providerURL(request.Candidate.BaseURL, endpoint)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, payload)
if err != nil {
return Response{}, err
}
req.Header.Set("Content-Type", contentType)
applyVectorizerAuth(req, request.Candidate.Credentials)
client := httpClient(request.HTTPClient, c.HTTPClient)
resp, err := client.Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
defer resp.Body.Close()
requestID := requestIDFromHTTPResponse(resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result, decodeErr := decodeHTTPResponse(resp)
if decodeErr != nil {
return Response{}, annotateResponseError(decodeErr, requestID, startedAt, time.Now())
}
return Response{}, &ClientError{Code: "provider_failed", Message: firstNonEmptyString(result["message"], result["error"], resp.Status), RequestID: requestID, StatusCode: resp.StatusCode, Retryable: resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500}
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, vectorizerMaxResponseBytes+1))
if err != nil {
return Response{}, &ClientError{Code: "invalid_response", Message: err.Error(), RequestID: requestID, Retryable: true}
}
if len(raw) == 0 || len(raw) > vectorizerMaxResponseBytes {
return Response{}, &ClientError{Code: "invalid_response", Message: "vectorizer response is empty or too large", RequestID: requestID, Retryable: false}
}
returnedToken := strings.TrimSpace(resp.Header.Get("X-Image-Token"))
returnedReceipt := strings.TrimSpace(resp.Header.Get("X-Receipt"))
if request.OnRemoteTaskSubmitted != nil && returnedToken != "" {
digest := sha256.Sum256([]byte(returnedToken))
if err := request.OnRemoteTaskSubmitted("vectorizer-"+hex.EncodeToString(digest[:8]), map[string]any{
"imageToken": returnedToken,
"receipt": returnedReceipt,
}); err != nil {
return Response{}, err
}
}
finishedAt := time.Now()
mimeType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
if mimeType == "" || mimeType == "application/octet-stream" {
mimeType = vectorizerContentType(format)
}
return Response{
Result: map[string]any{
"status": "success",
"model": request.Model,
"data": []any{map[string]any{
"type": vectorizerOutputKind(format),
"b64_json": base64.StdEncoding.EncodeToString(raw),
"mime_type": mimeType,
"format": format,
}},
"vectorizer": map[string]any{
"format": format,
"creditsCharged": numericHeader(resp.Header.Get("X-Credits-Charged")),
"creditsCalculated": numericHeader(resp.Header.Get("X-Credits-Calculated")),
},
},
RequestID: requestID,
Progress: append(providerProgress(request), Progress{Phase: "uploading", Progress: 0.9, Message: "vectorizer result received"}),
ResponseStartedAt: startedAt,
ResponseFinishedAt: finishedAt,
ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
}, nil
}
func (c VectorizerClient) validateSourceURL(ctx context.Context, rawURL string, allowPrivate bool) error {
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || strings.TrimSpace(parsed.Hostname()) == "" || parsed.User != nil {
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source URL must be a public http(s) URL without userinfo", Param: "source.url", StatusCode: http.StatusBadRequest}
}
if allowPrivate {
return nil
}
lookup := c.LookupIP
if lookup == nil {
lookup = net.DefaultResolver.LookupIPAddr
}
addresses, err := lookup(ctx, parsed.Hostname())
if err != nil || len(addresses) == 0 {
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source DNS resolution failed", Param: "source.url", StatusCode: http.StatusBadRequest}
}
for _, address := range addresses {
if topazBlockedAddress(address.IP) {
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source resolved to a blocked network", Param: "source.url", StatusCode: http.StatusBadRequest}
}
}
return nil
}
func vectorizerImageURL(body map[string]any) string {
if source, ok := body["source"].(map[string]any); ok {
return firstNonEmptyString(source["url"], source["image_url"], source["imageUrl"])
}
return firstNonEmptyString(body["image_url"], body["imageUrl"])
}
func vectorizerMultipartBody(fields map[string]string) (*bytes.Buffer, string, error) {
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
for key, value := range fields {
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, strings.ReplaceAll(key, `"`, `\"`)))
part, err := writer.CreatePart(header)
if err != nil {
return nil, "", err
}
if _, err := io.WriteString(part, value); err != nil {
return nil, "", err
}
}
if err := writer.Close(); err != nil {
return nil, "", err
}
return &payload, writer.FormDataContentType(), nil
}
func applyVectorizerAuth(req *http.Request, credentials map[string]any) {
username := credential(credentials, "username", "accessKey", "access_key", "apiId", "api_id", "id")
password := credential(credentials, "password", "secretKey", "secret_key", "apiSecret", "api_secret", "secret")
if username != "" || password != "" {
req.SetBasicAuth(username, password)
return
}
if apiKey := credential(credentials, "apiKey", "api_key", "token"); apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
func vectorizerFormatAllowed(format string) bool {
switch format {
case "svg", "eps", "pdf", "dxf", "png":
return true
default:
return false
}
}
func vectorizerCleanupMinArea(body map[string]any) string {
cleanup := strings.ToLower(strings.TrimSpace(firstNonEmptyString(body["cleanupLevel"], body["cleanup_level"])))
switch cleanup {
case "low":
return "0"
case "strong":
return "1"
default:
return "0.125"
}
}
func vectorizerMaxColors(body map[string]any) int {
for _, key := range []string{"maxColors", "max_colors"} {
if value := int(numericValue(body[key], 0)); value == 0 || value == 2 || value == 4 || value == 8 || value == 16 || value == 32 {
return value
}
}
return 0
}
func appendVectorizerOutputFields(fields map[string]string, format string) {
if format == "svg" {
fields["output.svg.version"] = "svg_1_1"
fields["output.svg.fixed_size"] = "false"
fields["output.svg.adobe_compatibility_mode"] = "true"
}
if format == "dxf" {
fields["output.dxf.compatibility_level"] = "lines_and_arcs"
}
}
func vectorizerContentType(format string) string {
switch format {
case "svg":
return "image/svg+xml"
case "eps":
return "application/postscript"
case "pdf":
return "application/pdf"
case "dxf":
return "application/dxf"
default:
return "image/png"
}
}
func vectorizerOutputKind(format string) string {
if format == "svg" || format == "png" {
return "image"
}
return "file"
}
func numericHeader(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return numericValue(value, 0)
}
@@ -0,0 +1,62 @@
package httpapi
import (
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestAdvancedMediaScopeAliases(t *testing.T) {
tests := []struct {
kind string
scope string
}{
{kind: "images.vectorize", scope: "image_vectorize"},
{kind: "images.vectorize", scope: "image"},
{kind: "images.vectorize", scope: "vectorize"},
{kind: "videos.upscales", scope: "video_enhance"},
{kind: "videos.upscales", scope: "video"},
{kind: "videos.upscales", scope: "video_upscale"},
}
for _, test := range tests {
user := &auth.User{APIKeyID: "key", APIKeyScopes: []string{test.scope}}
if !apiKeyScopeAllowed(user, test.kind) {
t.Fatalf("scope %q should allow %q", test.scope, test.kind)
}
}
if apiKeyScopeAllowed(&auth.User{APIKeyID: "key", APIKeyScopes: []string{"chat"}}, "videos.upscales") {
t.Fatal("chat scope must not allow video upscale")
}
}
func TestFillDailyTokenUsageDaysKeepsGapsAndStreaks(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
from := time.Date(2026, 7, 20, 0, 0, 0, 0, location)
to := time.Date(2026, 7, 24, 0, 0, 0, 0, location)
items, summary := fillDailyTokenUsageDays(map[string]store.DailyTokenUsage{
"2026-07-20": {Date: "2026-07-20", TotalTokens: 10, TaskCount: 1},
"2026-07-22": {Date: "2026-07-22", TotalTokens: 20, TaskCount: 1},
"2026-07-23": {Date: "2026-07-23", TotalTokens: 30, TaskCount: 2},
"2026-07-24": {Date: "2026-07-24", TotalTokens: 40, TaskCount: 1},
}, from, to)
if len(items) != 5 || items[1].Date != "2026-07-21" || items[1].TaskCount != 0 {
t.Fatalf("daily usage should contain continuous zero days: %+v", items)
}
if summary.CumulativeTokens != 100 || summary.PeakDailyTokens != 40 || summary.CurrentStreakDays != 3 || summary.LongestStreakDays != 3 {
t.Fatalf("unexpected daily usage summary: %+v", summary)
}
}
func TestAdvancedMediaDefaultModels(t *testing.T) {
if got := canonicalTaskModelName("images.vectorize", ""); got != "easy-image-vectorizer-1" {
t.Fatalf("vectorizer default model=%q", got)
}
if got := canonicalTaskModelName("videos.upscales", ""); got != "easy-proteus-standard-4" {
t.Fatalf("Topaz default model=%q", got)
}
}
@@ -0,0 +1,45 @@
package httpapi
import "net/http"
// createImageVectorizeTask godoc
// @Summary 图片矢量化
// @Description 将 URL 位图转换为 SVG、EPS、PDF、DXF 或 PNG;设置 X-Async=true 时返回统一异步任务结构。可使用同一所有者历史任务的 vectorizerTaskId 复用上游 image token。
// @Tags images
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param input body ImageVectorizeRequest true "图片矢量化请求"
// @Success 200 {object} CompatibleResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/images/vectorize [post]
func (s *Server) createImageVectorizeTask() http.Handler {
return s.createTask("images.vectorize", true)
}
// createVideoUpscaleTask godoc
// @Summary 视频超分
// @Description 原生执行 Topaz 视频增强流程;设置 X-Async=true 时返回统一异步任务结构,结果在任务完成前持久化到 Gateway 文件存储。
// @Tags videos
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param input body VideoUpscaleRequest true "视频超分请求"
// @Success 200 {object} CompatibleResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/videos/upscales [post]
func (s *Server) createVideoUpscaleTask() http.Handler {
return s.createTask("videos.upscales", true)
}
@@ -0,0 +1,101 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type dailyTokenUsageSummary struct {
CumulativeTokens int64 `json:"cumulativeTokens"`
PeakDailyTokens int64 `json:"peakDailyTokens"`
CurrentStreakDays int `json:"currentStreakDays"`
LongestStreakDays int `json:"longestStreakDays"`
}
// dailyTokenUsage godoc
// @Summary 查询每日 Token 与资源点用量
// @Description 按 IANA 时区返回连续自然日用量;API Key 仅统计当前 Key,JWT 统计当前用户。
// @Tags workspace
// @Produce json
// @Security BearerAuth
// @Param from query string true "开始日期 YYYY-MM-DD"
// @Param to query string true "结束日期 YYYY-MM-DD"
// @Param timezone query string false "IANA 时区" default(UTC)
// @Success 200 {object} DailyTokenUsageResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/token-usage/daily [get]
func (s *Server) dailyTokenUsage(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
timezone := strings.TrimSpace(r.URL.Query().Get("timezone"))
if timezone == "" {
timezone = "UTC"
}
location, err := time.LoadLocation(timezone)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid IANA timezone")
return
}
from, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(r.URL.Query().Get("from")), location)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid from date")
return
}
to, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(r.URL.Query().Get("to")), location)
if err != nil || to.Before(from) || to.Sub(from) > 399*24*time.Hour {
writeError(w, http.StatusBadRequest, "invalid to date or date range exceeds 400 days")
return
}
stored, err := s.store.ListDailyTokenUsage(r.Context(), user, from, to, timezone)
if err != nil {
s.logger.Error("list daily token usage failed", "error", err)
writeError(w, http.StatusInternalServerError, "list daily token usage failed")
return
}
byDate := make(map[string]store.DailyTokenUsage, len(stored))
for _, item := range stored {
byDate[item.Date] = item
}
items, summary := fillDailyTokenUsageDays(byDate, from, to)
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "tokenDays": items, "summary": summary,
"range": map[string]string{"from": from.Format("2006-01-02"), "to": to.Format("2006-01-02"), "timezone": timezone},
})
}
func fillDailyTokenUsageDays(byDate map[string]store.DailyTokenUsage, from, to time.Time) ([]store.DailyTokenUsage, dailyTokenUsageSummary) {
items := make([]store.DailyTokenUsage, 0, 32)
summary := dailyTokenUsageSummary{}
currentRun := 0
for day := from; !day.After(to); day = day.AddDate(0, 0, 1) {
key := day.Format("2006-01-02")
item, found := byDate[key]
if !found {
item.Date = key
}
items = append(items, item)
summary.CumulativeTokens += item.TotalTokens
if item.TotalTokens > summary.PeakDailyTokens {
summary.PeakDailyTokens = item.TotalTokens
}
if item.TaskCount > 0 {
currentRun++
if currentRun > summary.LongestStreakDays {
summary.LongestStreakDays = currentRun
}
} else {
currentRun = 0
}
}
summary.CurrentStreakDays = currentRun
return items, summary
}
+45
View File
@@ -1395,6 +1395,12 @@ func apiKeyScopeAllowed(user *auth.User, kind string) bool {
if required == "voice_clone" && (scope == "audio" || scope == "text_to_speech" || scope == "speech" || scope == "tts") {
return true
}
if required == "image_vectorize" && (scope == "image" || scope == "vectorize") {
return true
}
if required == "video_enhance" && (scope == "video" || scope == "video_upscale" || scope == "upscale") {
return true
}
}
return false
}
@@ -1408,6 +1414,14 @@ func requestModelName(body map[string]any) string {
func canonicalTaskModelName(kind string, model string) string {
model = strings.TrimSpace(model)
if model == "" {
switch kind {
case "images.vectorize":
return "easy-image-vectorizer-1"
case "videos.upscales":
return "easy-proteus-standard-4"
}
}
if kind != "videos.generations" {
return model
}
@@ -1441,8 +1455,12 @@ func scopeForTaskKind(kind string) string {
return "rerank"
case "images.generations", "images.edits":
return "image"
case "images.vectorize":
return "image_vectorize"
case "videos.generations":
return "video"
case "videos.upscales":
return "video_enhance"
case "song.generations", "music.generations":
return "music"
case "speech.generations":
@@ -1764,8 +1782,17 @@ func boolValue(body map[string]any, key string) bool {
// @Router /api/workspace/tasks/{taskID} [get]
// @Router /api/v1/tasks/{taskID} [get]
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err == nil {
if !runner.TaskAccessibleToUser(task, user) {
writeError(w, http.StatusNotFound, "task not found")
return
}
cancelState := runner.DescribeTaskCancellation(task)
task.Cancellable = &cancelState.Cancellable
task.Submitted = &cancelState.Submitted
@@ -1832,6 +1859,11 @@ func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
// @Router /api/workspace/tasks/{taskID}/param-preprocessing [get]
// @Router /api/v1/tasks/{taskID}/param-preprocessing [get]
func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err != nil {
if store.IsNotFound(err) {
@@ -1842,6 +1874,10 @@ func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, "get task failed")
return
}
if !runner.TaskAccessibleToUser(task, user) {
writeError(w, http.StatusNotFound, "task not found")
return
}
logs, err := s.store.ListTaskParamPreprocessingLogs(r.Context(), task.ID)
if err != nil {
s.logger.Error("list task parameter preprocessing logs failed", "taskID", task.ID, "error", err)
@@ -1865,6 +1901,11 @@ func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request)
// @Router /api/workspace/tasks/{taskID}/events [get]
// @Router /api/v1/tasks/{taskID}/events [get]
func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err != nil {
if store.IsNotFound(err) {
@@ -1874,6 +1915,10 @@ func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "get task failed")
return
}
if !runner.TaskAccessibleToUser(task, user) {
writeError(w, http.StatusNotFound, "task not found")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@@ -1141,6 +1141,10 @@ func canonicalCapabilityFilterValue(value string) string {
return "text_embedding"
case "rerank", "reranks":
return "text_rerank"
case "vectorize", "image_vectorizer":
return "image_vectorize"
case "video_upscale", "upscale":
return "video_enhance"
case "model":
return "model_3d"
default:
@@ -1164,6 +1168,10 @@ func capabilityFilterValueForTag(tag string) string {
return "structured_output"
case "数字人":
return "digital_human"
case "图片矢量化":
return "image_vectorize"
case "视频增强":
return "video_enhance"
case "重排序":
return "text_rerank"
case "3D 模型":
@@ -1194,11 +1202,13 @@ func capabilityLabel(value string) string {
"image_generate": "图像生成",
"image_edit": "图像编辑",
"image_analysis": "图像分析",
"image_vectorize": "图片矢量化",
"video_generate": "视频生成",
"image_to_video": "图生视频",
"text_to_video": "文生视频",
"video_edit": "视频编辑",
"video_understanding": "视频理解",
"video_enhance": "视频增强",
"audio_generate": "音频生成",
"text_to_speech": "语音合成",
"voice_clone": "音色克隆",
+52 -10
View File
@@ -210,17 +210,31 @@ type PricingEstimateResponse struct {
RequestFingerprint string `json:"requestFingerprint" example:"76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"`
}
type DailyTokenUsageResponse struct {
Items []store.DailyTokenUsage `json:"items"`
TokenDays []store.DailyTokenUsage `json:"tokenDays"`
Summary dailyTokenUsageSummary `json:"summary"`
Range map[string]string `json:"range"`
}
type TaskRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages,omitempty"`
Input interface{} `json:"input,omitempty"`
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
TextFileID string `json:"text_file_id,omitempty" example:""`
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
Stream *bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages,omitempty"`
Input interface{} `json:"input,omitempty"`
Query string `json:"query,omitempty" example:"Which document mentions EasyAI Gateway?"`
Documents []string `json:"documents,omitempty"`
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
TextFileID string `json:"text_file_id,omitempty" example:""`
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
AudioURL string `json:"audio_url,omitempty" example:"https://example.com/source-voice.mp3"`
DisplayName string `json:"display_name,omitempty" example:"EasyAI DEV acceptance"`
PromptAudioURL string `json:"prompt_audio_url,omitempty" example:"https://example.com/prompt-voice.mp3"`
PromptText string `json:"prompt_text,omitempty" example:"EasyAI voice clone prompt."`
PreviewModel string `json:"preview_model,omitempty" example:"speech-2.8-hd"`
Stream *bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
// MaxCompletionTokens includes visible output and reasoning tokens.
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
@@ -368,6 +382,34 @@ type ImageEditRequest struct {
RunMode string `json:"runMode,omitempty" example:"simulation"`
}
type ImageVectorizeSource struct {
URL string `json:"url,omitempty" example:"https://example.com/source.png"`
VectorizerTaskID string `json:"vectorizerTaskId,omitempty" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
}
type ImageVectorizeRequest struct {
Model string `json:"model,omitempty" example:"easy-image-vectorizer-1"`
Source ImageVectorizeSource `json:"source"`
Format string `json:"format,omitempty" example:"svg" enums:"svg,eps,pdf,dxf,png"`
MaxColors int `json:"maxColors,omitempty" example:"16" enums:"0,2,4,8,16,32"`
CleanupLevel string `json:"cleanupLevel,omitempty" example:"standard" enums:"low,standard,strong"`
}
type VideoUpscaleRequest struct {
Model string `json:"model,omitempty" example:"easy-proteus-standard-4"`
VideoURL string `json:"video_url" example:"https://example.com/source.mp4"`
Operation string `json:"operation,omitempty" example:"upscale" enums:"upscale"`
TargetResolution string `json:"target_resolution,omitempty" example:"1080p"`
OutputWidth int `json:"output_width,omitempty" example:"1920"`
OutputHeight int `json:"output_height,omitempty" example:"1080"`
PreserveAudio *bool `json:"preserve_audio,omitempty" example:"true"`
Duration float64 `json:"duration,omitempty" example:"3"`
SourceResolution string `json:"source_resolution,omitempty" example:"480p"`
SourceFrameRate float64 `json:"source_frame_rate,omitempty" example:"24"`
TargetFrameRate float64 `json:"target_frame_rate,omitempty" example:"24"`
SlowMotionRate float64 `json:"slow_motion_rate,omitempty" example:"1"`
}
type VideoGenerationRequest struct {
Model string `json:"model" example:"video-model"`
Prompt string `json:"prompt" example:"A cinematic drone shot over mountains"`
@@ -161,7 +161,7 @@ func validPricingRuleSetInput(input store.PricingRuleSetInput) bool {
return false
}
switch calculator := strings.TrimSpace(rule.CalculatorType); calculator {
case "", "token_usage", "unit_weight", "duration_weight":
case "", "token_usage", "unit_weight", "duration_weight", "transition_matrix":
default:
return false
}
@@ -57,7 +57,11 @@ func TestOpenAPIPublicRoutesUseCanonicalV1Prefix(t *testing.T) {
"/api/v1/chat/completions",
"/api/v1/responses",
"/api/v1/images/generations",
"/api/v1/images/vectorize",
"/api/v1/videos/generations",
"/api/v1/videos/upscales",
"/api/v1/pricing/estimate",
"/api/workspace/token-usage/daily",
"/api/v1/models/{model}:generateContent",
"/api/v1/videos/omni-video",
"/api/v1/kling/v1/videos/omni-video",
+8
View File
@@ -195,6 +195,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("GET /api/workspace/user-groups", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups)))
mux.Handle("GET /api/workspace/wallet", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
mux.Handle("GET /api/workspace/wallet/transactions", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
mux.Handle("GET /api/workspace/token-usage/daily", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.dailyTokenUsage)))
mux.Handle("GET /api/workspace/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/workspace/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/workspace/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
@@ -261,7 +262,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /api/v1/images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", true)))
mux.Handle("POST /api/v1/videos/upscales", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /api/v1/video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration)))
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
@@ -311,6 +315,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /v1/images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /v1/video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
mux.Handle("GET /v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
mux.Handle("POST /api/v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
@@ -48,6 +48,7 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
// @Tags volces-compatible
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any
// @Router /api/v1/contents/generations/tasks/{taskID} [get]
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
@@ -104,6 +105,7 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
// @Tags volces-compatible
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any
// @Router /api/v1/contents/generations/tasks/{taskID} [delete]
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
@@ -170,6 +172,7 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
// @Tags volces-compatible
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any
// @Router /api/v1/ai/result/{taskID} [get]
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
@@ -0,0 +1,32 @@
package runner
import (
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestCandidateWithEnvironmentCredentialsResolvesNamesAtRuntime(t *testing.T) {
t.Setenv("TEST_GATEWAY_PROVIDER_SECRET", "secret-value")
candidate, err := candidateWithEnvironmentCredentials(store.RuntimeModelCandidate{
Credentials: map[string]any{"safe": "existing"},
PlatformConfig: map[string]any{"credentialEnv": map[string]any{
"apiKey": "TEST_GATEWAY_PROVIDER_SECRET",
}},
})
if err != nil {
t.Fatal(err)
}
if candidate.Credentials["apiKey"] != "secret-value" || candidate.Credentials["safe"] != "existing" {
t.Fatalf("credentials not resolved: %+v", candidate.Credentials)
}
}
func TestCandidateWithEnvironmentCredentialsDoesNotLeakMissingValue(t *testing.T) {
candidate := store.RuntimeModelCandidate{PlatformConfig: map[string]any{"credentialEnv": map[string]any{"apiKey": "MISSING_GATEWAY_PROVIDER_SECRET"}}}
_, err := candidateWithEnvironmentCredentials(candidate)
if err == nil || strings.Contains(err.Error(), "MISSING_GATEWAY_PROVIDER_SECRET") {
t.Fatalf("missing credential error should be generic: %v", err)
}
}
@@ -39,6 +39,20 @@ func TestVideoModelTypeInferenceReadsContentArray(t *testing.T) {
}
}
func TestAdvancedMediaModelTypeInference(t *testing.T) {
if got := modelTypeFromKind("images.vectorize", nil); got != "image_vectorize" {
t.Fatalf("images.vectorize model type = %s", got)
}
if got := modelTypeFromKind("videos.upscales", nil); got != "video_enhance" {
t.Fatalf("videos.upscales model type = %s", got)
}
for _, alias := range []string{"vectorize", "image-vectorizer", "image_vectorize"} {
if got := canonicalModelType(alias); got != "image_vectorize" {
t.Fatalf("canonical image vectorize alias %q = %q", alias, got)
}
}
}
func TestVideoContentTextContributesToTokenEstimate(t *testing.T) {
tokens := estimateRequestTokens(map[string]any{
"model": "demo-video",
+169
View File
@@ -3,6 +3,7 @@ package runner
import (
"context"
"math"
"strconv"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
@@ -131,6 +132,20 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
resource = "image_edit"
baseKey = "editBase"
}
if kind == "images.vectorize" {
resource = "image_vectorize"
unit = "conversion"
baseKey = "vectorizeBase"
}
if kind == "videos.upscales" {
resource = "video_enhance"
unit = "5s_video"
baseKey = "videoEnhanceBase"
inputs := resolveVideoEnhancePricingInputs(body, response)
amount, details := videoEnhanceAmount(config, inputs, float64(count), resourcePrice(config, resource, baseKey, "basePrice"))
amount = math.Ceil(amount*1e8) / 1e8 * discount
return []any{billingLineWithDetails(candidate, resource, unit, float64(count)*inputs.DurationUnits, roundPrice(amount), discount, simulated, details)}
}
if kind == "videos.generations" {
resource = "video"
unit = "5s_video"
@@ -193,6 +208,160 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
return []any{billingLine(candidate, resource, unit, count, roundPrice(amount), discount, simulated)}
}
type videoEnhancePricingInputs struct {
DurationSeconds, DurationUnits, SourceFrameRate, TargetFrameRate, SlowMotionRate float64
SourceResolution, TargetResolution, Model string
}
var videoEnhanceResolutionPixels = map[string]float64{
"480p": 854 * 480, "720p": 1280 * 720, "1080p": 1920 * 1080,
"1440p": 2560 * 1440, "2160p": 3840 * 2160,
}
var videoEnhanceResolutionBaseline = map[string]float64{"480p": 1, "720p": 1.4, "1080p": 1.8, "1440p": 2.1, "2160p": 2.6}
func resolveVideoEnhancePricingInputs(body map[string]any, response clients.Response) videoEnhancePricingInputs {
merged := cloneMap(body)
if data, ok := response.Result["data"].([]any); ok && len(data) > 0 {
if item, ok := data[0].(map[string]any); ok {
for _, key := range []string{"duration", "source_resolution", "target_resolution", "source_frame_rate", "target_frame_rate", "slow_motion_rate"} {
if merged[key] == nil && item[key] != nil {
merged[key] = item[key]
}
}
}
}
duration := floatFromAny(merged["duration"])
if duration <= 0 {
duration = 5
}
targetValue := firstNonEmptyString(stringFromMap(merged, "target_resolution"), stringFromMap(merged, "output_resolution"), stringFromMap(merged, "resolution"))
if dimensions := videoEnhanceDimensionsValue(merged, "output_width", "output_height"); dimensions != "" {
targetValue = dimensions
}
target := normalizeVideoEnhanceResolution(targetValue, "1080p")
sourceValue := firstNonEmptyString(stringFromMap(merged, "source_resolution"), stringFromMap(merged, "resolution"))
if dimensions := videoEnhanceDimensionsValue(merged, "source_width", "source_height"); dimensions != "" {
sourceValue = dimensions
}
source := normalizeVideoEnhanceResolution(sourceValue, target)
sourceFPS := floatFromAny(firstPresentValue(merged, "source_frame_rate", "frame_rate", "frameRate"))
if sourceFPS <= 0 {
sourceFPS = 24
}
targetFPS := floatFromAny(firstPresentValue(merged, "target_frame_rate", "output_frame_rate"))
if targetFPS <= 0 {
targetFPS = sourceFPS
}
slowMotion := floatFromAny(merged["slow_motion_rate"])
if slowMotion <= 0 {
slowMotion = 1
}
return videoEnhancePricingInputs{
DurationSeconds: duration, DurationUnits: math.Max(1, math.Round(duration)/5),
SourceResolution: source, TargetResolution: target,
SourceFrameRate: sourceFPS, TargetFrameRate: targetFPS, SlowMotionRate: slowMotion,
Model: firstNonEmptyString(stringFromMap(merged, "model"), stringFromMap(merged, "enhancement_model")),
}
}
func videoEnhanceDimensionsValue(values map[string]any, widthKey, heightKey string) string {
width := int(math.Round(floatFromAny(values[widthKey])))
height := int(math.Round(floatFromAny(values[heightKey])))
if width <= 0 || height <= 0 {
return ""
}
return strconv.Itoa(width) + "x" + strconv.Itoa(height)
}
func normalizeVideoEnhanceResolution(value, fallback string) string {
normalized := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), "_upscale")
if normalized == "2k" {
normalized = "1440p"
}
if normalized == "4k" {
normalized = "2160p"
}
if width, height, ok := parseVideoEnhanceDimensions(normalized); ok {
pixels := float64(width * height)
for _, bucket := range []string{"480p", "720p", "1080p", "1440p", "2160p"} {
if pixels <= videoEnhanceResolutionPixels[bucket] {
return bucket
}
}
return "2160p"
}
if _, ok := videoEnhanceResolutionPixels[normalized]; ok {
return normalized
}
if _, ok := videoEnhanceResolutionPixels[fallback]; ok {
return fallback
}
return "1080p"
}
func parseVideoEnhanceDimensions(value string) (int, int, bool) {
parts := strings.Split(strings.ReplaceAll(value, " ", ""), "x")
if len(parts) != 2 {
return 0, 0, false
}
width, widthErr := strconv.Atoi(parts[0])
height, heightErr := strconv.Atoi(parts[1])
return width, height, widthErr == nil && heightErr == nil && width > 0 && height > 0
}
func videoEnhanceAmount(config map[string]any, input videoEnhancePricingInputs, count, basePrice float64) (float64, map[string]any) {
transitionKey := input.SourceResolution + "->" + input.TargetResolution
minimumResolutionWeight := videoEnhanceResolutionBaseline[input.TargetResolution]
resolutionWeight, configuredResolution := pricingDynamicWeight(config, "video_enhance", "resolutionTransitions", transitionKey)
if !configuredResolution {
resolutionWeight = math.Max(minimumResolutionWeight, videoEnhanceResolutionPixels[input.TargetResolution]/videoEnhanceResolutionPixels[input.SourceResolution])
} else {
resolutionWeight = math.Max(minimumResolutionWeight, resolutionWeight)
}
sourceFPS := math.Max(1, math.Round(input.SourceFrameRate))
targetFPS := math.Max(1, math.Round(input.TargetFrameRate))
frameKey := strconv.Itoa(int(sourceFPS)) + "->" + strconv.Itoa(int(targetFPS))
minimumFrameWeight := math.Max(1, targetFPS/24)
frameWeight, configuredFrame := pricingDynamicWeight(config, "video_enhance", "frameRateTransitions", frameKey)
if !configuredFrame {
frameWeight = minimumFrameWeight
} else {
frameWeight = math.Max(minimumFrameWeight, frameWeight)
}
modelWeight, configuredModel := pricingDynamicWeight(config, "video_enhance", "modelWeights", input.Model)
if !configuredModel {
modelWeight = 1
}
markup, foundMarkup := pricingDynamicWeight(config, "video_enhance", "", "markup")
if !foundMarkup {
markup = 1
}
amount := count * input.DurationUnits * basePrice * resolutionWeight * frameWeight * input.SlowMotionRate * modelWeight * markup
return amount, map[string]any{
"count": count, "durationSeconds": input.DurationSeconds, "durationUnit": "5s", "durationUnitCount": input.DurationUnits,
"sourceResolution": input.SourceResolution, "targetResolution": input.TargetResolution,
"resolutionTransitionKey": transitionKey, "resolutionTransitionWeight": resolutionWeight, "resolutionTransitionFallback": !configuredResolution,
"sourceFrameRate": input.SourceFrameRate, "targetFrameRate": input.TargetFrameRate,
"frameRateTransitionKey": frameKey, "frameRateTransitionWeight": frameWeight, "frameRateTransitionFallback": !configuredFrame,
"slowMotionRate": input.SlowMotionRate, "model": input.Model, "modelWeight": modelWeight, "markup": markup,
}
}
func pricingDynamicWeight(config map[string]any, resource, group, key string) (float64, bool) {
resourceConfig, _ := config[resource].(map[string]any)
dynamic, _ := resourceConfig["dynamicWeight"].(map[string]any)
if group == "" {
value, ok := dynamic[key]
weight := floatFromAny(value)
return weight, ok && weight > 0
}
values, _ := dynamic[group].(map[string]any)
value, ok := values[key]
weight := floatFromAny(value)
return weight, ok && weight > 0
}
func (s *Service) effectiveBillingConfig(ctx context.Context, candidate store.RuntimeModelCandidate) map[string]any {
var inheritedRuleSetConfig map[string]any
if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" && s.store != nil {
+58
View File
@@ -93,6 +93,64 @@ func TestVideoBillingEstimateProratesFiveSecondUnitsAndDynamicWeights(t *testing
}
}
func TestAdvancedMediaBillingUsesConversionAndTransitionMatrix(t *testing.T) {
service := &Service{}
vectorCandidate := store.RuntimeModelCandidate{ModelName: "easy-image-vectorizer-1", BaseBillingConfig: map[string]any{
"image_vectorize": map[string]any{"basePrice": 200},
}}
vectorLine := firstBillingLine(t, service.billings(context.Background(), nil, "images.vectorize", map[string]any{"count": 1}, vectorCandidate, clients.Response{}, true))
if vectorLine["resourceType"] != "image_vectorize" || vectorLine["unit"] != "conversion" || floatFromAny(vectorLine["amount"]) != 200 {
t.Fatalf("unexpected vectorizer billing: %+v", vectorLine)
}
topazCandidate := store.RuntimeModelCandidate{ModelName: "easy-proteus-standard-4", BaseBillingConfig: map[string]any{
"video_enhance": map[string]any{
"basePrice": 100,
"dynamicWeight": map[string]any{
"resolutionTransitions": map[string]any{"720p->1080p": 1.8},
"frameRateTransitions": map[string]any{"24->60": 2.5},
"markup": 1,
},
},
}}
topazLine := firstBillingLine(t, service.billings(context.Background(), nil, "videos.upscales", map[string]any{
"count": 1, "duration": 5, "source_resolution": "720p", "target_resolution": "1080p",
"source_frame_rate": 24, "target_frame_rate": 60, "slow_motion_rate": 2,
"model": "easy-proteus-standard-4",
}, topazCandidate, clients.Response{}, true))
if got, want := floatFromAny(topazLine["amount"]), 900.0; got != want {
t.Fatalf("video enhance amount=%v, want=%v, line=%+v", got, want, topazLine)
}
if topazLine["resolutionTransitionKey"] != "720p->1080p" || topazLine["frameRateTransitionKey"] != "24->60" {
t.Fatalf("missing transition evidence: %+v", topazLine)
}
}
func TestVideoEnhancePricingFallbackUsesPixelsAndBaseline(t *testing.T) {
amount, details := videoEnhanceAmount(map[string]any{"video_enhance": map[string]any{"dynamicWeight": map[string]any{}}}, videoEnhancePricingInputs{
DurationSeconds: 5, DurationUnits: 1, SourceResolution: "720p", TargetResolution: "2160p",
SourceFrameRate: 30, TargetFrameRate: 60, SlowMotionRate: 1,
}, 1, 100)
if amount != 2250 {
t.Fatalf("fallback amount=%v, want=2250 details=%+v", amount, details)
}
}
func TestVideoEnhancePricingPrefersProbedDimensions(t *testing.T) {
inputs := resolveVideoEnhancePricingInputs(map[string]any{
"duration": 3,
"source_resolution": "180p",
"source_width": 320,
"source_height": 180,
"target_resolution": "720p",
"output_width": 1280,
"output_height": 720,
}, clients.Response{})
if inputs.SourceResolution != "480p" || inputs.TargetResolution != "720p" {
t.Fatalf("dimension buckets = %s->%s, want 480p->720p", inputs.SourceResolution, inputs.TargetResolution)
}
}
func TestMusicBillingUsesSongResourceAndOutputCount(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{
+22
View File
@@ -659,6 +659,28 @@ func (s *Service) billingsWithResolvedPricingV2(
resource = "image_edit"
baseKey = "editBase"
}
if kind == "images.vectorize" {
resource = "image_vectorize"
unit = "conversion"
baseKey = "vectorizeBase"
}
if kind == "videos.upscales" {
resource = "video_enhance"
unit = "5s_video"
baseKey = "videoEnhanceBase"
inputs := resolveVideoEnhancePricingInputs(body, response)
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
if priceErr != nil {
return nil, 0, resolvedPricing{}, priceErr
}
rawAmount, details := videoEnhanceAmount(pricing.Config, inputs, float64(count), price.Float64())
rawAmount = math.Ceil(rawAmount*1e8) / 1e8
amount, amountErr := fixedAmountFromAny(rawAmount * discount.Float64())
if amountErr != nil {
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, amountErr)
}
return []any{buildLine(resource, unit, float64(count)*inputs.DurationUnits, amount, details)}, amount, pricing, nil
}
if kind == "videos.generations" {
resource = "video"
unit = "5s_video"
+103 -1
View File
@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -90,6 +92,8 @@ func New(cfg config.Config, db *store.Store, logger *slog.Logger, observers ...b
"volces": clients.VolcesClient{HTTPClient: httpClients.none},
"keling": clients.KelingClient{HTTPClient: httpClients.none},
"kling": clients.KelingClient{HTTPClient: httpClients.none},
"vectorizer": clients.VectorizerClient{HTTPClient: httpClients.none},
"topaz": clients.TopazClient{HTTPClient: httpClients.none},
"universal": clients.UniversalClient{HTTPClient: httpClients.none, ScriptExecutor: scriptExecutor},
"simulation": clients.SimulationClient{},
},
@@ -857,6 +861,11 @@ func billingItemsFixedTotal(items []any) fixedAmount {
}
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, pricing resolvedPricing, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
var err error
candidate, err = candidateWithEnvironmentCredentials(candidate)
if err != nil {
return clients.Response{}, err
}
simulated := isSimulation(task, candidate)
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
reservations := s.rateLimitReservations(ctx, user, candidate, body)
@@ -949,6 +958,15 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
})
return clients.Response{}, err
}
providerBody, err = s.preparePrivateProviderRequest(ctx, user, task.Kind, providerBody)
if err != nil {
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
AttemptID: attemptID, Status: "failed", Retryable: false,
Metrics: mergeMetrics(baseAttemptMetrics, map[string]any{"error": err.Error(), "retryable": false}),
ErrorCode: clients.ErrorCode(err), ErrorMessage: err.Error(),
})
return clients.Response{}, err
}
providerBody, err = s.hydrateProviderRequestAssets(ctx, providerBody, candidate)
if err != nil {
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
@@ -1421,6 +1439,8 @@ func modelTypeFromKind(kind string, body map[string]any) string {
return "image_edit"
}
return "image_generate"
case "images.vectorize":
return "image_vectorize"
case "videos.generations":
if videoRequestHasVideoOrAudioReference(body) {
return "omni_video"
@@ -1429,6 +1449,8 @@ func modelTypeFromKind(kind string, body map[string]any) string {
return "image_to_video"
}
return "video_generate"
case "videos.upscales":
return "video_enhance"
case "song.generations", "music.generations":
return "audio_generate"
case "speech.generations":
@@ -1463,6 +1485,10 @@ func canonicalModelType(value string) string {
return "text_to_speech"
case "voice", "voice_clone", "voiceclone", "voice.cloning":
return "voice_clone"
case "vectorize", "image_vectorizer", "image_vectorize":
return "image_vectorize"
case "video_upscale", "video_enhance", "upscale":
return "video_enhance"
default:
return normalized
}
@@ -1470,7 +1496,7 @@ func canonicalModelType(value string) string {
func isKnownModelType(value string) bool {
switch value {
case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni", "audio_generate", "text_to_speech", "voice_clone":
case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "image_vectorize", "video_generate", "video_enhance", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni", "audio_generate", "text_to_speech", "voice_clone":
return true
default:
return false
@@ -1703,6 +1729,14 @@ func validateRequest(kind string, body map[string]any) error {
if strings.TrimSpace(stringFromMap(body, "prompt")) == "" {
return errors.New("prompt is required")
}
case "images.vectorize":
if vectorizerSourceURL(body) == "" && vectorizerSourceTaskID(body) == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "source.url or source.vectorizerTaskId is required", Param: "source", StatusCode: 400}
}
case "videos.upscales":
if firstNonEmptyString(stringFromMap(body, "video_url"), stringFromMap(body, "videoUrl")) == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "video_url is required", Param: "video_url", StatusCode: 400}
}
case "song.generations", "music.generations":
if strings.TrimSpace(stringFromMap(body, "prompt")) == "" {
return errors.New("prompt is required")
@@ -1722,6 +1756,74 @@ func validateRequest(kind string, body map[string]any) error {
return nil
}
var credentialEnvironmentName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`)
func candidateWithEnvironmentCredentials(candidate store.RuntimeModelCandidate) (store.RuntimeModelCandidate, error) {
references, _ := candidate.PlatformConfig["credentialEnv"].(map[string]any)
if len(references) == 0 {
references, _ = candidate.PlatformConfig["credential_env"].(map[string]any)
}
if len(references) == 0 {
return candidate, nil
}
credentials := cloneMap(candidate.Credentials)
for credentialName, rawEnvironmentName := range references {
environmentName := strings.TrimSpace(fmt.Sprint(rawEnvironmentName))
if !credentialEnvironmentName.MatchString(environmentName) {
return candidate, &clients.ClientError{Code: "missing_credentials", Message: "platform credential environment reference is invalid", Retryable: false}
}
value, ok := os.LookupEnv(environmentName)
if !ok || strings.TrimSpace(value) == "" {
return candidate, &clients.ClientError{Code: "missing_credentials", Message: "platform credential environment variable is not configured", Retryable: false}
}
credentials[credentialName] = value
}
candidate.Credentials = credentials
return candidate, nil
}
func (s *Service) preparePrivateProviderRequest(ctx context.Context, user *auth.User, kind string, body map[string]any) (map[string]any, error) {
if kind != "images.vectorize" {
return body, nil
}
taskID := vectorizerSourceTaskID(body)
if taskID == "" {
return body, nil
}
sourceTask, err := s.store.GetTask(ctx, taskID)
if err != nil || !taskAccessibleToUser(sourceTask, user) || sourceTask.Kind != "images.vectorize" {
return nil, &clients.ClientError{Code: "not_found", Message: "source vectorizer task not found", StatusCode: 404, Retryable: false}
}
imageToken := strings.TrimSpace(fmt.Sprint(sourceTask.RemoteTaskPayload["imageToken"]))
if imageToken == "" {
return nil, &clients.ClientError{Code: "invalid_parameter", Message: "source vectorizer task cannot be reused", Param: "source.vectorizerTaskId", StatusCode: 400, Retryable: false}
}
privateBody := cloneMap(body)
privateBody["_vectorizer_image_token"] = imageToken
if receipt := strings.TrimSpace(fmt.Sprint(sourceTask.RemoteTaskPayload["receipt"])); receipt != "" {
privateBody["_vectorizer_receipt"] = receipt
}
return privateBody, nil
}
func vectorizerSourceURL(body map[string]any) string {
if source, ok := body["source"].(map[string]any); ok {
return strings.TrimSpace(firstNonEmptyString(stringFromMap(source, "url"), stringFromMap(source, "image_url"), stringFromMap(source, "imageUrl")))
}
return strings.TrimSpace(firstNonEmptyString(stringFromMap(body, "image_url"), stringFromMap(body, "imageUrl")))
}
func vectorizerSourceTaskID(body map[string]any) string {
for _, key := range []string{"source", "sourceContext"} {
if source, ok := body[key].(map[string]any); ok {
if taskID := strings.TrimSpace(firstNonEmptyString(stringFromMap(source, "vectorizerTaskId"), stringFromMap(source, "vectorizer_task_id"), stringFromMap(source, "taskId"), stringFromMap(source, "task_id"))); taskID != "" {
return taskID
}
}
}
return strings.TrimSpace(firstNonEmptyString(stringFromMap(body, "vectorizerTaskId"), stringFromMap(body, "vectorizer_task_id")))
}
func hasRerankDocuments(value any) bool {
switch typed := value.(type) {
case []any:
+7
View File
@@ -211,6 +211,13 @@ func taskAccessibleToUser(task store.GatewayTask, user *auth.User) bool {
return userID != "" && strings.TrimSpace(task.UserID) == userID
}
// TaskAccessibleToUser applies the same ownership boundary to every public
// task surface. API-key requests are deliberately scoped to the exact key,
// while JWT requests may see the current gateway user's tasks.
func TaskAccessibleToUser(task store.GatewayTask, user *auth.User) bool {
return taskAccessibleToUser(task, user)
}
func gatewayUserIDForAuth(user *auth.User) string {
if user == nil {
return ""
+80 -3
View File
@@ -8,10 +8,12 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"net/url"
@@ -85,6 +87,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
if err != nil {
return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true}
}
// Topaz download URLs are short-lived. Persist them before the task can be
// marked succeeded even when the global policy normally keeps URL media.
if taskKind == "videos.upscales" {
policy.UploadURLMedia = true
}
decisions := make([]generatedAssetDecision, len(data))
needsUpload := false
changed := false
@@ -723,7 +730,8 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
if err != nil {
return nil, "", err
}
resp, err := http.DefaultClient.Do(req)
allowLocal := strings.HasPrefix(strings.TrimSpace(asset.URL), "/")
resp, err := generatedAssetHTTPClient(allowLocal).Do(req)
if err != nil {
return nil, "", &clients.ClientError{Code: "upload_source_fetch_failed", Message: err.Error(), Retryable: true}
}
@@ -752,6 +760,47 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
return payload, strings.TrimSpace(strings.Split(contentType, ";")[0]), nil
}
func generatedAssetHTTPClient(allowLocal bool) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, errors.New("generated media DNS resolution failed")
}
for _, address := range addresses {
if generatedAssetBlockedAddress(address.IP) && !(allowLocal && address.IP.IsLoopback()) {
return nil, errors.New("generated media resolved to a blocked network")
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
var attempts []error
for _, resolved := range addresses {
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return connection, nil
}
attempts = append(attempts, dialErr)
}
return nil, errors.Join(attempts...)
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Minute,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func generatedAssetBlockedAddress(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
func (s *Service) generatedAssetFetchURL(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
@@ -1304,6 +1353,9 @@ func mediaContentTypeFromItem(item map[string]any) string {
func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, contentType string) string {
contentType = strings.ToLower(strings.TrimSpace(contentType))
if generatedContentTypeIsDocument(contentType) {
return "file"
}
if strings.HasPrefix(contentType, "image/") {
return "image"
}
@@ -1314,6 +1366,9 @@ func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, c
return "audio"
}
itemType := strings.ToLower(strings.TrimSpace(stringFromAny(item["type"])))
if itemType == "file" || strings.Contains(itemType, "document") {
return "file"
}
if strings.Contains(itemType, "video") {
return "video"
}
@@ -1346,6 +1401,8 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
return "video/mp4"
case "audio":
return "audio/mpeg"
case "file":
return "application/octet-stream"
default:
return "image/png"
}
@@ -1354,10 +1411,10 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
func resolvedGeneratedAssetContentType(declared string, kind string, payload []byte) string {
declared = normalizeGeneratedContentType(declared)
detected := detectGeneratedAssetContentType(payload)
if generatedContentTypeIsMedia(detected) {
if generatedContentTypeIsMedia(detected) || generatedContentTypeIsDocument(detected) {
return detected
}
if generatedContentTypeIsMedia(declared) {
if generatedContentTypeIsMedia(declared) || generatedContentTypeIsDocument(declared) {
return declared
}
return defaultContentTypeForGeneratedAsset(kind)
@@ -1380,6 +1437,15 @@ func generatedContentTypeIsMedia(contentType string) bool {
strings.HasPrefix(contentType, "audio/")
}
func generatedContentTypeIsDocument(contentType string) bool {
switch normalizeGeneratedContentType(contentType) {
case "application/pdf", "application/postscript", "application/eps", "application/dxf", "image/vnd.dxf":
return true
default:
return false
}
}
func generatedAssetKindFromContentType(fallback string, contentType string) string {
contentType = normalizeGeneratedContentType(contentType)
if strings.HasPrefix(contentType, "image/") {
@@ -1391,6 +1457,9 @@ func generatedAssetKindFromContentType(fallback string, contentType string) stri
if strings.HasPrefix(contentType, "audio/") {
return "audio"
}
if generatedContentTypeIsDocument(contentType) {
return "file"
}
fallback = strings.ToLower(strings.TrimSpace(fallback))
if fallback != "" {
return fallback
@@ -1434,6 +1503,14 @@ func randomHexSuffix(byteCount int) string {
func fileExtensionForContentType(contentType string, kind string) string {
normalized := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
switch normalized {
case "application/pdf":
return ".pdf"
case "application/postscript", "application/eps":
return ".eps"
case "application/dxf", "image/vnd.dxf":
return ".dxf"
}
switch normalized {
case "image/jpeg", "image/jpg":
return ".jpg"
case "image/webp":
+19
View File
@@ -52,6 +52,25 @@ func TestGeneratedAssetDecisionUploadsInlineImageBase64(t *testing.T) {
}
}
func TestGeneratedAssetDecisionUploadsVectorDocumentWithoutChangingType(t *testing.T) {
item := map[string]any{
"type": "file",
"b64_json": base64.StdEncoding.EncodeToString([]byte("%PDF-1.7\n")),
"mime_type": "application/pdf",
}
decision, err := generatedAssetDecisionForItem("images.vectorize", item, defaultGeneratedAssetUploadPolicy())
if err != nil {
t.Fatal(err)
}
if decision.Inline == nil || decision.Inline.Kind != "file" || decision.Inline.ContentType != "application/pdf" {
t.Fatalf("unexpected vector document decision: %+v", decision)
}
contentType := resolvedGeneratedAssetContentType(decision.Inline.ContentType, decision.Inline.Kind, decision.Inline.Bytes)
if contentType != "application/pdf" || fileExtensionForContentType(contentType, "file") != ".pdf" {
t.Fatalf("vector document type changed: contentType=%s", contentType)
}
}
func TestGeneratedAssetDecisionUploadsInlineVideoBuffer(t *testing.T) {
item := map[string]any{
"type": "video",
+4
View File
@@ -215,6 +215,10 @@ func (s *Service) DeleteClonedVoice(ctx context.Context, user *auth.User, rawID
if !ok {
return DeletedClonedVoiceResult{}, &clients.ClientError{Code: "cloned_voice_platform_unavailable", Message: "cloned voice platform binding is unavailable", StatusCode: 400, Retryable: false}
}
candidate, err = candidateWithEnvironmentCredentials(candidate)
if err != nil {
return DeletedClonedVoiceResult{}, err
}
requestHTTPClient, err := s.httpClientForCandidate(candidate, false)
if err != nil {
return DeletedClonedVoiceResult{}, err
+4
View File
@@ -486,8 +486,12 @@ func modelTypeAliases(value string) []string {
return []string{"image_generate"}
case "images.edits":
return []string{"image_edit"}
case "images.vectorize", "vectorize":
return []string{"image_vectorize"}
case "video", "videos.generations":
return []string{"video_generate"}
case "videos.upscales", "video_upscale":
return []string{"video_enhance"}
case "omni_video":
return []string{"video_generate", "image_to_video", "omni_video"}
case "song", "music", "song.generations", "music.generations", "music_generate":
+68
View File
@@ -0,0 +1,68 @@
package store
import (
"context"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
)
type DailyTokenUsage struct {
Date string `json:"date"`
TotalTokens int64 `json:"totalTokens"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedInputTokens int64 `json:"cachedInputTokens"`
ResourcePoints float64 `json:"resourcePoints"`
TaskCount int64 `json:"taskCount"`
}
func (s *Store) ListDailyTokenUsage(ctx context.Context, user *auth.User, from, to time.Time, timezone string) ([]DailyTokenUsage, error) {
gatewayUserID := localGatewayUserID(user)
apiKeyID := ""
userID := ""
if user != nil {
apiKeyID = strings.TrimSpace(user.APIKeyID)
userID = strings.TrimSpace(user.ID)
}
if gatewayUserID == "" && userID == "" {
return nil, ErrLocalUserRequired
}
rows, err := s.pool.Query(ctx, `
SELECT to_char(created_at AT TIME ZONE $6, 'YYYY-MM-DD') AS usage_day,
COALESCE(sum(
CASE WHEN jsonb_typeof(usage->'totalTokens') = 'number' THEN (usage->>'totalTokens')::numeric
WHEN jsonb_typeof(usage->'total_tokens') = 'number' THEN (usage->>'total_tokens')::numeric
ELSE COALESCE(CASE WHEN jsonb_typeof(usage->'inputTokens') = 'number' THEN (usage->>'inputTokens')::numeric ELSE 0 END, 0)
+ COALESCE(CASE WHEN jsonb_typeof(usage->'outputTokens') = 'number' THEN (usage->>'outputTokens')::numeric ELSE 0 END, 0)
END
), 0)::bigint AS total_tokens,
COALESCE(sum(CASE WHEN jsonb_typeof(usage->'inputTokens') = 'number' THEN (usage->>'inputTokens')::numeric WHEN jsonb_typeof(usage->'input_tokens') = 'number' THEN (usage->>'input_tokens')::numeric ELSE 0 END), 0)::bigint,
COALESCE(sum(CASE WHEN jsonb_typeof(usage->'outputTokens') = 'number' THEN (usage->>'outputTokens')::numeric WHEN jsonb_typeof(usage->'output_tokens') = 'number' THEN (usage->>'output_tokens')::numeric ELSE 0 END), 0)::bigint,
COALESCE(sum(CASE WHEN jsonb_typeof(usage->'cachedInputTokens') = 'number' THEN (usage->>'cachedInputTokens')::numeric WHEN jsonb_typeof(usage->'cached_input_tokens') = 'number' THEN (usage->>'cached_input_tokens')::numeric ELSE 0 END), 0)::bigint,
COALESCE(sum(final_charge_amount), 0)::float8,
count(*)::bigint
FROM gateway_tasks
WHERE status = 'succeeded'
AND ((NULLIF($1, '')::uuid IS NOT NULL AND gateway_user_id = NULLIF($1, '')::uuid)
OR (NULLIF($1, '')::uuid IS NULL AND NULLIF($2, '') IS NOT NULL AND user_id = $2))
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
AND created_at >= ($4::date::timestamp AT TIME ZONE $6)
AND created_at < (($5::date + 1)::timestamp AT TIME ZONE $6)
GROUP BY usage_day
ORDER BY usage_day`, gatewayUserID, userID, apiKeyID, from.Format("2006-01-02"), to.Format("2006-01-02"), timezone)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]DailyTokenUsage, 0)
for rows.Next() {
var item DailyTokenUsage
if err := rows.Scan(&item.Date, &item.TotalTokens, &item.InputTokens, &item.OutputTokens, &item.CachedInputTokens, &item.ResourcePoints, &item.TaskCount); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
@@ -0,0 +1,79 @@
package store
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
)
func TestListDailyTokenUsageScopesAPIKeyAndAppliesIANATimezone(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run daily usage PostgreSQL integration tests")
}
ctx := context.Background()
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var databaseName string
if err := db.pool.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
t.Fatalf("refusing to use non-test database %q", databaseName)
}
suffix := fmt.Sprint(time.Now().UnixNano())
var userID, otherUserID string
if err := db.pool.QueryRow(ctx, `INSERT INTO gateway_users (user_key, username) VALUES ($1, $2) RETURNING id::text`, "daily-user-"+suffix, "daily-user-"+suffix).Scan(&userID); err != nil {
t.Fatal(err)
}
if err := db.pool.QueryRow(ctx, `INSERT INTO gateway_users (user_key, username) VALUES ($1, $2) RETURNING id::text`, "daily-other-"+suffix, "daily-other-"+suffix).Scan(&otherUserID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tasks WHERE gateway_user_id IN ($1::uuid, $2::uuid)`, userID, otherUserID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_users WHERE id IN ($1::uuid, $2::uuid)`, userID, otherUserID)
})
insertTask := func(ownerID, keyID, createdAt string, totalTokens int, points float64) {
t.Helper()
_, err := db.pool.Exec(ctx, `
INSERT INTO gateway_tasks (kind, user_id, gateway_user_id, api_key_id, model, status, usage, final_charge_amount, created_at, finished_at)
VALUES ('chat.completions', $1::text, ($1::text)::uuid, $2::text, 'daily-test-model', 'succeeded', jsonb_build_object('totalTokens', $3::bigint, 'inputTokens', $3::bigint - 1, 'outputTokens', 1), $4::numeric, $5::timestamptz, $5::timestamptz)`, ownerID, keyID, totalTokens, points, createdAt)
if err != nil {
t.Fatal(err)
}
}
insertTask(userID, "daily-key-a-"+suffix, "2026-07-20T15:30:00Z", 10, 1)
insertTask(userID, "daily-key-a-"+suffix, "2026-07-21T16:30:00Z", 20, 2)
insertTask(userID, "daily-key-b-"+suffix, "2026-07-20T16:30:00Z", 100, 10)
insertTask(otherUserID, "daily-key-a-"+suffix, "2026-07-20T16:30:00Z", 999, 99)
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
from := time.Date(2026, 7, 20, 0, 0, 0, 0, location)
to := time.Date(2026, 7, 22, 0, 0, 0, 0, location)
keyItems, err := db.ListDailyTokenUsage(ctx, &auth.User{GatewayUserID: userID, APIKeyID: "daily-key-a-" + suffix}, from, to, "Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
if len(keyItems) != 2 || keyItems[0].Date != "2026-07-20" || keyItems[0].TotalTokens != 10 || keyItems[1].Date != "2026-07-22" || keyItems[1].TotalTokens != 20 {
t.Fatalf("API key usage leaked another key/user or ignored timezone: %+v", keyItems)
}
userItems, err := db.ListDailyTokenUsage(ctx, &auth.User{GatewayUserID: userID}, from, to, "Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
if len(userItems) != 3 || userItems[1].Date != "2026-07-21" || userItems[1].TotalTokens != 100 {
t.Fatalf("JWT user usage did not include all owned API keys: %+v", userItems)
}
}
@@ -54,9 +54,13 @@ func billingResourcesForModelTypes(modelTypes []string) map[string]bool {
resources["image"] = true
case "images.edits", "image_edit":
resources["image_edit"] = true
case "image_vectorize", "images.vectorize":
resources["image_vectorize"] = true
case "video", "videos.generations", "video_generate", "image_to_video", "text_to_video",
"video_edit", "omni_video", "video_reference", "video_first_last_frame":
resources["video"] = true
case "video_enhance", "videos.upscales", "video_upscale":
resources["video_enhance"] = true
case "audio", "text_to_speech", "speech", "voice_clone":
resources["audio"] = true
case "music", "music_generate", "audio_generate":
@@ -100,8 +104,12 @@ func billingConfigKeyAllowed(key string, resources map[string]bool) bool {
return resources["image"]
case "image_edit", "imageedit", "editbase":
return resources["image_edit"]
case "image_vectorize", "imagevectorize", "vectorizebase":
return resources["image_vectorize"]
case "video", "videobase":
return resources["video"]
case "video_enhance", "videoenhance", "videoenhancebase":
return resources["video_enhance"]
case "audio", "audiobase":
return resources["audio"]
case "music", "musicbase":
+2 -2
View File
@@ -29,7 +29,7 @@ const (
)
func defaultAPIKeyScopes() []string {
return []string{"chat", "embedding", "rerank", "image", "video", "music", "audio", "voice_clone"}
return []string{"chat", "embedding", "rerank", "image", "image_vectorize", "video", "video_enhance", "music", "audio", "voice_clone"}
}
func normalizeAPIKeyScopes(scopes []string) []string {
@@ -517,7 +517,7 @@ type GatewayTask struct {
Message string `json:"message,omitempty"`
AttemptCount int `json:"attemptCount"`
RemoteTaskID string `json:"remoteTaskId,omitempty"`
RemoteTaskPayload map[string]any `json:"remoteTaskPayload,omitempty"`
RemoteTaskPayload map[string]any `json:"-"`
Result map[string]any `json:"result,omitempty"`
Billings []any `json:"billings,omitempty"`
Usage map[string]any `json:"usage"`
+7 -1
View File
@@ -331,7 +331,7 @@ ORDER BY priority ASC, resource_type ASC, rule_key ASC`, id)
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s currency does not match its rule set", ruleKey)
}
switch calculatorType {
case "token_usage", "unit_weight", "duration_weight":
case "token_usage", "unit_weight", "duration_weight", "transition_matrix":
default:
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s uses unsupported calculator %s", ruleKey, calculatorType)
}
@@ -386,8 +386,12 @@ func ValidateEffectivePricingRuleShape(resourceType string, unit string, calcula
allowed = unit == "1k_tokens" && calculatorType == "token_usage"
case "image", "image_edit":
allowed = unit == "image" && calculatorType == "unit_weight"
case "image_vectorize":
allowed = unit == "conversion" && calculatorType == "unit_weight"
case "video":
allowed = unit == "5s" && calculatorType == "duration_weight"
case "video_enhance":
allowed = unit == "5s" && calculatorType == "transition_matrix"
case "music":
allowed = (unit == "song" || unit == "item") && calculatorType == "unit_weight"
case "audio":
@@ -415,6 +419,8 @@ func DefaultEffectivePricingCalculator(resourceType string) string {
return "token_usage"
case "video":
return "duration_weight"
case "video_enhance":
return "transition_matrix"
default:
return "unit_weight"
}
@@ -13,6 +13,8 @@ func TestValidateEffectivePricingRuleShape(t *testing.T) {
{name: "text", resource: "text_input", unit: "1k_tokens", calculator: "token_usage", valid: true},
{name: "image", resource: "image", unit: "image", calculator: "unit_weight", valid: true},
{name: "video", resource: "video", unit: "5s", calculator: "duration_weight", valid: true},
{name: "video enhance", resource: "video_enhance", unit: "5s", calculator: "transition_matrix", valid: true},
{name: "image vectorize", resource: "image_vectorize", unit: "conversion", calculator: "unit_weight", valid: true},
{name: "wrong video unit", resource: "video", unit: "second", calculator: "duration_weight"},
{name: "wrong image calculator", resource: "image", unit: "image", calculator: "token_usage"},
}
@@ -0,0 +1,25 @@
package store
import (
"encoding/json"
"strings"
"testing"
)
func TestGatewayTaskJSONOmitsPrivateRemoteTaskPayload(t *testing.T) {
raw, err := json.Marshal(GatewayTask{
ID: "task-private-state",
RemoteTaskID: "safe-upstream-id",
RemoteTaskPayload: map[string]any{"imageToken": "private-image-token", "receipt": "private-receipt"},
})
if err != nil {
t.Fatal(err)
}
serialized := strings.ToLower(string(raw))
if strings.Contains(serialized, "private-image-token") || strings.Contains(serialized, "private-receipt") || strings.Contains(serialized, "remotetaskpayload") {
t.Fatalf("private provider recovery state leaked into task JSON: %s", serialized)
}
if !strings.Contains(serialized, "safe-upstream-id") {
t.Fatalf("public upstream task id should remain available: %s", serialized)
}
}
+1 -3
View File
@@ -528,13 +528,11 @@ WHERE id = $1::uuid
}
_, err = tx.Exec(ctx, `
UPDATE gateway_task_attempts
SET remote_task_id = NULLIF($2::text, ''),
response_snapshot = COALESCE(response_snapshot, '{}'::jsonb) || jsonb_build_object('remote_task_payload', $3::jsonb)
SET remote_task_id = NULLIF($2::text, '')
WHERE id = $1::uuid
AND status = 'running'`,
attemptID,
remoteTaskID,
string(payloadJSON),
)
return err
})
@@ -0,0 +1,338 @@
INSERT INTO model_catalog_providers (
provider_key, provider_code, display_name, provider_type, default_base_url,
default_auth_type, source, capability_schema, status, metadata
)
VALUES
('vectorizer', 'vectorizer', 'Vectorizer.AI', 'vectorizer', 'https://api.vectorizer.ai/api/v1',
'Basic', 'gateway', '{"modelTypes":["image_vectorize"]}'::jsonb, 'active', '{"source":"gateway.native"}'::jsonb),
('topaz', 'topaz', 'Topaz Labs', 'topaz', 'https://api.topazlabs.com',
'APIKey', 'gateway', '{"modelTypes":["video_enhance"]}'::jsonb, 'active', '{"source":"gateway.native"}'::jsonb)
ON CONFLICT (provider_key) DO UPDATE
SET provider_code = EXCLUDED.provider_code,
display_name = EXCLUDED.display_name,
provider_type = EXCLUDED.provider_type,
default_base_url = EXCLUDED.default_base_url,
default_auth_type = EXCLUDED.default_auth_type,
capability_schema = EXCLUDED.capability_schema,
updated_at = now();
INSERT INTO model_pricing_rule_sets (
rule_set_key, name, description, category, currency, status, metadata
)
VALUES (
'desktop-advanced-media-v1', '桌面端高级媒体定价',
'图片矢量化按次计费,视频超分按时长、分辨率、帧率、慢动作和模型权重计费。',
'media', 'resource', 'active', '{"source":"gateway.native","version":1}'::jsonb
)
ON CONFLICT (rule_set_key) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
currency = EXCLUDED.currency,
status = EXCLUDED.status,
metadata = EXCLUDED.metadata,
updated_at = now();
INSERT INTO model_pricing_rules (
rule_set_id, rule_key, display_name, scope_type, resource_type, unit,
base_price, currency, dynamic_weight, calculator_type, dimension_schema,
formula_config, priority, status, metadata
)
VALUES
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
'image_vectorize', '图片转矢量', 'model', 'image_vectorize', 'conversion',
200, 'resource', '{}'::jsonb, 'unit_weight',
'{"dimensions":["count"],"defaults":{"count":1}}'::jsonb,
'{"formula":"count * base_price"}'::jsonb, 10, 'active',
'{"operationType":"image_vectorize"}'::jsonb
),
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
'video_enhance', '视频增强', 'model', 'video_enhance', '5s',
100, 'resource',
'{
"resolutionTransitions": {
"480p->480p":1,"480p->720p":1.4,"480p->1080p":1.8,"480p->1440p":2.1,"480p->2160p":2.6,
"720p->720p":1.4,"720p->1080p":1.8,"720p->1440p":2.1,"720p->2160p":2.6,
"1080p->1080p":1.8,"1080p->1440p":2.1,"1080p->2160p":2.6,
"1440p->1440p":2.1,"1440p->2160p":2.6,"2160p->2160p":2.6
},
"frameRateTransitions": {
"24->24":1,"24->30":1.25,"24->60":2.5,"24->120":5,"24->240":10,
"30->30":1.25,"30->60":2.5,"30->120":5,"30->240":10,
"60->60":2.5,"60->120":5,"60->240":10,"120->120":5,"120->240":10,"240->240":10
},
"modelWeights": {
"easy-proteus-standard-4":1,"easy-starlight-fast-2":1,
"easy-starlight-hq-1":1,"easy-starlight-mini-1":1
},
"markup":1
}'::jsonb,
'transition_matrix',
'{"dimensions":["count","duration_seconds","source_resolution","target_resolution","source_frame_rate","target_frame_rate","slow_motion_rate","model"],"defaults":{"count":1,"duration_seconds":5,"source_resolution":"720p","target_resolution":"1080p","source_frame_rate":24,"target_frame_rate":24,"slow_motion_rate":1}}'::jsonb,
'{"formula":"ceil(count * max(1, round(duration_seconds) / 5) * base_price * resolution_transition_weight * frame_rate_transition_weight * slow_motion_rate * model_weight * markup)"}'::jsonb,
20, 'active', '{"operationType":"video_upscale"}'::jsonb
),
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
'music_generation', '音乐生成', 'model', 'music', 'song',
20, 'resource', '{}'::jsonb, 'unit_weight',
'{"dimensions":["count"],"defaults":{"count":1}}'::jsonb,
'{"formula":"count * base_price"}'::jsonb,
30, 'active', '{"operationType":"audio_generate"}'::jsonb
),
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1'),
'music_generation', '音乐生成', 'model', 'music', 'song',
20, 'resource', '{}'::jsonb, 'unit_weight',
'{"dimensions":["count"],"defaults":{"count":1}}'::jsonb,
'{"formula":"count * base_price"}'::jsonb,
60, 'active', '{"operationType":"audio_generate","source":"gateway.catalog.compat"}'::jsonb
)
ON CONFLICT (rule_set_id, rule_key) WHERE rule_set_id IS NOT NULL DO UPDATE
SET display_name = EXCLUDED.display_name,
resource_type = EXCLUDED.resource_type,
unit = EXCLUDED.unit,
base_price = EXCLUDED.base_price,
currency = EXCLUDED.currency,
dynamic_weight = EXCLUDED.dynamic_weight,
calculator_type = EXCLUDED.calculator_type,
dimension_schema = EXCLUDED.dimension_schema,
formula_config = EXCLUDED.formula_config,
priority = EXCLUDED.priority,
status = EXCLUDED.status,
metadata = EXCLUDED.metadata,
updated_at = now();
WITH model_defs(provider_key, canonical_key, provider_model_name, display_name, model_type, capabilities, billing_config, rate_limits, metadata) AS (
VALUES
(
'vectorizer', 'vectorizer:easy-image-vectorizer-1', 'easy-image-vectorizer-1', '图片转矢量 v1',
'["image_vectorize"]'::jsonb,
'{"image_vectorize":{"support_url_input":true,"support_base64_input":false,"input_format_allowed":["png","jpg","jpeg","webp","bmp","gif"],"output_format_allowed":["svg","eps","pdf","dxf","png"],"max_colors_options":[0,2,4,8,16,32],"cleanup_levels":["low","standard","strong"],"async_task":true},"originalTypes":["image_vectorize"]}'::jsonb,
'{"image_vectorize":{"basePrice":200,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":2,"leaseTtlSeconds":180}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"vectorizer","alias":"easy-image-vectorizer-1","description":"将位图转换为 SVG、EPS、PDF、DXF 或 PNG。","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-proteus-standard-4', 'prob-4', '标准视频超分 v4', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":2,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-proteus-standard-4","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-starlight-fast-2', 'slf-2', '快速视频超分 v2', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":2,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-starlight-fast-2","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-starlight-hq-1', 'slhq-1', '高质量视频超分 v1', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":1,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-starlight-hq-1","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-starlight-mini-1', 'slm-1', '轻量视频修复增强 v1', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale","restore","denoise"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":1,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-starlight-mini-1","selectable":true}'::jsonb
)
)
INSERT INTO base_model_catalog (
provider_id, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
capabilities, base_billing_config, default_rate_limit_policy, pricing_rule_set_id,
metadata, catalog_type, status
)
SELECT provider.id, defs.provider_key, defs.canonical_key, defs.provider_model_name, defs.model_type, defs.display_name,
defs.capabilities, defs.billing_config, defs.rate_limits,
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
defs.metadata, 'system', 'active'
FROM model_defs defs
JOIN model_catalog_providers provider ON provider.provider_key = defs.provider_key
ON CONFLICT (canonical_model_key) DO UPDATE
SET provider_id = EXCLUDED.provider_id,
provider_key = EXCLUDED.provider_key,
provider_model_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name ELSE base_model_catalog.provider_model_name END,
model_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.model_type ELSE base_model_catalog.model_type END,
display_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name ELSE base_model_catalog.display_name END,
capabilities = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.capabilities ELSE base_model_catalog.capabilities END,
base_billing_config = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.base_billing_config ELSE base_model_catalog.base_billing_config END,
default_rate_limit_policy = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_rate_limit_policy ELSE base_model_catalog.default_rate_limit_policy END,
pricing_rule_set_id = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.pricing_rule_set_id ELSE base_model_catalog.pricing_rule_set_id END,
metadata = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.metadata ELSE base_model_catalog.metadata END,
status = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'active' ELSE base_model_catalog.status END,
updated_at = now();
INSERT INTO integration_platforms (
provider, platform_key, name, base_url, auth_type, credentials, config,
default_pricing_mode, default_discount_factor, retry_policy, rate_limit_policy,
priority, status, disabled_reason
)
VALUES
(
'vectorizer', 'vectorizer-native', 'Vectorizer.AI Native', 'https://api.vectorizer.ai/api/v1', 'basic', '{}'::jsonb,
'{"sourceSpecType":"vectorizer","credentialEnv":{"accessKey":"VECTORIZER_API_ID","secretKey":"VECTORIZER_API_SECRET"},"mode":"production","retentionDays":"7"}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":2,"leaseTtlSeconds":180}]}'::jsonb,
120, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'topaz', 'topaz-native', 'Topaz Labs Native', 'https://api.topazlabs.com', 'api_key', '{}'::jsonb,
'{"sourceSpecType":"topaz","credentialEnv":{"apiKey":"TOPAZ_API_KEY"},"maxInputBytes":2147483648,"pollIntervalMs":15000,"pollTimeoutMs":3600000}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":2,"leaseTtlSeconds":7200}]}'::jsonb,
130, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'minimax', 'minimax-native-dev', 'MiniMax Native DEV', 'https://api.minimaxi.com/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"minimax","credentialEnv":{"apiKey":"MINIMAX_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
110, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'minimax-openai', 'minimax-openai-native-dev', 'MiniMax OpenAI Native DEV', 'https://api.minimaxi.com/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"openai","credentialEnv":{"apiKey":"MINIMAX_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
105, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'volces', 'volces-native-dev', 'Volcengine Native DEV', 'https://ark.cn-beijing.volces.com/api/v3', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"volces","credentialEnv":{"apiKey":"VOLCES_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":3,"leaseTtlSeconds":600}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'aliyun-bailian-openai', 'aliyun-bailian-openai-native-dev', 'Aliyun Bailian OpenAI Native DEV', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"openai","credentialEnv":{"apiKey":"ALIYUN_BAILIAN_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'aliyun-bailian-openai', 'aliyun-bailian-rerank-native-dev', 'Aliyun Bailian Rerank Native DEV', 'https://dashscope.aliyuncs.com/compatible-api/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"openai","credentialEnv":{"apiKey":"ALIYUN_BAILIAN_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'suno', 'suno-native-dev', 'Suno Native DEV', 'https://api.cqtai.com/api/cqt', 'api_key', '{}'::jsonb,
'{"sourceSpecType":"suno","credentialEnv":{"apiKey":"SUNO_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":30,"windowSeconds":60},{"metric":"concurrent","limit":2,"leaseTtlSeconds":1200}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
)
ON CONFLICT (platform_key) DO UPDATE
SET name = EXCLUDED.name,
base_url = EXCLUDED.base_url,
auth_type = EXCLUDED.auth_type,
credentials = '{}'::jsonb,
config = EXCLUDED.config,
default_pricing_mode = EXCLUDED.default_pricing_mode,
default_discount_factor = EXCLUDED.default_discount_factor,
retry_policy = EXCLUDED.retry_policy,
rate_limit_policy = EXCLUDED.rate_limit_policy,
priority = EXCLUDED.priority,
updated_at = now();
WITH platform_model_defs(platform_key, canonical_key, model_name, provider_model_name, model_alias) AS (
VALUES
('vectorizer-native', 'vectorizer:easy-image-vectorizer-1', 'easy-image-vectorizer-1', 'easy-image-vectorizer-1', 'easy-image-vectorizer-1'),
('topaz-native', 'topaz:easy-proteus-standard-4', 'easy-proteus-standard-4', 'prob-4', 'easy-proteus-standard-4'),
('topaz-native', 'topaz:easy-starlight-fast-2', 'easy-starlight-fast-2', 'slf-2', 'easy-starlight-fast-2'),
('topaz-native', 'topaz:easy-starlight-hq-1', 'easy-starlight-hq-1', 'slhq-1', 'easy-starlight-hq-1'),
('topaz-native', 'topaz:easy-starlight-mini-1', 'easy-starlight-mini-1', 'slm-1', 'easy-starlight-mini-1')
)
INSERT INTO platform_models (
platform_id, base_model_id, model_name, provider_model_name, model_alias, model_type,
display_name, capabilities, pricing_mode, pricing_rule_set_id, billing_config,
retry_policy, rate_limit_policy, enabled
)
SELECT platform.id, base.id, defs.model_name, defs.provider_model_name, defs.model_alias, base.model_type,
base.display_name, base.capabilities, 'inherit_discount', base.pricing_rule_set_id, base.base_billing_config,
'{"enabled":true,"maxAttempts":2}'::jsonb, base.default_rate_limit_policy, true
FROM platform_model_defs defs
JOIN integration_platforms platform ON platform.platform_key = defs.platform_key
JOIN base_model_catalog base ON base.canonical_model_key = defs.canonical_key
ON CONFLICT (platform_id, model_name) DO UPDATE
SET base_model_id = EXCLUDED.base_model_id,
provider_model_name = EXCLUDED.provider_model_name,
model_alias = EXCLUDED.model_alias,
model_type = EXCLUDED.model_type,
display_name = EXCLUDED.display_name,
capabilities = EXCLUDED.capabilities,
pricing_mode = EXCLUDED.pricing_mode,
pricing_rule_set_id = EXCLUDED.pricing_rule_set_id,
billing_config = EXCLUDED.billing_config,
retry_policy = EXCLUDED.retry_policy,
rate_limit_policy = EXCLUDED.rate_limit_policy,
enabled = EXCLUDED.enabled,
updated_at = now();
INSERT INTO platform_models (
platform_id, base_model_id, model_name, provider_model_name, model_alias, model_type,
display_name, capabilities, pricing_mode, pricing_rule_set_id, billing_config,
retry_policy, rate_limit_policy, enabled
)
SELECT platform.id, base.id, base.provider_model_name, base.provider_model_name,
COALESCE(NULLIF(base.metadata->>'alias', ''), base.provider_model_name), base.model_type,
base.display_name, base.capabilities, 'inherit_discount',
CASE WHEN platform.platform_key = 'suno-native-dev'
THEN (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1')
ELSE base.pricing_rule_set_id
END,
base.base_billing_config, '{"enabled":true,"maxAttempts":2}'::jsonb,
base.default_rate_limit_policy, true
FROM integration_platforms platform
JOIN base_model_catalog base ON
(platform.platform_key = 'minimax-native-dev'
AND base.provider_key = 'minimax'
AND base.model_type ?| ARRAY['text_to_speech', 'voice_clone', 'video_generate'])
OR
(platform.platform_key = 'minimax-openai-native-dev'
AND base.provider_key = 'minimax-openai'
AND base.model_type ?| ARRAY['text_generate'])
OR
(platform.platform_key = 'volces-native-dev'
AND base.provider_key = 'volces'
AND base.model_type ?| ARRAY['image_generate', 'image_edit'])
OR
(platform.platform_key = 'aliyun-bailian-openai-native-dev'
AND base.provider_key = 'aliyun-bailian-openai'
AND base.model_type ?| ARRAY['text_embedding'])
OR
(platform.platform_key = 'aliyun-bailian-rerank-native-dev'
AND base.provider_key = 'aliyun-bailian-openai'
AND base.model_type ?| ARRAY['text_rerank'])
OR
(platform.platform_key = 'suno-native-dev'
AND base.provider_key = 'suno'
AND base.model_type ?| ARRAY['audio_generate'])
WHERE platform.platform_key IN (
'minimax-native-dev', 'minimax-openai-native-dev', 'volces-native-dev',
'aliyun-bailian-openai-native-dev', 'aliyun-bailian-rerank-native-dev', 'suno-native-dev'
)
AND base.status = 'active'
ON CONFLICT (platform_id, model_name) DO UPDATE
SET base_model_id = EXCLUDED.base_model_id,
provider_model_name = EXCLUDED.provider_model_name,
model_alias = EXCLUDED.model_alias,
model_type = EXCLUDED.model_type,
display_name = EXCLUDED.display_name,
capabilities = EXCLUDED.capabilities,
pricing_mode = EXCLUDED.pricing_mode,
pricing_rule_set_id = EXCLUDED.pricing_rule_set_id,
billing_config = EXCLUDED.billing_config,
retry_policy = EXCLUDED.retry_policy,
rate_limit_policy = EXCLUDED.rate_limit_policy,
enabled = EXCLUDED.enabled,
updated_at = now();
+7
View File
@@ -0,0 +1,7 @@
services:
api:
env_file:
- ./.local-secrets/dev-real.env
environment:
APP_ENV: development
BILLING_ENGINE_MODE: ${AI_GATEWAY_COMPOSE_BILLING_ENGINE_MODE:-enforce}