diff --git a/Dockerfile b/Dockerfile index 96cd46f..a5248b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index fc68149..500e41f 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -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": {} diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 211c814..a57f9b6 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -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: 返回当前用户关联的用户组及策略摘要,供个人中心使用。 diff --git a/apps/api/internal/clients/advanced_media_test.go b/apps/api/internal/clients/advanced_media_test.go new file mode 100644 index 0000000..56252fd --- /dev/null +++ b/apps/api/internal/clients/advanced_media_test.go @@ -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, ``) + })) + 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 +} diff --git a/apps/api/internal/clients/topaz.go b/apps/api/internal/clients/topaz.go new file mode 100644 index 0000000..6df4085 --- /dev/null +++ b/apps/api/internal/clients/topaz.go @@ -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() +} diff --git a/apps/api/internal/clients/vectorizer.go b/apps/api/internal/clients/vectorizer.go new file mode 100644 index 0000000..cbde679 --- /dev/null +++ b/apps/api/internal/clients/vectorizer.go @@ -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) +} diff --git a/apps/api/internal/httpapi/advanced_media_test.go b/apps/api/internal/httpapi/advanced_media_test.go new file mode 100644 index 0000000..91389b2 --- /dev/null +++ b/apps/api/internal/httpapi/advanced_media_test.go @@ -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) + } +} diff --git a/apps/api/internal/httpapi/advanced_task_handlers.go b/apps/api/internal/httpapi/advanced_task_handlers.go new file mode 100644 index 0000000..b4dc5db --- /dev/null +++ b/apps/api/internal/httpapi/advanced_task_handlers.go @@ -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) +} diff --git a/apps/api/internal/httpapi/daily_usage_handlers.go b/apps/api/internal/httpapi/daily_usage_handlers.go new file mode 100644 index 0000000..014f607 --- /dev/null +++ b/apps/api/internal/httpapi/daily_usage_handlers.go @@ -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 +} diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index ecd68a1..5206a2c 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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") diff --git a/apps/api/internal/httpapi/model_catalog.go b/apps/api/internal/httpapi/model_catalog.go index 87deb2b..965326c 100644 --- a/apps/api/internal/httpapi/model_catalog.go +++ b/apps/api/internal/httpapi/model_catalog.go @@ -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": "音色克隆", diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 40cd84a..ba7d72d 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -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"` diff --git a/apps/api/internal/httpapi/pricing_handlers.go b/apps/api/internal/httpapi/pricing_handlers.go index bc99e1c..fc76df6 100644 --- a/apps/api/internal/httpapi/pricing_handlers.go +++ b/apps/api/internal/httpapi/pricing_handlers.go @@ -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 } diff --git a/apps/api/internal/httpapi/public_api_prefix_test.go b/apps/api/internal/httpapi/public_api_prefix_test.go index 1ef24a5..d054f88 100644 --- a/apps/api/internal/httpapi/public_api_prefix_test.go +++ b/apps/api/internal/httpapi/public_api_prefix_test.go @@ -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", diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 64bf06c..edc7df4 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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))) diff --git a/apps/api/internal/httpapi/volces_compat_handlers.go b/apps/api/internal/httpapi/volces_compat_handlers.go index 3d28035..1323c0d 100644 --- a/apps/api/internal/httpapi/volces_compat_handlers.go +++ b/apps/api/internal/httpapi/volces_compat_handlers.go @@ -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) { diff --git a/apps/api/internal/runner/credential_env_test.go b/apps/api/internal/runner/credential_env_test.go new file mode 100644 index 0000000..ff80e62 --- /dev/null +++ b/apps/api/internal/runner/credential_env_test.go @@ -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) + } +} diff --git a/apps/api/internal/runner/param_processor_test.go b/apps/api/internal/runner/param_processor_test.go index 1c53b44..73107ab 100644 --- a/apps/api/internal/runner/param_processor_test.go +++ b/apps/api/internal/runner/param_processor_test.go @@ -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", diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index 7026ac9..724acec 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -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 { diff --git a/apps/api/internal/runner/pricing_test.go b/apps/api/internal/runner/pricing_test.go index 8c36e79..47f7229 100644 --- a/apps/api/internal/runner/pricing_test.go +++ b/apps/api/internal/runner/pricing_test.go @@ -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{ diff --git a/apps/api/internal/runner/pricing_v2.go b/apps/api/internal/runner/pricing_v2.go index 62ab266..359105e 100644 --- a/apps/api/internal/runner/pricing_v2.go +++ b/apps/api/internal/runner/pricing_v2.go @@ -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" diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index 2211ccf..a2c1959 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -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: diff --git a/apps/api/internal/runner/task_cancel.go b/apps/api/internal/runner/task_cancel.go index 4f18d37..29e9491 100644 --- a/apps/api/internal/runner/task_cancel.go +++ b/apps/api/internal/runner/task_cancel.go @@ -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 "" diff --git a/apps/api/internal/runner/upload.go b/apps/api/internal/runner/upload.go index 9bf0dc0..a3fc312 100644 --- a/apps/api/internal/runner/upload.go +++ b/apps/api/internal/runner/upload.go @@ -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": diff --git a/apps/api/internal/runner/upload_test.go b/apps/api/internal/runner/upload_test.go index 56b1dac..97ee6fc 100644 --- a/apps/api/internal/runner/upload_test.go +++ b/apps/api/internal/runner/upload_test.go @@ -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", diff --git a/apps/api/internal/runner/voice_clone.go b/apps/api/internal/runner/voice_clone.go index 2be1172..97b594c 100644 --- a/apps/api/internal/runner/voice_clone.go +++ b/apps/api/internal/runner/voice_clone.go @@ -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 diff --git a/apps/api/internal/store/base_models.go b/apps/api/internal/store/base_models.go index 38067c1..e21bc30 100644 --- a/apps/api/internal/store/base_models.go +++ b/apps/api/internal/store/base_models.go @@ -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": diff --git a/apps/api/internal/store/daily_usage.go b/apps/api/internal/store/daily_usage.go new file mode 100644 index 0000000..469d177 --- /dev/null +++ b/apps/api/internal/store/daily_usage.go @@ -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() +} diff --git a/apps/api/internal/store/daily_usage_integration_test.go b/apps/api/internal/store/daily_usage_integration_test.go new file mode 100644 index 0000000..f629426 --- /dev/null +++ b/apps/api/internal/store/daily_usage_integration_test.go @@ -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) + } +} diff --git a/apps/api/internal/store/model_billing_filter.go b/apps/api/internal/store/model_billing_filter.go index 284fb1d..6f60337 100644 --- a/apps/api/internal/store/model_billing_filter.go +++ b/apps/api/internal/store/model_billing_filter.go @@ -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": diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 4840668..404601b 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -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"` diff --git a/apps/api/internal/store/pricing_rules.go b/apps/api/internal/store/pricing_rules.go index bbff1ad..a536230 100644 --- a/apps/api/internal/store/pricing_rules.go +++ b/apps/api/internal/store/pricing_rules.go @@ -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" } diff --git a/apps/api/internal/store/pricing_rules_test.go b/apps/api/internal/store/pricing_rules_test.go index 4c538cd..a2e4296 100644 --- a/apps/api/internal/store/pricing_rules_test.go +++ b/apps/api/internal/store/pricing_rules_test.go @@ -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"}, } diff --git a/apps/api/internal/store/private_task_state_test.go b/apps/api/internal/store/private_task_state_test.go new file mode 100644 index 0000000..b2118dd --- /dev/null +++ b/apps/api/internal/store/private_task_state_test.go @@ -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) + } +} diff --git a/apps/api/internal/store/tasks_runtime.go b/apps/api/internal/store/tasks_runtime.go index d350497..b034161 100644 --- a/apps/api/internal/store/tasks_runtime.go +++ b/apps/api/internal/store/tasks_runtime.go @@ -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 }) diff --git a/apps/api/migrations/0075_desktop_gateway_advanced_media.sql b/apps/api/migrations/0075_desktop_gateway_advanced_media.sql new file mode 100644 index 0000000..bbe9ca4 --- /dev/null +++ b/apps/api/migrations/0075_desktop_gateway_advanced_media.sql @@ -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(); diff --git a/docker-compose.dev-real.yml b/docker-compose.dev-real.yml new file mode 100644 index 0000000..cbb7aa8 --- /dev/null +++ b/docker-compose.dev-real.yml @@ -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}