feat(gateway): 补齐桌面端高级媒体直连接口 #25
+1
-1
@@ -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
@@ -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 仅统计当前 Key,JWT 统计当前用户。",
|
||||
"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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "音色克隆",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user