Compare commits
9 Commits
feature/op
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba419cd90a | |||
| d09a4c2e4d | |||
| 90c3315468 | |||
| ae197a742f | |||
| 34c3251c6d | |||
| 62d426bdfb | |||
| 7abb6a1baf | |||
| be283daaa3 | |||
| 2a9a833cd7 |
10
.gitignore
vendored
10
.gitignore
vendored
@ -15,3 +15,13 @@ coverage/
|
|||||||
.idea
|
.idea
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
|
# Devenv
|
||||||
|
.devenv*
|
||||||
|
devenv.local.nix
|
||||||
|
devenv.local.yaml
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|
||||||
|
# pre-commit
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|||||||
@ -2357,6 +2357,355 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/admin/system/file-storage/channels": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "返回所有未删除的文件存储通道,用于管理上传与生成资源回传策略。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"summary": "列出文件存储通道",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.FileStorageChannelListResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "创建文件存储通道,当前主要用于配置 server-main OpenAPI 上传通道。",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"summary": "创建文件存储通道",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "文件存储通道",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageChannelInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageChannel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/admin/system/file-storage/channels/{channelID}": {
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "软删除指定文件存储通道。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"summary": "删除文件存储通道",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件存储通道 ID",
|
||||||
|
"name": "channelID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "更新指定文件存储通道的名称、凭证、场景、优先级、状态和重试策略。",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"summary": "更新文件存储通道",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件存储通道 ID",
|
||||||
|
"name": "channelID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "文件存储通道",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageChannelInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageChannel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/admin/system/file-storage/settings": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "返回文件存储系统设置;数据库对象尚未创建时返回默认设置。",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"summary": "获取文件存储设置",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageSettings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "更新生成资源上传策略等文件存储系统设置。",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"summary": "更新文件存储设置",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "文件存储设置",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageSettingsInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageSettings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/admin/tenants": {
|
"/api/admin/tenants": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@ -3651,26 +4000,27 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "/api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream SSE;stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json",
|
||||||
|
"text/event-stream"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"tasks"
|
"tasks"
|
||||||
],
|
],
|
||||||
"summary": "创建或执行 AI 任务",
|
"summary": "创建 Chat Completions",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "true 时异步创建任务并返回 202",
|
"description": "该接口忽略此参数",
|
||||||
"name": "X-Async",
|
"name": "X-Async",
|
||||||
"in": "header"
|
"in": "header"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "AI 任务请求,字段随任务类型变化",
|
"description": "Chat Completions 请求",
|
||||||
"name": "input",
|
"name": "input",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@ -3683,13 +4033,7 @@
|
|||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/httpapi.CompatibleResponse"
|
"$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse"
|
||||||
}
|
|
||||||
},
|
|
||||||
"202": {
|
|
||||||
"description": "Accepted",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
@ -3737,6 +4081,74 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/files/upload": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "上传文件到配置的文件存储通道;没有启用通道时回退到本地静态上传目录。单文件最大 256MiB。",
|
||||||
|
"consumes": [
|
||||||
|
"multipart/form-data"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"files"
|
||||||
|
],
|
||||||
|
"summary": "上传文件",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"description": "要上传的文件",
|
||||||
|
"name": "file",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"default": "ai-gateway-openapi",
|
||||||
|
"description": "上传来源标识",
|
||||||
|
"name": "source",
|
||||||
|
"in": "formData"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.FileUploadResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Bad Gateway",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/images/edits": {
|
"/api/v1/images/edits": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@ -3744,7 +4156,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -3837,7 +4249,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -4236,7 +4648,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -4568,7 +4980,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5035,7 +5447,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5148,7 +5560,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5241,7 +5653,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5360,7 +5772,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5446,6 +5858,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/static/generated/{asset}": {
|
||||||
|
"get": {
|
||||||
|
"description": "从本地生成资源目录读取图片、视频等任务产物;不存在时返回 404。",
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"static"
|
||||||
|
],
|
||||||
|
"summary": "获取本地生成资源",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "资源文件名",
|
||||||
|
"name": "asset",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/static/simulation/{asset}": {
|
"/static/simulation/{asset}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "返回本地模拟模式使用的图片、视频封面或短视频资源。",
|
"description": "返回本地模拟模式使用的图片、视频封面或短视频资源。",
|
||||||
@ -5482,6 +5929,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/static/uploaded/{asset}": {
|
||||||
|
"get": {
|
||||||
|
"description": "从本地上传资源目录读取用户上传文件;不存在时返回 404。",
|
||||||
|
"produces": [
|
||||||
|
"application/octet-stream"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"static"
|
||||||
|
],
|
||||||
|
"summary": "获取本地上传资源",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "资源文件名",
|
||||||
|
"name": "asset",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/chat/completions": {
|
"/v1/chat/completions": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@ -5489,7 +5971,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5575,6 +6057,74 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v1/files/upload": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "上传文件到配置的文件存储通道;没有启用通道时回退到本地静态上传目录。单文件最大 256MiB。",
|
||||||
|
"consumes": [
|
||||||
|
"multipart/form-data"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"files"
|
||||||
|
],
|
||||||
|
"summary": "上传文件",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"description": "要上传的文件",
|
||||||
|
"name": "file",
|
||||||
|
"in": "formData",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"default": "ai-gateway-openapi",
|
||||||
|
"description": "上传来源标识",
|
||||||
|
"name": "source",
|
||||||
|
"in": "formData"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.FileUploadResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Bad Gateway",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v1/images/edits": {
|
"/v1/images/edits": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@ -5582,7 +6132,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5675,7 +6225,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5768,7 +6318,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@ -5996,6 +6546,82 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"httpapi.ChatCompletionChoice": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"finish_reason": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "stop"
|
||||||
|
},
|
||||||
|
"index": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 0
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"$ref": "#/definitions/httpapi.ChatCompletionChoiceMessage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"httpapi.ChatCompletionChoiceMessage": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Hello"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"httpapi.ChatCompletionCompatibleResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"choices": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/httpapi.ChatCompletionChoice"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1710000000
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "chatcmpl-123"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "gpt-4o-mini"
|
||||||
|
},
|
||||||
|
"object": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "chat.completion"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"$ref": "#/definitions/httpapi.ChatCompletionUsage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"httpapi.ChatCompletionUsage": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"completion_tokens": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 8
|
||||||
|
},
|
||||||
|
"prompt_tokens": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 12
|
||||||
|
},
|
||||||
|
"total_tokens": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"httpapi.ChatMessage": {
|
"httpapi.ChatMessage": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -6062,6 +6688,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"httpapi.FileStorageChannelListResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/store.FileStorageChannel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"httpapi.FileUploadResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"assetStorage": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"contentType": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "image/png"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "image.png"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "file_abc123"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1024
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "/static/uploaded/upload-abc123.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"httpapi.HealthResponse": {
|
"httpapi.HealthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -7407,6 +8073,121 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"store.FileStorageChannel": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"channelKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"credentialsPreview": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastError": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastFailedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lastSucceededAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retryPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
|
},
|
||||||
|
"scenes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"uploadUrl": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"store.FileStorageChannelInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"apiKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"channelKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retryPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
|
},
|
||||||
|
"scenes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"uploadUrl": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"store.FileStorageSettings": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resultUploadPolicy": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"store.FileStorageSettingsInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resultUploadPolicy": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"store.GatewayTask": {
|
"store.GatewayTask": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -92,6 +92,59 @@ definitions:
|
|||||||
$ref: '#/definitions/store.CatalogProvider'
|
$ref: '#/definitions/store.CatalogProvider'
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
|
httpapi.ChatCompletionChoice:
|
||||||
|
properties:
|
||||||
|
finish_reason:
|
||||||
|
example: stop
|
||||||
|
type: string
|
||||||
|
index:
|
||||||
|
example: 0
|
||||||
|
type: integer
|
||||||
|
message:
|
||||||
|
$ref: '#/definitions/httpapi.ChatCompletionChoiceMessage'
|
||||||
|
type: object
|
||||||
|
httpapi.ChatCompletionChoiceMessage:
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
example: Hello
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
example: assistant
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
httpapi.ChatCompletionCompatibleResponse:
|
||||||
|
properties:
|
||||||
|
choices:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/httpapi.ChatCompletionChoice'
|
||||||
|
type: array
|
||||||
|
created:
|
||||||
|
example: 1710000000
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
|
example: chatcmpl-123
|
||||||
|
type: string
|
||||||
|
model:
|
||||||
|
example: gpt-4o-mini
|
||||||
|
type: string
|
||||||
|
object:
|
||||||
|
example: chat.completion
|
||||||
|
type: string
|
||||||
|
usage:
|
||||||
|
$ref: '#/definitions/httpapi.ChatCompletionUsage'
|
||||||
|
type: object
|
||||||
|
httpapi.ChatCompletionUsage:
|
||||||
|
properties:
|
||||||
|
completion_tokens:
|
||||||
|
example: 8
|
||||||
|
type: integer
|
||||||
|
prompt_tokens:
|
||||||
|
example: 12
|
||||||
|
type: integer
|
||||||
|
total_tokens:
|
||||||
|
example: 20
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
httpapi.ChatMessage:
|
httpapi.ChatMessage:
|
||||||
properties:
|
properties:
|
||||||
content:
|
content:
|
||||||
@ -138,6 +191,34 @@ definitions:
|
|||||||
example: 400
|
example: 400
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
httpapi.FileStorageChannelListResponse:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/store.FileStorageChannel'
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
httpapi.FileUploadResponse:
|
||||||
|
properties:
|
||||||
|
assetStorage:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
contentType:
|
||||||
|
example: image/png
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
example: image.png
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
example: file_abc123
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
example: 1024
|
||||||
|
type: integer
|
||||||
|
url:
|
||||||
|
example: /static/uploaded/upload-abc123.png
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
httpapi.HealthResponse:
|
httpapi.HealthResponse:
|
||||||
properties:
|
properties:
|
||||||
env:
|
env:
|
||||||
@ -1045,6 +1126,83 @@ definitions:
|
|||||||
secret:
|
secret:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
store.FileStorageChannel:
|
||||||
|
properties:
|
||||||
|
channelKey:
|
||||||
|
type: string
|
||||||
|
config:
|
||||||
|
additionalProperties: {}
|
||||||
|
type: object
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
credentialsPreview:
|
||||||
|
additionalProperties: {}
|
||||||
|
type: object
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
lastError:
|
||||||
|
type: string
|
||||||
|
lastFailedAt:
|
||||||
|
type: string
|
||||||
|
lastSucceededAt:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
retryPolicy:
|
||||||
|
additionalProperties: {}
|
||||||
|
type: object
|
||||||
|
scenes:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
uploadUrl:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
store.FileStorageChannelInput:
|
||||||
|
properties:
|
||||||
|
apiKey:
|
||||||
|
type: string
|
||||||
|
channelKey:
|
||||||
|
type: string
|
||||||
|
config:
|
||||||
|
additionalProperties: {}
|
||||||
|
type: object
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
retryPolicy:
|
||||||
|
additionalProperties: {}
|
||||||
|
type: object
|
||||||
|
scenes:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
uploadUrl:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
store.FileStorageSettings:
|
||||||
|
properties:
|
||||||
|
resultUploadPolicy:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
store.FileStorageSettingsInput:
|
||||||
|
properties:
|
||||||
|
resultUploadPolicy:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
store.GatewayTask:
|
store.GatewayTask:
|
||||||
properties:
|
properties:
|
||||||
apiKeyId:
|
apiKeyId:
|
||||||
@ -3644,6 +3802,229 @@ paths:
|
|||||||
summary: 更新 Runner 策略
|
summary: 更新 Runner 策略
|
||||||
tags:
|
tags:
|
||||||
- runtime
|
- runtime
|
||||||
|
/api/admin/system/file-storage/channels:
|
||||||
|
get:
|
||||||
|
description: 返回所有未删除的文件存储通道,用于管理上传与生成资源回传策略。
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.FileStorageChannelListResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 列出文件存储通道
|
||||||
|
tags:
|
||||||
|
- system
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: 创建文件存储通道,当前主要用于配置 server-main OpenAPI 上传通道。
|
||||||
|
parameters:
|
||||||
|
- description: 文件存储通道
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.FileStorageChannelInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.FileStorageChannel'
|
||||||
|
"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'
|
||||||
|
"409":
|
||||||
|
description: Conflict
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 创建文件存储通道
|
||||||
|
tags:
|
||||||
|
- system
|
||||||
|
/api/admin/system/file-storage/channels/{channelID}:
|
||||||
|
delete:
|
||||||
|
description: 软删除指定文件存储通道。
|
||||||
|
parameters:
|
||||||
|
- description: 文件存储通道 ID
|
||||||
|
in: path
|
||||||
|
name: channelID
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: No Content
|
||||||
|
"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'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 删除文件存储通道
|
||||||
|
tags:
|
||||||
|
- system
|
||||||
|
patch:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: 更新指定文件存储通道的名称、凭证、场景、优先级、状态和重试策略。
|
||||||
|
parameters:
|
||||||
|
- description: 文件存储通道 ID
|
||||||
|
in: path
|
||||||
|
name: channelID
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: 文件存储通道
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.FileStorageChannelInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.FileStorageChannel'
|
||||||
|
"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'
|
||||||
|
"409":
|
||||||
|
description: Conflict
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 更新文件存储通道
|
||||||
|
tags:
|
||||||
|
- system
|
||||||
|
/api/admin/system/file-storage/settings:
|
||||||
|
get:
|
||||||
|
description: 返回文件存储系统设置;数据库对象尚未创建时返回默认设置。
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.FileStorageSettings'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"403":
|
||||||
|
description: Forbidden
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 获取文件存储设置
|
||||||
|
tags:
|
||||||
|
- system
|
||||||
|
patch:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: 更新生成资源上传策略等文件存储系统设置。
|
||||||
|
parameters:
|
||||||
|
- description: 文件存储设置
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.FileStorageSettingsInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/store.FileStorageSettings'
|
||||||
|
"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'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 更新文件存储设置
|
||||||
|
tags:
|
||||||
|
- system
|
||||||
/api/admin/tenants:
|
/api/admin/tenants:
|
||||||
get:
|
get:
|
||||||
description: 管理端返回网关租户列表。
|
description: 管理端返回网关租户列表。
|
||||||
@ -4472,14 +4853,14 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: /api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream
|
||||||
SSE 流。
|
SSE;stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: 该接口忽略此参数
|
||||||
in: header
|
in: header
|
||||||
name: X-Async
|
name: X-Async
|
||||||
type: boolean
|
type: boolean
|
||||||
- description: AI 任务请求,字段随任务类型变化
|
- description: Chat Completions 请求
|
||||||
in: body
|
in: body
|
||||||
name: input
|
name: input
|
||||||
required: true
|
required: true
|
||||||
@ -4487,15 +4868,12 @@ paths:
|
|||||||
$ref: '#/definitions/httpapi.TaskRequest'
|
$ref: '#/definitions/httpapi.TaskRequest'
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
|
- text/event-stream
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/httpapi.CompatibleResponse'
|
$ref: '#/definitions/httpapi.ChatCompletionCompatibleResponse'
|
||||||
"202":
|
|
||||||
description: Accepted
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
|
|
||||||
"400":
|
"400":
|
||||||
description: Bad Request
|
description: Bad Request
|
||||||
schema:
|
schema:
|
||||||
@ -4526,15 +4904,59 @@ paths:
|
|||||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 创建或执行 AI 任务
|
summary: 创建 Chat Completions
|
||||||
tags:
|
tags:
|
||||||
- tasks
|
- tasks
|
||||||
|
/api/v1/files/upload:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- multipart/form-data
|
||||||
|
description: 上传文件到配置的文件存储通道;没有启用通道时回退到本地静态上传目录。单文件最大 256MiB。
|
||||||
|
parameters:
|
||||||
|
- description: 要上传的文件
|
||||||
|
in: formData
|
||||||
|
name: file
|
||||||
|
required: true
|
||||||
|
type: file
|
||||||
|
- default: ai-gateway-openapi
|
||||||
|
description: 上传来源标识
|
||||||
|
in: formData
|
||||||
|
name: source
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.FileUploadResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"502":
|
||||||
|
description: Bad Gateway
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"503":
|
||||||
|
description: Service Unavailable
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 上传文件
|
||||||
|
tags:
|
||||||
|
- files
|
||||||
/api/v1/images/edits:
|
/api/v1/images/edits:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -4594,8 +5016,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -4848,8 +5270,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5062,8 +5484,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5363,8 +5785,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5437,8 +5859,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5498,8 +5920,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5576,8 +5998,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5633,6 +6055,29 @@ paths:
|
|||||||
summary: 创建或执行 AI 任务
|
summary: 创建或执行 AI 任务
|
||||||
tags:
|
tags:
|
||||||
- tasks
|
- tasks
|
||||||
|
/static/generated/{asset}:
|
||||||
|
get:
|
||||||
|
description: 从本地生成资源目录读取图片、视频等任务产物;不存在时返回 404。
|
||||||
|
parameters:
|
||||||
|
- description: 资源文件名
|
||||||
|
in: path
|
||||||
|
name: asset
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/octet-stream
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: 获取本地生成资源
|
||||||
|
tags:
|
||||||
|
- static
|
||||||
/static/simulation/{asset}:
|
/static/simulation/{asset}:
|
||||||
get:
|
get:
|
||||||
description: 返回本地模拟模式使用的图片、视频封面或短视频资源。
|
description: 返回本地模拟模式使用的图片、视频封面或短视频资源。
|
||||||
@ -5657,12 +6102,35 @@ paths:
|
|||||||
summary: 获取模拟资源
|
summary: 获取模拟资源
|
||||||
tags:
|
tags:
|
||||||
- simulation
|
- simulation
|
||||||
|
/static/uploaded/{asset}:
|
||||||
|
get:
|
||||||
|
description: 从本地上传资源目录读取用户上传文件;不存在时返回 404。
|
||||||
|
parameters:
|
||||||
|
- description: 资源文件名
|
||||||
|
in: path
|
||||||
|
name: asset
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/octet-stream
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
type: file
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
summary: 获取本地上传资源
|
||||||
|
tags:
|
||||||
|
- static
|
||||||
/v1/chat/completions:
|
/v1/chat/completions:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5718,12 +6186,56 @@ paths:
|
|||||||
summary: 创建或执行 AI 任务
|
summary: 创建或执行 AI 任务
|
||||||
tags:
|
tags:
|
||||||
- tasks
|
- tasks
|
||||||
|
/v1/files/upload:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- multipart/form-data
|
||||||
|
description: 上传文件到配置的文件存储通道;没有启用通道时回退到本地静态上传目录。单文件最大 256MiB。
|
||||||
|
parameters:
|
||||||
|
- description: 要上传的文件
|
||||||
|
in: formData
|
||||||
|
name: file
|
||||||
|
required: true
|
||||||
|
type: file
|
||||||
|
- default: ai-gateway-openapi
|
||||||
|
description: 上传来源标识
|
||||||
|
in: formData
|
||||||
|
name: source
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.FileUploadResponse'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"502":
|
||||||
|
description: Bad Gateway
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
"503":
|
||||||
|
description: Service Unavailable
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 上传文件
|
||||||
|
tags:
|
||||||
|
- files
|
||||||
/v1/images/edits:
|
/v1/images/edits:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5783,8 +6295,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
@ -5844,8 +6356,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或
|
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible
|
||||||
SSE 流。
|
路径同步返回兼容响应或 SSE 流。
|
||||||
parameters:
|
parameters:
|
||||||
- description: true 时异步创建任务并返回 202
|
- description: true 时异步创建任务并返回 202
|
||||||
in: header
|
in: header
|
||||||
|
|||||||
@ -110,6 +110,7 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
|||||||
t.Fatalf("decode request: %v", err)
|
t.Fatalf("decode request: %v", err)
|
||||||
}
|
}
|
||||||
gotModel, _ = body["model"].(string)
|
gotModel, _ = body["model"].(string)
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"id": "chatcmpl-test",
|
"id": "chatcmpl-test",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
@ -145,6 +146,9 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
|||||||
if response.RequestID != "req-chat-test" || response.ResponseStartedAt.IsZero() || response.ResponseFinishedAt.IsZero() {
|
if response.RequestID != "req-chat-test" || response.ResponseStartedAt.IsZero() || response.ResponseFinishedAt.IsZero() {
|
||||||
t.Fatalf("response metadata was not captured: %+v", response)
|
t.Fatalf("response metadata was not captured: %+v", response)
|
||||||
}
|
}
|
||||||
|
if response.ResponseDurationMS < 20 {
|
||||||
|
t.Fatalf("response duration should include upstream latency, got %dms", response.ResponseDurationMS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIClientChatStreamContract(t *testing.T) {
|
func TestOpenAIClientChatStreamContract(t *testing.T) {
|
||||||
@ -662,6 +666,266 @@ func TestVolcesClientVideoResumePollsExistingTaskID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKelingClientVideoSubmitsAndPollsImageTask(t *testing.T) {
|
||||||
|
var submitPath string
|
||||||
|
var pollPath string
|
||||||
|
var gotAuth string
|
||||||
|
var submittedTaskID string
|
||||||
|
var submittedPayload map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
switch r.Method + " " + r.URL.Path {
|
||||||
|
case "POST /videos/image2video":
|
||||||
|
submitPath = r.URL.Path
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&submittedPayload); err != nil {
|
||||||
|
t.Fatalf("decode keling submit: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := submittedPayload["aspect_ratio"]; ok {
|
||||||
|
t.Fatalf("image2video payload should not include aspect_ratio: %+v", submittedPayload)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"request_id": "req-submit",
|
||||||
|
"data": map[string]any{"task_id": "keling-task-1"},
|
||||||
|
})
|
||||||
|
case "GET /videos/image2video/keling-task-1":
|
||||||
|
pollPath = r.URL.Path
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"request_id": "req-poll",
|
||||||
|
"data": map[string]any{
|
||||||
|
"task_id": "keling-task-1",
|
||||||
|
"task_status": "succeed",
|
||||||
|
"created_at": 456,
|
||||||
|
"task_result": map[string]any{
|
||||||
|
"videos": []any{map[string]any{"url": "https://example.com/keling.mp4", "duration": 6}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "image_to_video",
|
||||||
|
Model: "可灵2.6",
|
||||||
|
Body: map[string]any{
|
||||||
|
"model": "可灵2.6",
|
||||||
|
"prompt": "A clean product reveal",
|
||||||
|
"first_frame": "data:image/png;base64,Zmlyc3Q=",
|
||||||
|
"last_frame": "data:image/png;base64,bGFzdA==",
|
||||||
|
"duration": 6,
|
||||||
|
"resolution": "1080p",
|
||||||
|
"aspect_ratio": "16:9",
|
||||||
|
"audio": true,
|
||||||
|
"camera_control": "simple:zoom",
|
||||||
|
"camera_control_strength": 0.6,
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Provider: "keling",
|
||||||
|
AuthType: "AccessKey-SecretKey",
|
||||||
|
ModelName: "可灵2.6",
|
||||||
|
ProviderModelName: "kling-v2-6",
|
||||||
|
Credentials: map[string]any{"accessKey": "ak", "secretKey": "sk"},
|
||||||
|
PlatformConfig: map[string]any{
|
||||||
|
"kelingPollIntervalMs": 100,
|
||||||
|
"kelingPollTimeoutSeconds": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
|
||||||
|
submittedTaskID = remoteTaskID
|
||||||
|
if payload["endpoint"] != "/videos/image2video" || payload["taskType"] != "image2video" {
|
||||||
|
t.Fatalf("unexpected submitted keling payload: %+v", payload)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run keling video: %v", err)
|
||||||
|
}
|
||||||
|
if submitPath != "/videos/image2video" || pollPath != "/videos/image2video/keling-task-1" || !strings.HasPrefix(gotAuth, "Bearer ") {
|
||||||
|
t.Fatalf("unexpected keling paths/auth submit=%s poll=%s auth=%s", submitPath, pollPath, gotAuth)
|
||||||
|
}
|
||||||
|
if submittedTaskID != "keling-task-1" {
|
||||||
|
t.Fatalf("remote task submit callback did not receive task id, got %q", submittedTaskID)
|
||||||
|
}
|
||||||
|
if submittedPayload["model_name"] != "kling-v2-6" ||
|
||||||
|
submittedPayload["prompt"] != "A clean product reveal" ||
|
||||||
|
submittedPayload["duration"] != "6" ||
|
||||||
|
submittedPayload["mode"] != "pro" ||
|
||||||
|
submittedPayload["sound"] != "on" ||
|
||||||
|
submittedPayload["image"] != "Zmlyc3Q=" ||
|
||||||
|
submittedPayload["image_tail"] != "bGFzdA==" {
|
||||||
|
t.Fatalf("unexpected keling submit payload: %+v", submittedPayload)
|
||||||
|
}
|
||||||
|
camera, _ := submittedPayload["camera_control"].(map[string]any)
|
||||||
|
config, _ := camera["config"].(map[string]any)
|
||||||
|
if camera["type"] != "simple" || numericValue(config["zoom"], 0) != 0.6 || numericValue(config["pan"], -1) != 0 {
|
||||||
|
t.Fatalf("unexpected keling camera conversion: %+v", submittedPayload["camera_control"])
|
||||||
|
}
|
||||||
|
data, _ := response.Result["data"].([]any)
|
||||||
|
item, _ := data[0].(map[string]any)
|
||||||
|
if response.Result["upstream_task_id"] != "keling-task-1" || item["url"] != "https://example.com/keling.mp4" || item["video_url"] != "https://example.com/keling.mp4" {
|
||||||
|
t.Fatalf("unexpected keling response: %+v", response.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
|
||||||
|
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "omni_video",
|
||||||
|
Model: "可灵V3多模态",
|
||||||
|
Body: map[string]any{
|
||||||
|
"model": "可灵V3多模态",
|
||||||
|
"duration": 8,
|
||||||
|
"aspect_ratio": "9:16",
|
||||||
|
"resolution": "2160p",
|
||||||
|
"audio": true,
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "Refine the base video"},
|
||||||
|
map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
|
||||||
|
map[string]any{"type": "image_url", "role": "last_frame", "image_url": map[string]any{"url": "https://example.com/last.png"}},
|
||||||
|
map[string]any{
|
||||||
|
"type": "video_url",
|
||||||
|
"role": "video_base",
|
||||||
|
"video_url": map[string]any{
|
||||||
|
"url": "https://example.com/base.mp4",
|
||||||
|
"keep_original_sound": "yes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
Provider: "keling",
|
||||||
|
ProviderModelName: "kling-v3-omni",
|
||||||
|
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||||
|
},
|
||||||
|
}, "token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build keling omni payload: %v", err)
|
||||||
|
}
|
||||||
|
if len(cleanupIDs) != 0 {
|
||||||
|
t.Fatalf("unexpected cleanup ids: %+v", cleanupIDs)
|
||||||
|
}
|
||||||
|
if payload["model_name"] != "kling-v3-omni" || payload["mode"] != "4k" || payload["prompt"] != "Refine the base video" {
|
||||||
|
t.Fatalf("unexpected keling omni base fields: %+v", payload)
|
||||||
|
}
|
||||||
|
if _, ok := payload["sound"]; ok {
|
||||||
|
t.Fatalf("omni payload with base video should not include sound: %+v", payload)
|
||||||
|
}
|
||||||
|
if _, ok := payload["duration"]; ok {
|
||||||
|
t.Fatalf("base video edit should not include duration: %+v", payload)
|
||||||
|
}
|
||||||
|
if _, ok := payload["aspect_ratio"]; ok {
|
||||||
|
t.Fatalf("base video edit should not include aspect_ratio: %+v", payload)
|
||||||
|
}
|
||||||
|
watermark, _ := payload["watermark_info"].(map[string]any)
|
||||||
|
if watermark["enabled"] != false {
|
||||||
|
t.Fatalf("keling watermark should be disabled by default: %+v", payload)
|
||||||
|
}
|
||||||
|
images, _ := payload["image_list"].([]any)
|
||||||
|
if len(images) != 2 {
|
||||||
|
t.Fatalf("unexpected keling image_list: %+v", payload["image_list"])
|
||||||
|
}
|
||||||
|
firstImage, _ := images[0].(map[string]any)
|
||||||
|
lastImage, _ := images[1].(map[string]any)
|
||||||
|
if firstImage["type"] != "first_frame" || lastImage["type"] != "end_frame" {
|
||||||
|
t.Fatalf("frame roles should convert to keling omni types: %+v", images)
|
||||||
|
}
|
||||||
|
videos, _ := payload["video_list"].([]map[string]any)
|
||||||
|
if len(videos) != 1 || videos[0]["refer_type"] != "base" || videos[0]["keep_original_sound"] != "yes" {
|
||||||
|
t.Fatalf("video roles should convert to keling omni refer_type: %+v", payload["video_list"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKelingClientVideoResumePollsWithoutSubmitting(t *testing.T) {
|
||||||
|
var submitCalled bool
|
||||||
|
var pollPath string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method + " " + r.URL.Path {
|
||||||
|
case "POST /general/custom-elements", "POST /videos/omni-video":
|
||||||
|
submitCalled = true
|
||||||
|
t.Fatalf("resume should not submit or upload temporary elements")
|
||||||
|
case "GET /videos/omni-video/keling-existing":
|
||||||
|
pollPath = r.URL.Path
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": 0,
|
||||||
|
"request_id": "req-resume",
|
||||||
|
"data": map[string]any{
|
||||||
|
"task_id": "keling-existing",
|
||||||
|
"task_status": "succeed",
|
||||||
|
"task_result": map[string]any{
|
||||||
|
"videos": []any{map[string]any{"url": "https://example.com/resumed-keling.mp4"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||||
|
Kind: "videos.generations",
|
||||||
|
ModelType: "omni_video",
|
||||||
|
Model: "可灵V3多模态",
|
||||||
|
Body: map[string]any{"prompt": "resume", "pollIntervalMs": 100, "pollTimeoutSeconds": 1},
|
||||||
|
RemoteTaskID: "keling-existing",
|
||||||
|
RemoteTaskPayload: map[string]any{
|
||||||
|
"endpoint": "/videos/omni-video",
|
||||||
|
},
|
||||||
|
Candidate: store.RuntimeModelCandidate{
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Provider: "keling",
|
||||||
|
AuthType: "AccessKey-SecretKey",
|
||||||
|
ProviderModelName: "kling-v3-omni",
|
||||||
|
Credentials: map[string]any{"accessKey": "ak", "secretKey": "sk"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resume keling video: %v", err)
|
||||||
|
}
|
||||||
|
if submitCalled || pollPath != "/videos/omni-video/keling-existing" {
|
||||||
|
t.Fatalf("resume should poll existing task only, submit=%v poll=%s", submitCalled, pollPath)
|
||||||
|
}
|
||||||
|
data, _ := response.Result["data"].([]any)
|
||||||
|
item, _ := data[0].(map[string]any)
|
||||||
|
if response.Result["upstream_task_id"] != "keling-existing" || item["url"] != "https://example.com/resumed-keling.mp4" {
|
||||||
|
t.Fatalf("unexpected resumed keling response: %+v", response.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKelingElementPayloadMapsTags(t *testing.T) {
|
||||||
|
payload := kelingCreateElementPayload(map[string]any{
|
||||||
|
"name": "subject",
|
||||||
|
"frontal_image_url": "https://example.com/front.png",
|
||||||
|
"tags": []any{"character", "unknown"},
|
||||||
|
"refer_images": []any{
|
||||||
|
map[string]any{"url": "https://example.com/side.png"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if payload["element_name"] != "subject" || payload["element_frontal_image"] != "https://example.com/front.png" {
|
||||||
|
t.Fatalf("unexpected element payload base fields: %+v", payload)
|
||||||
|
}
|
||||||
|
tags, _ := payload["tag_list"].([]any)
|
||||||
|
if len(tags) != 2 {
|
||||||
|
t.Fatalf("unexpected tag list: %+v", payload["tag_list"])
|
||||||
|
}
|
||||||
|
firstTag, _ := tags[0].(map[string]any)
|
||||||
|
secondTag, _ := tags[1].(map[string]any)
|
||||||
|
if firstTag["tag_id"] != "o_102" || secondTag["tag_id"] != "o_108" {
|
||||||
|
t.Fatalf("unexpected keling tag conversion: %+v", payload["tag_list"])
|
||||||
|
}
|
||||||
|
refs, _ := payload["element_refer_list"].([]any)
|
||||||
|
if len(refs) != 1 {
|
||||||
|
t.Fatalf("unexpected element references: %+v", payload["element_refer_list"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func extractText(result map[string]any) string {
|
func extractText(result map[string]any) string {
|
||||||
choices, _ := result["choices"].([]any)
|
choices, _ := result["choices"].([]any)
|
||||||
choice, _ := choices[0].(map[string]any)
|
choice, _ := choices[0].(map[string]any)
|
||||||
|
|||||||
@ -27,11 +27,11 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
return Response{}, err
|
return Response{}, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
responseStartedAt := time.Now()
|
||||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
}
|
}
|
||||||
responseStartedAt := time.Now()
|
|
||||||
requestID := requestIDFromHTTPResponse(resp)
|
requestID := requestIDFromHTTPResponse(resp)
|
||||||
result, err := decodeHTTPResponse(resp)
|
result, err := decodeHTTPResponse(resp)
|
||||||
responseFinishedAt := time.Now()
|
responseFinishedAt := time.Now()
|
||||||
|
|||||||
960
apps/api/internal/clients/keling.go
Normal file
960
apps/api/internal/clients/keling.go
Normal file
@ -0,0 +1,960 @@
|
|||||||
|
package clients
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KelingClient struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type kelingPreparedTask struct {
|
||||||
|
Endpoint string
|
||||||
|
Payload map[string]any
|
||||||
|
RemoteTaskPayload map[string]any
|
||||||
|
CleanupElementIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) Run(ctx context.Context, request Request) (Response, error) {
|
||||||
|
if request.Kind != "videos.generations" {
|
||||||
|
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported keling request kind", Retryable: false}
|
||||||
|
}
|
||||||
|
token, err := kelingAuthToken(request.Candidate)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
return c.runVideo(ctx, request, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) runVideo(ctx context.Context, request Request, token string) (Response, error) {
|
||||||
|
submitStartedAt := time.Now()
|
||||||
|
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
|
||||||
|
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||||
|
prepared := kelingResumePreparedTask(request)
|
||||||
|
if upstreamTaskID == "" {
|
||||||
|
var err error
|
||||||
|
prepared, err = c.prepareVideoTask(ctx, request, token)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if upstreamTaskID == "" {
|
||||||
|
_ = c.cleanupKelingElements(context.WithoutCancel(ctx), request, token, prepared.CleanupElementIDs)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if upstreamTaskID == "" {
|
||||||
|
submitResult, requestID, err := c.postJSON(ctx, request, prepared.Endpoint, token, prepared.Payload)
|
||||||
|
submitRequestID = requestID
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
|
||||||
|
}
|
||||||
|
upstreamTaskID = strings.TrimSpace(stringFromAny(kelingData(submitResult)["task_id"]))
|
||||||
|
if upstreamTaskID == "" {
|
||||||
|
_ = c.cleanupKelingElements(context.WithoutCancel(ctx), request, token, prepared.CleanupElementIDs)
|
||||||
|
return Response{}, &ClientError{Code: "invalid_response", Message: "keling video task id is missing", RequestID: submitRequestID, Retryable: false}
|
||||||
|
}
|
||||||
|
prepared.RemoteTaskPayload["submit"] = submitResult
|
||||||
|
if request.OnRemoteTaskSubmitted != nil {
|
||||||
|
if err := request.OnRemoteTaskSubmitted(upstreamTaskID, prepared.RemoteTaskPayload); err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollEndpoint := kelingPollEndpoint(request, prepared.Endpoint)
|
||||||
|
interval := kelingPollInterval(request)
|
||||||
|
timeout := kelingPollTimeout(request)
|
||||||
|
deadline := time.NewTimer(timeout)
|
||||||
|
defer deadline.Stop()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
var lastResult map[string]any
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: submitRequestID, Retryable: true}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
pollStartedAt := time.Now()
|
||||||
|
pollResult, pollRequestID, err := c.getJSON(ctx, request, pollEndpoint+"/"+upstreamTaskID, token)
|
||||||
|
pollFinishedAt := time.Now()
|
||||||
|
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||||
|
}
|
||||||
|
lastResult = pollResult
|
||||||
|
|
||||||
|
switch kelingTaskStatus(pollResult) {
|
||||||
|
case "succeed":
|
||||||
|
_ = c.cleanupKelingElements(context.WithoutCancel(ctx), request, token, prepared.CleanupElementIDs)
|
||||||
|
prepared.CleanupElementIDs = nil
|
||||||
|
result := kelingVideoSuccessResult(request, upstreamTaskID, pollResult)
|
||||||
|
return Response{
|
||||||
|
Result: result,
|
||||||
|
RequestID: requestID,
|
||||||
|
Progress: kelingVideoProgress(request, upstreamTaskID),
|
||||||
|
ResponseStartedAt: submitStartedAt,
|
||||||
|
ResponseFinishedAt: pollFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||||
|
}, nil
|
||||||
|
case "failed":
|
||||||
|
_ = c.cleanupKelingElements(context.WithoutCancel(ctx), request, token, prepared.CleanupElementIDs)
|
||||||
|
prepared.CleanupElementIDs = nil
|
||||||
|
return Response{}, &ClientError{
|
||||||
|
Code: kelingTaskErrorCode(pollResult),
|
||||||
|
Message: kelingTaskErrorMessage(request.Candidate, pollResult),
|
||||||
|
RequestID: requestID,
|
||||||
|
ResponseStartedAt: submitStartedAt,
|
||||||
|
ResponseFinishedAt: pollFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||||
|
Retryable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
|
||||||
|
case <-deadline.C:
|
||||||
|
return Response{}, &ClientError{
|
||||||
|
Code: "timeout",
|
||||||
|
Message: fmt.Sprintf("keling video task %s did not finish before timeout; last status: %s", upstreamTaskID, kelingTaskStatus(lastResult)),
|
||||||
|
RequestID: requestID,
|
||||||
|
Retryable: true,
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) prepareVideoTask(ctx context.Context, request Request, token string) (kelingPreparedTask, error) {
|
||||||
|
if kelingIsOmniRequest(request) {
|
||||||
|
payload, cleanupIDs, err := c.kelingOmniPayload(ctx, request, token)
|
||||||
|
if err != nil {
|
||||||
|
return kelingPreparedTask{}, err
|
||||||
|
}
|
||||||
|
return kelingPreparedTask{
|
||||||
|
Endpoint: "/videos/omni-video",
|
||||||
|
Payload: payload,
|
||||||
|
RemoteTaskPayload: map[string]any{"endpoint": "/videos/omni-video", "mode": "omni_video", "cleanupElementIds": cleanupIDs},
|
||||||
|
CleanupElementIDs: cleanupIDs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
payload, taskType, err := kelingVideoPayload(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
return kelingPreparedTask{}, err
|
||||||
|
}
|
||||||
|
endpoint := "/videos/" + taskType
|
||||||
|
return kelingPreparedTask{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Payload: payload,
|
||||||
|
RemoteTaskPayload: map[string]any{"endpoint": endpoint, "taskType": taskType},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingResumePreparedTask(request Request) kelingPreparedTask {
|
||||||
|
endpoint := ""
|
||||||
|
for _, key := range []string{"endpoint", "pollEndpoint"} {
|
||||||
|
if value := strings.TrimSpace(stringFromAny(request.RemoteTaskPayload[key])); value != "" {
|
||||||
|
endpoint = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endpoint == "" {
|
||||||
|
if kelingIsOmniRequest(request) {
|
||||||
|
endpoint = "/videos/omni-video"
|
||||||
|
} else {
|
||||||
|
endpoint = "/videos/" + kelingTaskTypeFromRequest(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kelingPreparedTask{Endpoint: endpoint, RemoteTaskPayload: map[string]any{"endpoint": endpoint}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingVideoPayload(ctx context.Context, request Request) (map[string]any, string, error) {
|
||||||
|
body := cleanProviderBody(request.Body)
|
||||||
|
content := contentItems(body["content"])
|
||||||
|
if len(content) == 0 {
|
||||||
|
content = buildVolcesContentFromBody(body)
|
||||||
|
}
|
||||||
|
prompt := firstKelingPrompt(content)
|
||||||
|
if prompt == "" {
|
||||||
|
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling video prompt is required", StatusCode: 400, Retryable: false}
|
||||||
|
}
|
||||||
|
firstFrame, lastFrame, referenceImages := kelingImageInputs(content)
|
||||||
|
isImage2Video := firstFrame != "" || lastFrame != "" || len(referenceImages) > 0
|
||||||
|
primaryImage := firstFrame
|
||||||
|
if primaryImage == "" && len(referenceImages) <= 1 && len(referenceImages) > 0 {
|
||||||
|
primaryImage = referenceImages[0]
|
||||||
|
}
|
||||||
|
if primaryImage == "" {
|
||||||
|
primaryImage = lastFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"prompt": prompt,
|
||||||
|
"model_name": upstreamModelName(request.Candidate),
|
||||||
|
"duration": fmtDuration(body["duration"], 5),
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(stringFromAny(body["negative_prompt"])); value != "" {
|
||||||
|
payload["negative_prompt"] = value
|
||||||
|
}
|
||||||
|
if value, ok := body["cfg_scale"]; ok && numericValue(value, 0) > 0 {
|
||||||
|
payload["cfg_scale"] = value
|
||||||
|
}
|
||||||
|
if boolValue(body, "audio") || boolValue(body, "output_audio") {
|
||||||
|
payload["sound"] = "on"
|
||||||
|
}
|
||||||
|
if mode := kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")); mode != "" {
|
||||||
|
payload["mode"] = mode
|
||||||
|
}
|
||||||
|
if ratio := strings.TrimSpace(firstNonEmptyStringValue(body, "aspect_ratio", "aspectRatio", "ratio")); strings.Contains(ratio, ":") {
|
||||||
|
payload["aspect_ratio"] = ratio
|
||||||
|
}
|
||||||
|
if camera := kelingCameraControl(body); camera != nil {
|
||||||
|
payload["camera_control"] = camera
|
||||||
|
}
|
||||||
|
if primaryImage != "" {
|
||||||
|
encoded, err := kelingImageToBase64(ctx, request, primaryImage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
payload["image"] = encoded
|
||||||
|
}
|
||||||
|
if lastFrame != "" {
|
||||||
|
encoded, err := kelingImageToBase64(ctx, request, lastFrame)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
payload["image_tail"] = encoded
|
||||||
|
}
|
||||||
|
if len(referenceImages) > 0 {
|
||||||
|
imageList := make([]any, 0, len(referenceImages))
|
||||||
|
for _, url := range referenceImages {
|
||||||
|
encoded, err := kelingImageToBase64(ctx, request, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
imageList = append(imageList, map[string]any{"image": encoded})
|
||||||
|
}
|
||||||
|
payload["image_list"] = imageList
|
||||||
|
}
|
||||||
|
if !strings.Contains(stringFromAny(payload["aspect_ratio"]), ":") || isImage2Video {
|
||||||
|
delete(payload, "aspect_ratio")
|
||||||
|
}
|
||||||
|
|
||||||
|
taskType := "text2video"
|
||||||
|
if primaryImage != "" {
|
||||||
|
taskType = "image2video"
|
||||||
|
} else if len(referenceImages) > 1 {
|
||||||
|
taskType = "multi-image2video"
|
||||||
|
}
|
||||||
|
return payload, taskType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingTaskTypeFromRequest(request Request) string {
|
||||||
|
body := cleanProviderBody(request.Body)
|
||||||
|
content := contentItems(body["content"])
|
||||||
|
if len(content) == 0 {
|
||||||
|
content = buildVolcesContentFromBody(body)
|
||||||
|
}
|
||||||
|
firstFrame, lastFrame, referenceImages := kelingImageInputs(content)
|
||||||
|
if firstFrame != "" || lastFrame != "" || len(referenceImages) == 1 {
|
||||||
|
return "image2video"
|
||||||
|
}
|
||||||
|
if len(referenceImages) > 1 {
|
||||||
|
return "multi-image2video"
|
||||||
|
}
|
||||||
|
return "text2video"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, token string) (map[string]any, []string, error) {
|
||||||
|
body := cleanProviderBody(request.Body)
|
||||||
|
content := contentItems(body["content"])
|
||||||
|
if len(content) == 0 {
|
||||||
|
content = buildVolcesContentFromBody(body)
|
||||||
|
}
|
||||||
|
prompt := firstKelingPrompt(content)
|
||||||
|
images := kelingOmniImageList(content)
|
||||||
|
videos := kelingOmniVideoList(content)
|
||||||
|
uploadedElementIDs := make([]string, 0)
|
||||||
|
elements, createdIDs, err := c.kelingOmniElementList(ctx, request, token, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
uploadedElementIDs = append(uploadedElementIDs, createdIDs...)
|
||||||
|
shots := kelingShotPrompts(content)
|
||||||
|
hasMultiPrompt := len(shots) > 0
|
||||||
|
hasVideo := len(videos) > 0
|
||||||
|
hasVideoEdit := kelingHasBaseVideo(videos)
|
||||||
|
hasFirstFrame := kelingHasFirstFrame(images)
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"model_name": upstreamModelName(request.Candidate),
|
||||||
|
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
|
||||||
|
"watermark_info": map[string]any{"enabled": false},
|
||||||
|
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
|
||||||
|
}
|
||||||
|
if !hasMultiPrompt {
|
||||||
|
payload["prompt"] = prompt
|
||||||
|
if body["duration"] != nil {
|
||||||
|
payload["duration"] = fmtDuration(body["duration"], 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ratio := strings.TrimSpace(firstNonEmptyStringValue(body, "aspect_ratio", "aspectRatio", "ratio")); strings.Contains(ratio, ":") {
|
||||||
|
payload["aspect_ratio"] = ratio
|
||||||
|
}
|
||||||
|
if len(images) > 0 {
|
||||||
|
payload["image_list"] = images
|
||||||
|
}
|
||||||
|
if len(videos) > 0 {
|
||||||
|
payload["video_list"] = videos
|
||||||
|
}
|
||||||
|
if len(elements) > 0 {
|
||||||
|
payload["element_list"] = elements
|
||||||
|
}
|
||||||
|
if (boolValue(body, "audio") || boolValue(body, "output_audio")) && !hasVideo {
|
||||||
|
payload["sound"] = "on"
|
||||||
|
}
|
||||||
|
if hasMultiPrompt {
|
||||||
|
payload["multi_shot"] = true
|
||||||
|
payload["shot_type"] = "customize"
|
||||||
|
total := 0.0
|
||||||
|
multiPrompt := make([]any, 0, len(shots))
|
||||||
|
for index, shot := range shots {
|
||||||
|
duration := shot.duration
|
||||||
|
if duration <= 0 {
|
||||||
|
duration = 5
|
||||||
|
}
|
||||||
|
total += duration
|
||||||
|
multiPrompt = append(multiPrompt, map[string]any{
|
||||||
|
"index": index + 1,
|
||||||
|
"prompt": shot.text,
|
||||||
|
"duration": fmtDuration(duration, 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
delete(payload, "prompt")
|
||||||
|
payload["multi_prompt"] = multiPrompt
|
||||||
|
payload["duration"] = fmtDuration(total, 0)
|
||||||
|
}
|
||||||
|
deleteEmptyStringFields(payload)
|
||||||
|
if hasVideoEdit {
|
||||||
|
delete(payload, "duration")
|
||||||
|
delete(payload, "aspect_ratio")
|
||||||
|
}
|
||||||
|
if hasVideo && !hasVideoEdit && !strings.Contains(stringFromAny(payload["aspect_ratio"]), ":") {
|
||||||
|
payload["aspect_ratio"] = "16:9"
|
||||||
|
}
|
||||||
|
if !hasVideoEdit && !hasFirstFrame && !strings.Contains(stringFromAny(payload["aspect_ratio"]), ":") {
|
||||||
|
payload["aspect_ratio"] = "16:9"
|
||||||
|
}
|
||||||
|
return payload, uploadedElementIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request, token string, content []map[string]any) ([]any, []string, error) {
|
||||||
|
elements := make([]any, 0)
|
||||||
|
createdIDs := make([]string, 0)
|
||||||
|
for _, item := range content {
|
||||||
|
if stringFromAny(item["type"]) != "element" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
element := mapFromAny(item["element"])
|
||||||
|
if element == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id := kelingStringFromAny(firstPresent(element["element_id"], element["id"])); id != "" {
|
||||||
|
elements = append(elements, map[string]any{"element_id": id})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inline := mapFromAny(element["inline_element"])
|
||||||
|
if inline == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := kelingCreateElementPayload(inline)
|
||||||
|
if payload == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := c.createKelingElement(ctx, request, token, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, createdIDs, err
|
||||||
|
}
|
||||||
|
elements = append(elements, map[string]any{"element_id": id})
|
||||||
|
createdIDs = append(createdIDs, id)
|
||||||
|
}
|
||||||
|
return elements, createdIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, path), bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
|
}
|
||||||
|
requestID := requestIDFromHTTPResponse(resp)
|
||||||
|
result, err := decodeHTTPResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
return result, requestID, err
|
||||||
|
}
|
||||||
|
if code := intFromAny(result["code"]); code != 0 {
|
||||||
|
return result, requestID, &ClientError{Code: kelingEnvelopeErrorCode(result), Message: kelingEnvelopeErrorMessage(result), RequestID: firstNonEmpty(requestID, stringFromAny(result["request_id"])), Retryable: false}
|
||||||
|
}
|
||||||
|
return result, firstNonEmpty(requestID, stringFromAny(result["request_id"])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) getJSON(ctx context.Context, request Request, path string, token string) (map[string]any, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(request.Candidate.BaseURL, path), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
|
}
|
||||||
|
requestID := requestIDFromHTTPResponse(resp)
|
||||||
|
result, err := decodeHTTPResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
return result, requestID, err
|
||||||
|
}
|
||||||
|
if code := intFromAny(result["code"]); code != 0 {
|
||||||
|
return result, requestID, &ClientError{Code: kelingEnvelopeErrorCode(result), Message: kelingEnvelopeErrorMessage(result), RequestID: firstNonEmpty(requestID, stringFromAny(result["request_id"])), Retryable: false}
|
||||||
|
}
|
||||||
|
return result, firstNonEmpty(requestID, stringFromAny(result["request_id"])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) createKelingElement(ctx context.Context, request Request, token string, payload map[string]any) (string, error) {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, "/general/custom-elements"), bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", &ClientError{Code: statusCodeName(resp.StatusCode), Message: errorMessage(body, resp.Status), StatusCode: resp.StatusCode, RequestID: requestIDFromHTTPResponse(resp), Retryable: HTTPRetryable(resp.StatusCode)}
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&parsed); err != nil {
|
||||||
|
return "", &ClientError{Code: "invalid_response", Message: err.Error(), Retryable: false}
|
||||||
|
}
|
||||||
|
if parsed.Code != 0 {
|
||||||
|
return "", &ClientError{Code: "keling_element_create_failed", Message: parsed.Message, RequestID: parsed.RequestID, Retryable: false}
|
||||||
|
}
|
||||||
|
id := kelingStringFromAny(parsed.Data["element_id"])
|
||||||
|
if id == "" {
|
||||||
|
return "", &ClientError{Code: "invalid_response", Message: "keling element id is missing", RequestID: parsed.RequestID, Retryable: false}
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c KelingClient) cleanupKelingElements(ctx context.Context, request Request, token string, elementIDs []string) error {
|
||||||
|
for _, id := range elementIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _, _ = c.postJSON(ctx, request, "/general/delete-elements", token, map[string]any{"element_id": id})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingAuthToken(candidate store.RuntimeModelCandidate) (string, error) {
|
||||||
|
apiKey := credential(candidate.Credentials, "apiKey", "api_key", "key", "token")
|
||||||
|
accessKey := credential(candidate.Credentials, "accessKey", "access_key", "ak")
|
||||||
|
secretKey := credential(candidate.Credentials, "secretKey", "secret_key", "sk")
|
||||||
|
if accessKey != "" || secretKey != "" || strings.EqualFold(strings.TrimSpace(candidate.AuthType), "AccessKey-SecretKey") {
|
||||||
|
if accessKey == "" || secretKey == "" {
|
||||||
|
return "", &ClientError{Code: "missing_credentials", Message: "keling accessKey and secretKey are required", Retryable: false}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"iss": accessKey,
|
||||||
|
"exp": now.Add(30 * time.Minute).Unix(),
|
||||||
|
"nbf": now.Add(-5 * time.Second).Unix(),
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := token.SignedString([]byte(secretKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", &ClientError{Code: "auth_failed", Message: err.Error(), Retryable: false}
|
||||||
|
}
|
||||||
|
return signed, nil
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", &ClientError{Code: "missing_credentials", Message: "keling api key is required", Retryable: false}
|
||||||
|
}
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingImageToBase64(ctx context.Context, request Request, value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "data:") {
|
||||||
|
parts := strings.SplitN(value, ",", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[1]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, value, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := httpClient(request.HTTPClient).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
return "", &ClientError{Code: statusCodeName(resp.StatusCode), Message: errorMessage(raw, resp.Status), StatusCode: resp.StatusCode, RequestID: requestIDFromHTTPResponse(resp), Retryable: HTTPRetryable(resp.StatusCode)}
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(raw), nil
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingIsOmniRequest(request Request) bool {
|
||||||
|
modelType := strings.TrimSpace(request.ModelType)
|
||||||
|
return modelType == "omni_video" || modelType == "omni" ||
|
||||||
|
request.Candidate.Capabilities["omni_video"] != nil ||
|
||||||
|
request.Candidate.Capabilities["omni"] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstKelingPrompt(content []map[string]any) string {
|
||||||
|
for _, item := range content {
|
||||||
|
if stringFromAny(item["type"]) == "text" && stringFromAny(item["role"]) != "shot_prompt" && item["shot_index"] == nil {
|
||||||
|
if text := strings.TrimSpace(stringFromAny(item["text"])); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingImageInputs(content []map[string]any) (string, string, []string) {
|
||||||
|
firstFrame := ""
|
||||||
|
lastFrame := ""
|
||||||
|
references := make([]string, 0)
|
||||||
|
for _, item := range content {
|
||||||
|
if !isKelingImageContent(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := kelingNestedURL(item, "image_url")
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch stringFromAny(item["role"]) {
|
||||||
|
case "first_frame":
|
||||||
|
if firstFrame == "" {
|
||||||
|
firstFrame = url
|
||||||
|
}
|
||||||
|
case "last_frame":
|
||||||
|
if lastFrame == "" {
|
||||||
|
lastFrame = url
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
references = append(references, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstFrame, lastFrame, references
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingOmniImageList(content []map[string]any) []any {
|
||||||
|
out := make([]any, 0)
|
||||||
|
for _, item := range content {
|
||||||
|
if !isKelingImageContent(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := kelingNestedURL(item, "image_url")
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
image := map[string]any{"image_url": url}
|
||||||
|
switch stringFromAny(item["role"]) {
|
||||||
|
case "first_frame":
|
||||||
|
image["type"] = "first_frame"
|
||||||
|
case "last_frame":
|
||||||
|
image["type"] = "end_frame"
|
||||||
|
}
|
||||||
|
out = append(out, image)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingOmniVideoList(content []map[string]any) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0)
|
||||||
|
for _, item := range content {
|
||||||
|
if !isKelingVideoContent(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nested := mapFromAny(item["video_url"])
|
||||||
|
url := strings.TrimSpace(stringFromAny(nested["url"]))
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
video := map[string]any{"video_url": url}
|
||||||
|
referType := strings.TrimSpace(stringFromAny(nested["refer_type"]))
|
||||||
|
if referType == "" {
|
||||||
|
switch stringFromAny(item["role"]) {
|
||||||
|
case "video_base":
|
||||||
|
referType = "base"
|
||||||
|
case "video_feature", "reference_video":
|
||||||
|
referType = "feature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if referType == "base" || referType == "feature" {
|
||||||
|
video["refer_type"] = referType
|
||||||
|
}
|
||||||
|
if keep := strings.TrimSpace(stringFromAny(nested["keep_original_sound"])); keep != "" {
|
||||||
|
video["keep_original_sound"] = keep
|
||||||
|
}
|
||||||
|
out = append(out, video)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type kelingShotPrompt struct {
|
||||||
|
index int
|
||||||
|
text string
|
||||||
|
duration float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingShotPrompts(content []map[string]any) []kelingShotPrompt {
|
||||||
|
shots := make([]kelingShotPrompt, 0)
|
||||||
|
for index, item := range content {
|
||||||
|
if stringFromAny(item["type"]) != "text" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stringFromAny(item["role"]) != "shot_prompt" && item["shot_index"] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(stringFromAny(item["text"]))
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shotIndex := int(math.Floor(numericValue(item["shot_index"], float64(index))))
|
||||||
|
shots = append(shots, kelingShotPrompt{index: shotIndex, text: text, duration: numericValue(item["duration"], 5)})
|
||||||
|
}
|
||||||
|
sort.SliceStable(shots, func(i, j int) bool { return shots[i].index < shots[j].index })
|
||||||
|
return shots
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingHasBaseVideo(videos []map[string]any) bool {
|
||||||
|
for _, video := range videos {
|
||||||
|
if stringFromAny(video["refer_type"]) == "base" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingHasFirstFrame(images []any) bool {
|
||||||
|
for _, item := range images {
|
||||||
|
image := mapFromAny(item)
|
||||||
|
if stringFromAny(image["type"]) == "first_frame" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingCreateElementPayload(inline map[string]any) map[string]any {
|
||||||
|
frontURL := strings.TrimSpace(firstNonEmptyStringValue(inline, "frontal_image_url", "frontalImageUrl", "element_frontal_image", "image_url", "imageUrl", "url"))
|
||||||
|
if frontURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := firstNonEmptyStringValue(inline, "name", "element_name", "elementName")
|
||||||
|
if name == "" {
|
||||||
|
name = "temporary element"
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"element_name": name,
|
||||||
|
"element_description": firstNonEmpty(firstNonEmptyStringValue(inline, "description"), name),
|
||||||
|
"element_frontal_image": frontURL,
|
||||||
|
}
|
||||||
|
referImages := make([]any, 0)
|
||||||
|
for _, ref := range mapListFromAny(firstPresent(inline["refer_images"], inline["referImages"], inline["element_refer_list"])) {
|
||||||
|
url := strings.TrimSpace(firstNonEmptyStringValue(ref, "url", "image_url", "imageUrl"))
|
||||||
|
if url != "" {
|
||||||
|
referImages = append(referImages, map[string]any{"image_url": url})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(referImages) > 0 {
|
||||||
|
payload["element_refer_list"] = referImages
|
||||||
|
}
|
||||||
|
if tags := kelingElementTagList(inline["tags"]); len(tags) > 0 {
|
||||||
|
payload["tag_list"] = tags
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingElementTagList(value any) []any {
|
||||||
|
mapping := map[string]string{
|
||||||
|
"hot_meme": "o_101",
|
||||||
|
"character": "o_102",
|
||||||
|
"animal": "o_103",
|
||||||
|
"prop": "o_104",
|
||||||
|
"costume": "o_105",
|
||||||
|
"scene": "o_106",
|
||||||
|
"effect": "o_107",
|
||||||
|
"other": "o_108",
|
||||||
|
}
|
||||||
|
out := make([]any, 0)
|
||||||
|
for _, tag := range stringListFromAny(value) {
|
||||||
|
id := mapping[strings.TrimSpace(tag)]
|
||||||
|
if id == "" {
|
||||||
|
id = mapping["other"]
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"tag_id": id})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingNestedURL(item map[string]any, key string) string {
|
||||||
|
nested := mapFromAny(item[key])
|
||||||
|
if nested != nil {
|
||||||
|
if value := strings.TrimSpace(stringFromAny(nested["url"])); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(stringFromAny(item[key]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKelingImageContent(item map[string]any) bool {
|
||||||
|
return stringFromAny(item["type"]) == "image_url" || mapFromAny(item["image_url"]) != nil || strings.TrimSpace(stringFromAny(item["image_url"])) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKelingVideoContent(item map[string]any) bool {
|
||||||
|
return stringFromAny(item["type"]) == "video_url" || mapFromAny(item["video_url"]) != nil || strings.TrimSpace(stringFromAny(item["video_url"])) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingModeByResolution(resolution string) string {
|
||||||
|
switch strings.TrimSpace(resolution) {
|
||||||
|
case "2160p":
|
||||||
|
return "4k"
|
||||||
|
case "1080p":
|
||||||
|
return "pro"
|
||||||
|
case "480p", "720p", "":
|
||||||
|
return "std"
|
||||||
|
default:
|
||||||
|
if strings.HasSuffix(strings.TrimSpace(resolution), "p") {
|
||||||
|
return "std"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingCameraControl(body map[string]any) map[string]any {
|
||||||
|
cameraControl := strings.TrimSpace(stringFromAny(body["camera_control"]))
|
||||||
|
if cameraControl == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(cameraControl, "simple") {
|
||||||
|
directions := []string{"horizontal", "vertical", "pan", "tilt", "roll", "zoom"}
|
||||||
|
current := ""
|
||||||
|
parts := strings.SplitN(cameraControl, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
current = parts[1]
|
||||||
|
}
|
||||||
|
strength := firstPresent(body["camera_control_strength"], body["cameraControlStrength"])
|
||||||
|
config := map[string]any{}
|
||||||
|
for _, direction := range directions {
|
||||||
|
if direction == current {
|
||||||
|
config[direction] = strength
|
||||||
|
} else {
|
||||||
|
config[direction] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "simple", "config": config}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": cameraControl}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingData(result map[string]any) map[string]any {
|
||||||
|
data, _ := result["data"].(map[string]any)
|
||||||
|
if data == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingTaskStatus(result map[string]any) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(stringFromAny(kelingData(result)["task_status"])))
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingTaskErrorCode(result map[string]any) string {
|
||||||
|
if code := intFromAny(result["code"]); code != 0 {
|
||||||
|
return fmt.Sprintf("keling_%d", code)
|
||||||
|
}
|
||||||
|
return "keling_task_failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingTaskErrorMessage(candidate store.RuntimeModelCandidate, result map[string]any) string {
|
||||||
|
message := strings.TrimSpace(stringFromAny(kelingData(result)["task_status_msg"]))
|
||||||
|
if message == "" {
|
||||||
|
message = strings.TrimSpace(stringFromAny(result["message"]))
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = "keling video task failed"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Platform:%s,Code:%v,requestId:%s,message:%s", candidate.Provider, result["code"], stringFromAny(result["request_id"]), message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingEnvelopeErrorCode(result map[string]any) string {
|
||||||
|
if code := intFromAny(result["code"]); code != 0 {
|
||||||
|
return fmt.Sprintf("keling_%d", code)
|
||||||
|
}
|
||||||
|
return "keling_error"
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingEnvelopeErrorMessage(result map[string]any) string {
|
||||||
|
if message := strings.TrimSpace(stringFromAny(result["message"])); message != "" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return "keling request failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[string]any) map[string]any {
|
||||||
|
data := kelingData(raw)
|
||||||
|
taskResult, _ := data["task_result"].(map[string]any)
|
||||||
|
videos, _ := taskResult["videos"].([]any)
|
||||||
|
items := make([]any, 0, len(videos))
|
||||||
|
for _, rawVideo := range videos {
|
||||||
|
video := mapFromAny(rawVideo)
|
||||||
|
url := strings.TrimSpace(stringFromAny(video["url"]))
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := map[string]any{"url": url, "video_url": url, "type": "video"}
|
||||||
|
if duration := intFromAny(video["duration"]); duration > 0 {
|
||||||
|
item["duration"] = duration
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
created := intFromAny(data["created_at"])
|
||||||
|
if created == 0 {
|
||||||
|
created = int(nowUnix())
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": upstreamTaskID,
|
||||||
|
"object": "video.generation",
|
||||||
|
"created": created,
|
||||||
|
"model": upstreamModelName(request.Candidate),
|
||||||
|
"status": "succeeded",
|
||||||
|
"upstream_task_id": upstreamTaskID,
|
||||||
|
"data": items,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingVideoProgress(request Request, upstreamTaskID string) []Progress {
|
||||||
|
progress := providerProgress(request)
|
||||||
|
progress = append(progress, Progress{
|
||||||
|
Phase: "polling_result",
|
||||||
|
Progress: 0.9,
|
||||||
|
Message: "keling video task completed",
|
||||||
|
Payload: map[string]any{"upstreamTaskId": upstreamTaskID},
|
||||||
|
})
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingPollEndpoint(request Request, fallback string) string {
|
||||||
|
for _, key := range []string{"endpoint", "pollEndpoint"} {
|
||||||
|
if value := strings.TrimSpace(stringFromAny(request.RemoteTaskPayload[key])); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingPollInterval(request Request) time.Duration {
|
||||||
|
ms := numericValue(firstPresent(request.Candidate.PlatformConfig["kelingPollIntervalMs"], request.Candidate.PlatformConfig["klingPollIntervalMs"], request.Body["pollIntervalMs"], request.Body["poll_interval_ms"]), 15000)
|
||||||
|
if ms < 100 {
|
||||||
|
ms = 100
|
||||||
|
}
|
||||||
|
return time.Duration(ms) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingPollTimeout(request Request) time.Duration {
|
||||||
|
seconds := numericValue(firstPresent(request.Candidate.PlatformConfig["kelingPollTimeoutSeconds"], request.Candidate.PlatformConfig["klingPollTimeoutSeconds"], request.Body["pollTimeoutSeconds"], request.Body["poll_timeout_seconds"]), 600)
|
||||||
|
if seconds < 1 {
|
||||||
|
seconds = 600
|
||||||
|
}
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtDuration(value any, fallback float64) string {
|
||||||
|
duration := numericValue(value, fallback)
|
||||||
|
if math.Abs(duration-math.Round(duration)) < 1e-9 {
|
||||||
|
return fmt.Sprintf("%d", int(math.Round(duration)))
|
||||||
|
}
|
||||||
|
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", duration), "0"), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEmptyStringFields(payload map[string]any) {
|
||||||
|
for key, value := range payload {
|
||||||
|
if text, ok := value.(string); ok && strings.TrimSpace(text) == "" {
|
||||||
|
delete(payload, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kelingStringFromAny(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case json.Number:
|
||||||
|
return typed.String()
|
||||||
|
case float64:
|
||||||
|
if math.Abs(typed-math.Round(typed)) < 1e-9 {
|
||||||
|
return fmt.Sprintf("%.0f", typed)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", typed)
|
||||||
|
case int:
|
||||||
|
return fmt.Sprintf("%d", typed)
|
||||||
|
case int64:
|
||||||
|
return fmt.Sprintf("%d", typed)
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(typed)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,11 +33,11 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
responseStartedAt := time.Now()
|
||||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
}
|
}
|
||||||
responseStartedAt := time.Now()
|
|
||||||
requestID := requestIDFromHTTPResponse(resp)
|
requestID := requestIDFromHTTPResponse(resp)
|
||||||
result, err := decodeOpenAIResponse(resp, stream, request.StreamDelta)
|
result, err := decodeOpenAIResponse(resp, stream, request.StreamDelta)
|
||||||
responseFinishedAt := time.Now()
|
responseFinishedAt := time.Now()
|
||||||
|
|||||||
@ -146,5 +146,8 @@ func responseDurationMS(startedAt time.Time, finishedAt time.Time) int64 {
|
|||||||
if duration < 0 {
|
if duration < 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if duration == 0 && finishedAt.After(startedAt) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
return duration
|
return duration
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,11 +45,11 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
responseStartedAt := time.Now()
|
||||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
}
|
}
|
||||||
responseStartedAt := time.Now()
|
|
||||||
requestID := requestIDFromHTTPResponse(resp)
|
requestID := requestIDFromHTTPResponse(resp)
|
||||||
result, err := decodeHTTPResponse(resp)
|
result, err := decodeHTTPResponse(resp)
|
||||||
responseFinishedAt := time.Now()
|
responseFinishedAt := time.Now()
|
||||||
|
|||||||
114
apps/api/internal/httpapi/chat_completions_mode_test.go
Normal file
114
apps/api/internal/httpapi/chat_completions_mode_test.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanTaskResponseTreatsAPIV1ChatCompletionsAsSynchronousCompatibleResponse(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil)
|
||||||
|
req.Header.Set("X-Async", "true")
|
||||||
|
|
||||||
|
plan := planTaskResponse("chat.completions", false, map[string]any{"stream": true}, req)
|
||||||
|
|
||||||
|
if plan.asyncMode {
|
||||||
|
t.Fatal("/api/v1/chat/completions must not enter async task mode")
|
||||||
|
}
|
||||||
|
if !plan.compatibleMode {
|
||||||
|
t.Fatal("/api/v1/chat/completions should return OpenAI-compatible response payloads")
|
||||||
|
}
|
||||||
|
if !plan.streamMode {
|
||||||
|
t.Fatal("stream=true should select SSE streaming mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanTaskResponseKeepsAsyncTaskModeForOtherAPIV1Tasks(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||||
|
req.Header.Set("X-Async", "true")
|
||||||
|
|
||||||
|
plan := planTaskResponse("images.generations", false, map[string]any{"stream": true}, req)
|
||||||
|
|
||||||
|
if !plan.asyncMode {
|
||||||
|
t.Fatal("non-chat /api/v1 task endpoints should keep X-Async task mode")
|
||||||
|
}
|
||||||
|
if plan.compatibleMode {
|
||||||
|
t.Fatal("non-compatible /api/v1 task endpoints should not return OpenAI-compatible payloads")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteCompatibleTaskResponseReturnsJSONWhenStreamIsFalse(t *testing.T) {
|
||||||
|
executor := &fakeTaskExecutor{output: map[string]any{"id": "chatcmpl-test", "object": "chat.completion"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
writeCompatibleTaskResponse(context.Background(), recorder, req, executor, "chat.completions", "gpt-test", store.GatewayTask{ID: "task-test"}, &auth.User{}, false)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d want=%d body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if executor.executeCalls != 1 || executor.streamCalls != 0 {
|
||||||
|
t.Fatalf("expected non-stream execute only, got execute=%d stream=%d", executor.executeCalls, executor.streamCalls)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode response body: %v body=%s", err, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if body["object"] != "chat.completion" {
|
||||||
|
t.Fatalf("unexpected compatible JSON response: %+v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {
|
||||||
|
executor := &fakeTaskExecutor{
|
||||||
|
deltas: []string{"hel", "lo"},
|
||||||
|
output: map[string]any{"id": "chatcmpl-test", "object": "chat.completion"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
writeCompatibleTaskResponse(context.Background(), recorder, req, executor, "chat.completions", "gpt-test", store.GatewayTask{ID: "task-test"}, &auth.User{}, true)
|
||||||
|
|
||||||
|
if executor.executeCalls != 0 || executor.streamCalls != 1 {
|
||||||
|
t.Fatalf("expected stream execute only, got execute=%d stream=%d", executor.executeCalls, executor.streamCalls)
|
||||||
|
}
|
||||||
|
if contentType := recorder.Header().Get("Content-Type"); contentType != "text/event-stream" {
|
||||||
|
t.Fatalf("Content-Type=%q want text/event-stream", contentType)
|
||||||
|
}
|
||||||
|
body := recorder.Body.String()
|
||||||
|
for _, want := range []string{"event: message", `"content":"hel"`, `"content":"lo"`, `"finish_reason":"stop"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("SSE body missing %s: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTaskExecutor struct {
|
||||||
|
executeCalls int
|
||||||
|
streamCalls int
|
||||||
|
deltas []string
|
||||||
|
output map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskExecutor) Execute(context.Context, store.GatewayTask, *auth.User) (runner.Result, error) {
|
||||||
|
f.executeCalls++
|
||||||
|
return runner.Result{Output: f.output}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskExecutor) ExecuteStream(_ context.Context, _ store.GatewayTask, _ *auth.User, onDelta clients.StreamDelta) (runner.Result, error) {
|
||||||
|
f.streamCalls++
|
||||||
|
for _, delta := range f.deltas {
|
||||||
|
if err := onDelta(delta); err != nil {
|
||||||
|
return runner.Result{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runner.Result{Output: f.output}, nil
|
||||||
|
}
|
||||||
@ -316,13 +316,17 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
|||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
defaultTextModel := "openai:gpt-4o-mini"
|
defaultTextModel := "openai:gpt-4o-mini"
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
var apiV1Chat map[string]any
|
||||||
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": defaultTextModel,
|
"model": defaultTextModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "ping"}},
|
"messages": []map[string]any{{"role": "user", "content": "ping"}},
|
||||||
}, http.StatusAccepted, &taskResponse)
|
}, "default-chat-"+suffixText, http.StatusOK, &apiV1Chat, &taskResponse.Task)
|
||||||
|
if apiV1Chat["object"] != "chat.completion" {
|
||||||
|
t.Fatalf("unexpected api v1 chat response: %+v", apiV1Chat)
|
||||||
|
}
|
||||||
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "succeeded" || taskResponse.Task.RunMode != "simulation" {
|
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "succeeded" || taskResponse.Task.RunMode != "simulation" {
|
||||||
t.Fatalf("unexpected task response: %+v", taskResponse.Task)
|
t.Fatalf("unexpected task response: %+v", taskResponse.Task)
|
||||||
}
|
}
|
||||||
@ -513,13 +517,13 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
|||||||
ErrorCode string `json:"errorCode"`
|
ErrorCode string `json:"errorCode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
|
||||||
"model": deniedModel,
|
"model": deniedModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "permission deny"}},
|
"messages": []map[string]any{{"role": "user", "content": "permission deny"}},
|
||||||
}, http.StatusAccepted, &deniedTask)
|
}, "permission-deny-"+suffixText, http.StatusNotFound, nil, &deniedTask.Task)
|
||||||
if deniedTask.Task.Status != "failed" || deniedTask.Task.ErrorCode != "no_model_candidate" {
|
if deniedTask.Task.Status != "failed" || deniedTask.Task.ErrorCode != "no_model_candidate" {
|
||||||
t.Fatalf("deny access rule should hide denied model from runtime candidates: %+v", deniedTask.Task)
|
t.Fatalf("deny access rule should hide denied model from runtime candidates: %+v", deniedTask.Task)
|
||||||
}
|
}
|
||||||
@ -561,13 +565,13 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
|||||||
ErrorCode string `json:"errorCode"`
|
ErrorCode string `json:"errorCode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
|
||||||
"model": controlledModel,
|
"model": controlledModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "allow should block other keys"}},
|
"messages": []map[string]any{{"role": "user", "content": "allow should block other keys"}},
|
||||||
}, http.StatusAccepted, &blockedControlledTask)
|
}, "permission-allow-block-"+suffixText, http.StatusNotFound, nil, &blockedControlledTask.Task)
|
||||||
if blockedControlledTask.Task.Status != "failed" || blockedControlledTask.Task.ErrorCode != "no_model_candidate" {
|
if blockedControlledTask.Task.Status != "failed" || blockedControlledTask.Task.ErrorCode != "no_model_candidate" {
|
||||||
t.Fatalf("allow access rule should make the resource unavailable to unmatched subjects: %+v", blockedControlledTask.Task)
|
t.Fatalf("allow access rule should make the resource unavailable to unmatched subjects: %+v", blockedControlledTask.Task)
|
||||||
}
|
}
|
||||||
@ -586,13 +590,13 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
|
||||||
"model": controlledModel,
|
"model": controlledModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "allow should pass"}},
|
"messages": []map[string]any{{"role": "user", "content": "allow should pass"}},
|
||||||
}, http.StatusAccepted, &allowedControlledTask)
|
}, "permission-allow-pass-"+suffixText, http.StatusOK, nil, &allowedControlledTask.Task)
|
||||||
if allowedControlledTask.Task.Status != "succeeded" {
|
if allowedControlledTask.Task.Status != "succeeded" {
|
||||||
t.Fatalf("matching allow access rule should make the controlled model usable: %+v", allowedControlledTask.Task)
|
t.Fatalf("matching allow access rule should make the controlled model usable: %+v", allowedControlledTask.Task)
|
||||||
}
|
}
|
||||||
@ -645,13 +649,13 @@ WHERE gateway_user_id = $1::uuid
|
|||||||
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": pricingModel,
|
"model": pricingModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "priced ping"}},
|
"messages": []map[string]any{{"role": "user", "content": "priced ping"}},
|
||||||
}, http.StatusAccepted, &pricingTask)
|
}, "pricing-chat-"+suffixText, http.StatusOK, nil, &pricingTask.Task)
|
||||||
if pricingTask.Task.Status != "succeeded" || !floatNear(pricingTask.Task.FinalChargeAmount, 0.028) {
|
if pricingTask.Task.Status != "succeeded" || !floatNear(pricingTask.Task.FinalChargeAmount, 0.028) {
|
||||||
t.Fatalf("custom pricing rule set should drive text billing, got task=%+v", pricingTask.Task)
|
t.Fatalf("custom pricing rule set should drive text billing, got task=%+v", pricingTask.Task)
|
||||||
}
|
}
|
||||||
@ -757,14 +761,14 @@ WHERE reference_type = 'gateway_task'
|
|||||||
ErrorCode string `json:"errorCode"`
|
ErrorCode string `json:"errorCode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": rateLimitedModel,
|
"model": rateLimitedModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"simulationProfile": "non_retryable_failure",
|
"simulationProfile": "non_retryable_failure",
|
||||||
"messages": []map[string]any{{"role": "user", "content": "failed first"}},
|
"messages": []map[string]any{{"role": "user", "content": "failed first"}},
|
||||||
}, http.StatusAccepted, &rateLimitFailedTask)
|
}, "rate-limit-failed-first-"+suffixText, http.StatusBadGateway, nil, &rateLimitFailedTask.Task)
|
||||||
if rateLimitFailedTask.Task.Status != "failed" || rateLimitFailedTask.Task.ErrorCode != "bad_request" {
|
if rateLimitFailedTask.Task.Status != "failed" || rateLimitFailedTask.Task.ErrorCode != "bad_request" {
|
||||||
t.Fatalf("failed rate-limited task should fail before consuming rpm: %+v", rateLimitFailedTask.Task)
|
t.Fatalf("failed rate-limited task should fail before consuming rpm: %+v", rateLimitFailedTask.Task)
|
||||||
}
|
}
|
||||||
@ -774,13 +778,13 @@ WHERE reference_type = 'gateway_task'
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": rateLimitedModel,
|
"model": rateLimitedModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "first"}},
|
"messages": []map[string]any{{"role": "user", "content": "first"}},
|
||||||
}, http.StatusAccepted, &rateLimitTaskOne)
|
}, "rate-limit-first-"+suffixText, http.StatusOK, nil, &rateLimitTaskOne.Task)
|
||||||
if rateLimitTaskOne.Task.Status != "succeeded" {
|
if rateLimitTaskOne.Task.Status != "succeeded" {
|
||||||
t.Fatalf("first rate-limited task should succeed: %+v", rateLimitTaskOne.Task)
|
t.Fatalf("first rate-limited task should succeed: %+v", rateLimitTaskOne.Task)
|
||||||
}
|
}
|
||||||
@ -790,13 +794,13 @@ WHERE reference_type = 'gateway_task'
|
|||||||
ErrorCode string `json:"errorCode"`
|
ErrorCode string `json:"errorCode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": rateLimitedModel,
|
"model": rateLimitedModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "second"}},
|
"messages": []map[string]any{{"role": "user", "content": "second"}},
|
||||||
}, http.StatusAccepted, &rateLimitTaskTwo)
|
}, "rate-limit-second-"+suffixText, http.StatusTooManyRequests, nil, &rateLimitTaskTwo.Task)
|
||||||
if rateLimitTaskTwo.Task.Status != "failed" || rateLimitTaskTwo.Task.ErrorCode != "rate_limit" {
|
if rateLimitTaskTwo.Task.Status != "failed" || rateLimitTaskTwo.Task.ErrorCode != "rate_limit" {
|
||||||
t.Fatalf("runtime policy rate limit should fail second task with rate_limit: %+v", rateLimitTaskTwo.Task)
|
t.Fatalf("runtime policy rate limit should fail second task with rate_limit: %+v", rateLimitTaskTwo.Task)
|
||||||
}
|
}
|
||||||
@ -808,12 +812,12 @@ WHERE reference_type = 'gateway_task'
|
|||||||
AsyncMode bool `json:"asyncMode"`
|
AsyncMode bool `json:"asyncMode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/responses", apiKeyResponse.Secret, map[string]any{
|
||||||
"model": rateLimitedModel,
|
"model": rateLimitedModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "async queued"}},
|
"input": "async queued",
|
||||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &asyncRateLimitTask)
|
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &asyncRateLimitTask)
|
||||||
if asyncRateLimitTask.TaskID == "" || asyncRateLimitTask.Task.ID != asyncRateLimitTask.TaskID || !asyncRateLimitTask.Task.AsyncMode {
|
if asyncRateLimitTask.TaskID == "" || asyncRateLimitTask.Task.ID != asyncRateLimitTask.TaskID || !asyncRateLimitTask.Task.AsyncMode {
|
||||||
t.Fatalf("async task response should expose task id and async mode: %+v", asyncRateLimitTask)
|
t.Fatalf("async task response should expose task id and async mode: %+v", asyncRateLimitTask)
|
||||||
@ -984,11 +988,11 @@ WHERE reference_type = 'gateway_task'
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": failoverModel,
|
"model": failoverModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"messages": []map[string]any{{"role": "user", "content": "retry please"}},
|
"messages": []map[string]any{{"role": "user", "content": "retry please"}},
|
||||||
}, http.StatusAccepted, &failoverTask)
|
}, "failover-chat-"+suffixText, http.StatusOK, nil, &failoverTask.Task)
|
||||||
if failoverTask.Task.Status != "succeeded" {
|
if failoverTask.Task.Status != "succeeded" {
|
||||||
t.Fatalf("failover task should succeed through second client: %+v", failoverTask.Task)
|
t.Fatalf("failover task should succeed through second client: %+v", failoverTask.Task)
|
||||||
}
|
}
|
||||||
@ -1103,13 +1107,13 @@ WHERE failed.id = $1::uuid`, failedPlatform.ID, successPlatform.ID, unrelatedPri
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": degradeModel,
|
"model": degradeModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "degrade please"}},
|
"messages": []map[string]any{{"role": "user", "content": "degrade please"}},
|
||||||
}, http.StatusAccepted, °radeTask)
|
}, "degrade-chat-"+suffixText, http.StatusOK, nil, °radeTask.Task)
|
||||||
if degradeTask.Task.Status != "succeeded" {
|
if degradeTask.Task.Status != "succeeded" {
|
||||||
t.Fatalf("degrade task should fail over after cooling down failed model: %+v", degradeTask.Task)
|
t.Fatalf("degrade task should fail over after cooling down failed model: %+v", degradeTask.Task)
|
||||||
}
|
}
|
||||||
@ -1170,13 +1174,13 @@ WHERE m.platform_id = $1::uuid
|
|||||||
ErrorCode string `json:"errorCode"`
|
ErrorCode string `json:"errorCode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||||
"model": autoDisableModel,
|
"model": autoDisableModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "disable please"}},
|
"messages": []map[string]any{{"role": "user", "content": "disable please"}},
|
||||||
}, http.StatusAccepted, &autoDisableTask)
|
}, "auto-disable-chat-"+suffixText, http.StatusBadGateway, nil, &autoDisableTask.Task)
|
||||||
if autoDisableTask.Task.Status != "failed" || autoDisableTask.Task.ErrorCode != "invalid_api_key" {
|
if autoDisableTask.Task.Status != "failed" || autoDisableTask.Task.ErrorCode != "invalid_api_key" {
|
||||||
t.Fatalf("auto disable task should fail with invalid_api_key: %+v", autoDisableTask.Task)
|
t.Fatalf("auto disable task should fail with invalid_api_key: %+v", autoDisableTask.Task)
|
||||||
}
|
}
|
||||||
@ -1293,12 +1297,12 @@ WHERE m.platform_id = $1::uuid
|
|||||||
AsyncMode bool `json:"asyncMode"`
|
AsyncMode bool `json:"asyncMode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
|
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/responses", apiKeyResponse.Secret, map[string]any{
|
||||||
"model": defaultTextModel,
|
"model": defaultTextModel,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 2000,
|
"simulationDurationMs": 2000,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "river worker restart"}},
|
"input": "river worker restart",
|
||||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &restartAsyncTask)
|
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &restartAsyncTask)
|
||||||
if restartAsyncTask.TaskID == "" || !restartAsyncTask.Task.AsyncMode {
|
if restartAsyncTask.TaskID == "" || !restartAsyncTask.Task.AsyncMode {
|
||||||
t.Fatalf("restart async task should be accepted as async: %+v", restartAsyncTask)
|
t.Fatalf("restart async task should be accepted as async: %+v", restartAsyncTask)
|
||||||
@ -1453,6 +1457,20 @@ func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string, token string, payload map[string]any, marker string, expectedStatus int, responseOut any, taskDetailOut any) string {
|
||||||
|
t.Helper()
|
||||||
|
payload["integrationTestMarker"] = marker
|
||||||
|
if responseOut == nil {
|
||||||
|
responseOut = &map[string]any{}
|
||||||
|
}
|
||||||
|
doJSON(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, expectedStatus, responseOut)
|
||||||
|
taskID := waitForTaskIDByRequestField(t, ctx, pool, "integrationTestMarker", marker, 2*time.Second)
|
||||||
|
if taskDetailOut != nil {
|
||||||
|
doJSON(t, baseURL, http.MethodGet, "/api/v1/tasks/"+taskID, token, nil, http.StatusOK, taskDetailOut)
|
||||||
|
}
|
||||||
|
return taskID
|
||||||
|
}
|
||||||
|
|
||||||
type taskWaitDetail struct {
|
type taskWaitDetail struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@ -1481,6 +1499,11 @@ func waitForTaskStatus(t *testing.T, baseURL string, token string, taskID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func waitForTaskIDByRequestMarker(t *testing.T, ctx context.Context, pool *pgxpool.Pool, marker string, timeout time.Duration) string {
|
func waitForTaskIDByRequestMarker(t *testing.T, ctx context.Context, pool *pgxpool.Pool, marker string, timeout time.Duration) string {
|
||||||
|
t.Helper()
|
||||||
|
return waitForTaskIDByRequestField(t, ctx, pool, "cancelTestId", marker, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForTaskIDByRequestField(t *testing.T, ctx context.Context, pool *pgxpool.Pool, key string, value string, timeout time.Duration) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@ -1488,15 +1511,15 @@ func waitForTaskIDByRequestMarker(t *testing.T, ctx context.Context, pool *pgxpo
|
|||||||
err := pool.QueryRow(ctx, `
|
err := pool.QueryRow(ctx, `
|
||||||
SELECT id::text
|
SELECT id::text
|
||||||
FROM gateway_tasks
|
FROM gateway_tasks
|
||||||
WHERE request->>'cancelTestId' = $1
|
WHERE request->>$1 = $2
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1`, marker).Scan(&taskID)
|
LIMIT 1`, key, value).Scan(&taskID)
|
||||||
if err == nil && taskID != "" {
|
if err == nil && taskID != "" {
|
||||||
return taskID
|
return taskID
|
||||||
}
|
}
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
}
|
}
|
||||||
t.Fatalf("task with request marker %s was not created within %s", marker, timeout)
|
t.Fatalf("task with request %s=%s was not created within %s", key, value, timeout)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1577,13 +1600,13 @@ func assertLoadAvoidanceSimulatedRetryChain(t *testing.T, ctx context.Context, t
|
|||||||
ErrorCode string `json:"errorCode"`
|
ErrorCode string `json:"errorCode"`
|
||||||
} `json:"task"`
|
} `json:"task"`
|
||||||
}
|
}
|
||||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/chat/completions", runtimeToken, map[string]any{
|
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, baseURL, runtimeToken, map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"runMode": "simulation",
|
"runMode": "simulation",
|
||||||
"simulation": true,
|
"simulation": true,
|
||||||
"simulationDurationMs": 5,
|
"simulationDurationMs": 5,
|
||||||
"messages": []map[string]any{{"role": "user", "content": "load avoidance retry chain"}},
|
"messages": []map[string]any{{"role": "user", "content": "load avoidance retry chain"}},
|
||||||
}, http.StatusAccepted, &taskResponse)
|
}, "load-avoidance-"+suffixText, http.StatusBadGateway, nil, &taskResponse.Task)
|
||||||
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "failed" || taskResponse.Task.ErrorCode != "bad_request" {
|
if taskResponse.Task.ID == "" || taskResponse.Task.Status != "failed" || taskResponse.Task.ErrorCode != "bad_request" {
|
||||||
t.Fatalf("load avoidance task should only fail after avoided clients are retried, got %+v", taskResponse.Task)
|
t.Fatalf("load avoidance task should only fail after avoided clients are retried, got %+v", taskResponse.Task)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,22 @@ import (
|
|||||||
|
|
||||||
const maxGatewayUploadBytes = 256 << 20
|
const maxGatewayUploadBytes = 256 << 20
|
||||||
|
|
||||||
|
// uploadFile godoc
|
||||||
|
// @Summary 上传文件
|
||||||
|
// @Description 上传文件到配置的文件存储通道;没有启用通道时回退到本地静态上传目录。单文件最大 256MiB。
|
||||||
|
// @Tags files
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param file formData file true "要上传的文件"
|
||||||
|
// @Param source formData string false "上传来源标识" default(ai-gateway-openapi)
|
||||||
|
// @Success 200 {object} FileUploadResponse
|
||||||
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 502 {object} ErrorEnvelope
|
||||||
|
// @Failure 503 {object} ErrorEnvelope
|
||||||
|
// @Router /api/v1/files/upload [post]
|
||||||
|
// @Router /v1/files/upload [post]
|
||||||
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -858,7 +859,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
// createTask godoc
|
// createTask godoc
|
||||||
// @Summary 创建或执行 AI 任务
|
// @Summary 创建或执行 AI 任务
|
||||||
// @Description 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。
|
// @Description 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。
|
||||||
// @Tags tasks
|
// @Tags tasks
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@ -874,7 +875,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
|||||||
// @Failure 404 {object} ErrorEnvelope
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Failure 429 {object} ErrorEnvelope
|
// @Failure 429 {object} ErrorEnvelope
|
||||||
// @Failure 502 {object} ErrorEnvelope
|
// @Failure 502 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/chat/completions [post]
|
|
||||||
// @Router /api/v1/responses [post]
|
// @Router /api/v1/responses [post]
|
||||||
// @Router /api/v1/images/generations [post]
|
// @Router /api/v1/images/generations [post]
|
||||||
// @Router /api/v1/images/edits [post]
|
// @Router /api/v1/images/edits [post]
|
||||||
@ -909,13 +909,13 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
|
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
asyncMode := asyncRequest(r)
|
responsePlan := planTaskResponse(kind, compatible, body, r)
|
||||||
|
|
||||||
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
|
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Model: model,
|
Model: model,
|
||||||
RunMode: runModeFromRequest(body),
|
RunMode: runModeFromRequest(body),
|
||||||
Async: asyncMode,
|
Async: responsePlan.asyncMode,
|
||||||
Request: body,
|
Request: body,
|
||||||
}, user)
|
}, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -923,7 +923,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
writeError(w, http.StatusInternalServerError, "create task failed")
|
writeError(w, http.StatusInternalServerError, "create task failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if asyncMode {
|
if responsePlan.asyncMode {
|
||||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
|
writeError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
|
||||||
return
|
return
|
||||||
@ -933,65 +933,8 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
}
|
}
|
||||||
runCtx, cancelRun := s.requestExecutionContext(r)
|
runCtx, cancelRun := s.requestExecutionContext(r)
|
||||||
defer cancelRun()
|
defer cancelRun()
|
||||||
if compatible {
|
if responsePlan.compatibleMode {
|
||||||
if boolValue(body, "stream") {
|
writeCompatibleTaskResponse(runCtx, w, r, s.runner, kind, model, task, user, responsePlan.streamMode)
|
||||||
flusher := prepareCompatibleStream(w)
|
|
||||||
result, runErr := s.runner.ExecuteStream(runCtx, task, user, func(delta string) error {
|
|
||||||
if !requestStillConnected(r) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
writeCompatibleDelta(w, kind, model, delta)
|
|
||||||
if flusher != nil {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if runErr != nil {
|
|
||||||
if !requestStillConnected(r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
status := statusFromRunError(runErr)
|
|
||||||
errorPayload := map[string]any{
|
|
||||||
"code": runErrorCode(runErr),
|
|
||||||
"message": runErrorMessage(runErr),
|
|
||||||
"status": status,
|
|
||||||
}
|
|
||||||
if result.Task.ID != "" {
|
|
||||||
errorPayload["taskId"] = result.Task.ID
|
|
||||||
}
|
|
||||||
if result.Task.RequestID != "" {
|
|
||||||
errorPayload["requestId"] = result.Task.RequestID
|
|
||||||
}
|
|
||||||
for key, value := range runErrorDetails(runErr) {
|
|
||||||
errorPayload[key] = value
|
|
||||||
}
|
|
||||||
sendSSE(w, "error", map[string]any{"error": errorPayload})
|
|
||||||
if flusher != nil {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !requestStillConnected(r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeCompatibleDone(w, kind, model, result.Output)
|
|
||||||
if flusher != nil {
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, runErr := s.runner.Execute(runCtx, task, user)
|
|
||||||
if runErr != nil {
|
|
||||||
if !requestStillConnected(r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !requestStillConnected(r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, result.Output)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, runErr := s.runner.Execute(runCtx, task, user)
|
result, runErr := s.runner.Execute(runCtx, task, user)
|
||||||
@ -1006,6 +949,29 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createAPIV1ChatCompletions godoc
|
||||||
|
// @Summary 创建 Chat Completions
|
||||||
|
// @Description /api/v1/chat/completions 同步执行:stream=true 返回 text/event-stream SSE;stream=false 或未传返回兼容 JSON;该接口忽略 X-Async。
|
||||||
|
// @Tags tasks
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Produce text/event-stream
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param X-Async header bool false "该接口忽略此参数"
|
||||||
|
// @Param input body TaskRequest true "Chat Completions 请求"
|
||||||
|
// @Success 200 {object} ChatCompletionCompatibleResponse
|
||||||
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 402 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 429 {object} ErrorEnvelope
|
||||||
|
// @Failure 502 {object} ErrorEnvelope
|
||||||
|
// @Router /api/v1/chat/completions [post]
|
||||||
|
func (s *Server) createAPIV1ChatCompletions() http.Handler {
|
||||||
|
return s.createTask("chat.completions", false)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
|
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
|
||||||
base := context.WithoutCancel(r.Context())
|
base := context.WithoutCancel(r.Context())
|
||||||
if s.ctx == nil {
|
if s.ctx == nil {
|
||||||
@ -1031,11 +997,98 @@ func requestStillConnected(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type taskExecutor interface {
|
||||||
|
Execute(context.Context, store.GatewayTask, *auth.User) (runner.Result, error)
|
||||||
|
ExecuteStream(context.Context, store.GatewayTask, *auth.User, clients.StreamDelta) (runner.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter, r *http.Request, executor taskExecutor, kind string, model string, task store.GatewayTask, user *auth.User, streamMode bool) {
|
||||||
|
if streamMode {
|
||||||
|
flusher := prepareCompatibleStream(w)
|
||||||
|
result, runErr := executor.ExecuteStream(runCtx, task, user, func(delta string) error {
|
||||||
|
if !requestStillConnected(r) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writeCompatibleDelta(w, kind, model, delta)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if runErr != nil {
|
||||||
|
if !requestStillConnected(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := statusFromRunError(runErr)
|
||||||
|
errorPayload := map[string]any{
|
||||||
|
"code": runErrorCode(runErr),
|
||||||
|
"message": runErrorMessage(runErr),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if result.Task.ID != "" {
|
||||||
|
errorPayload["taskId"] = result.Task.ID
|
||||||
|
}
|
||||||
|
if result.Task.RequestID != "" {
|
||||||
|
errorPayload["requestId"] = result.Task.RequestID
|
||||||
|
}
|
||||||
|
for key, value := range runErrorDetails(runErr) {
|
||||||
|
errorPayload[key] = value
|
||||||
|
}
|
||||||
|
sendSSE(w, "error", map[string]any{"error": errorPayload})
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requestStillConnected(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeCompatibleDone(w, kind, model, result.Output)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, runErr := executor.Execute(runCtx, task, user)
|
||||||
|
if runErr != nil {
|
||||||
|
if !requestStillConnected(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requestStillConnected(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result.Output)
|
||||||
|
}
|
||||||
|
|
||||||
func asyncRequest(r *http.Request) bool {
|
func asyncRequest(r *http.Request) bool {
|
||||||
value := strings.TrimSpace(strings.ToLower(r.Header.Get("x-async")))
|
value := strings.TrimSpace(strings.ToLower(r.Header.Get("x-async")))
|
||||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type taskResponsePlan struct {
|
||||||
|
asyncMode bool
|
||||||
|
compatibleMode bool
|
||||||
|
streamMode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func planTaskResponse(kind string, compatible bool, body map[string]any, r *http.Request) taskResponsePlan {
|
||||||
|
asyncMode := asyncRequest(r)
|
||||||
|
compatibleMode := compatible
|
||||||
|
if kind == "chat.completions" && !compatible {
|
||||||
|
asyncMode = false
|
||||||
|
compatibleMode = true
|
||||||
|
}
|
||||||
|
return taskResponsePlan{
|
||||||
|
asyncMode: asyncMode,
|
||||||
|
compatibleMode: compatibleMode,
|
||||||
|
streamMode: boolValue(body, "stream"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeTaskAccepted(w http.ResponseWriter, task store.GatewayTask) {
|
func writeTaskAccepted(w http.ResponseWriter, task store.GatewayTask) {
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
"taskId": task.ID,
|
"taskId": task.ID,
|
||||||
|
|||||||
@ -123,6 +123,19 @@ type TaskEventListResponse struct {
|
|||||||
Items []store.TaskEvent `json:"items"`
|
Items []store.TaskEvent `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileStorageChannelListResponse struct {
|
||||||
|
Items []store.FileStorageChannel `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileUploadResponse struct {
|
||||||
|
ID string `json:"id,omitempty" example:"file_abc123"`
|
||||||
|
URL string `json:"url,omitempty" example:"/static/uploaded/upload-abc123.png"`
|
||||||
|
Filename string `json:"filename,omitempty" example:"image.png"`
|
||||||
|
ContentType string `json:"contentType,omitempty" example:"image/png"`
|
||||||
|
Size int `json:"size,omitempty" example:"1024"`
|
||||||
|
AssetStorage map[string]interface{} `json:"assetStorage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ReplacePlatformModelsRequest struct {
|
type ReplacePlatformModelsRequest struct {
|
||||||
Models []store.CreatePlatformModelInput `json:"models"`
|
Models []store.CreatePlatformModelInput `json:"models"`
|
||||||
}
|
}
|
||||||
@ -229,6 +242,32 @@ type CompatibleResponse struct {
|
|||||||
Usage map[string]interface{} `json:"usage,omitempty"`
|
Usage map[string]interface{} `json:"usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatCompletionCompatibleResponse struct {
|
||||||
|
ID string `json:"id" example:"chatcmpl-123"`
|
||||||
|
Object string `json:"object" example:"chat.completion"`
|
||||||
|
Created int64 `json:"created,omitempty" example:"1710000000"`
|
||||||
|
Model string `json:"model" example:"gpt-4o-mini"`
|
||||||
|
Choices []ChatCompletionChoice `json:"choices"`
|
||||||
|
Usage *ChatCompletionUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatCompletionChoice struct {
|
||||||
|
Index int `json:"index" example:"0"`
|
||||||
|
Message ChatCompletionChoiceMessage `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason,omitempty" example:"stop"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatCompletionChoiceMessage struct {
|
||||||
|
Role string `json:"role" example:"assistant"`
|
||||||
|
Content string `json:"content" example:"Hello"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatCompletionUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens,omitempty" example:"12"`
|
||||||
|
CompletionTokens int `json:"completion_tokens,omitempty" example:"8"`
|
||||||
|
TotalTokens int `json:"total_tokens,omitempty" example:"20"`
|
||||||
|
}
|
||||||
|
|
||||||
type NetworkProxyConfigResponse struct {
|
type NetworkProxyConfigResponse struct {
|
||||||
GlobalHTTPProxy string `json:"globalHttpProxy" example:"http://127.0.0.1:7890"`
|
GlobalHTTPProxy string `json:"globalHttpProxy" example:"http://127.0.0.1:7890"`
|
||||||
GlobalHTTPProxySet bool `json:"globalHttpProxySet" example:"true"`
|
GlobalHTTPProxySet bool `json:"globalHttpProxySet" example:"true"`
|
||||||
|
|||||||
@ -126,7 +126,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||||
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
||||||
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
|
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
|
||||||
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", false)))
|
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
|
||||||
mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false)))
|
mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false)))
|
||||||
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
|
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||||
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
|
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||||
|
|||||||
@ -9,10 +9,28 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// serveGeneratedStaticAsset godoc
|
||||||
|
// @Summary 获取本地生成资源
|
||||||
|
// @Description 从本地生成资源目录读取图片、视频等任务产物;不存在时返回 404。
|
||||||
|
// @Tags static
|
||||||
|
// @Produce octet-stream
|
||||||
|
// @Param asset path string true "资源文件名"
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Router /static/generated/{asset} [get]
|
||||||
func (s *Server) serveGeneratedStaticAsset(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveGeneratedStaticAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
s.serveLocalStaticAsset(w, r, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir)
|
s.serveLocalStaticAsset(w, r, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveUploadedStaticAsset godoc
|
||||||
|
// @Summary 获取本地上传资源
|
||||||
|
// @Description 从本地上传资源目录读取用户上传文件;不存在时返回 404。
|
||||||
|
// @Tags static
|
||||||
|
// @Produce octet-stream
|
||||||
|
// @Param asset path string true "资源文件名"
|
||||||
|
// @Success 200 {file} file
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Router /static/uploaded/{asset} [get]
|
||||||
func (s *Server) serveUploadedStaticAsset(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveUploadedStaticAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
s.serveLocalStaticAsset(w, r, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir)
|
s.serveLocalStaticAsset(w, r, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,17 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// listFileStorageChannels godoc
|
||||||
|
// @Summary 列出文件存储通道
|
||||||
|
// @Description 返回所有未删除的文件存储通道,用于管理上传与生成资源回传策略。
|
||||||
|
// @Tags system
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} FileStorageChannelListResponse
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/system/file-storage/channels [get]
|
||||||
func (s *Server) listFileStorageChannels(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listFileStorageChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := s.store.ListFileStorageChannels(r.Context())
|
items, err := s.store.ListFileStorageChannels(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -18,6 +29,17 @@ func (s *Server) listFileStorageChannels(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getFileStorageSettings godoc
|
||||||
|
// @Summary 获取文件存储设置
|
||||||
|
// @Description 返回文件存储系统设置;数据库对象尚未创建时返回默认设置。
|
||||||
|
// @Tags system
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} store.FileStorageSettings
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/system/file-storage/settings [get]
|
||||||
func (s *Server) getFileStorageSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getFileStorageSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
settings, err := s.store.GetFileStorageSettings(r.Context())
|
settings, err := s.store.GetFileStorageSettings(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -32,6 +54,20 @@ func (s *Server) getFileStorageSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, settings)
|
writeJSON(w, http.StatusOK, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateFileStorageSettings godoc
|
||||||
|
// @Summary 更新文件存储设置
|
||||||
|
// @Description 更新生成资源上传策略等文件存储系统设置。
|
||||||
|
// @Tags system
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param body body store.FileStorageSettingsInput true "文件存储设置"
|
||||||
|
// @Success 200 {object} store.FileStorageSettings
|
||||||
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/system/file-storage/settings [patch]
|
||||||
func (s *Server) updateFileStorageSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) updateFileStorageSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
var input store.FileStorageSettingsInput
|
var input store.FileStorageSettingsInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
@ -47,6 +83,21 @@ func (s *Server) updateFileStorageSettings(w http.ResponseWriter, r *http.Reques
|
|||||||
writeJSON(w, http.StatusOK, settings)
|
writeJSON(w, http.StatusOK, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createFileStorageChannel godoc
|
||||||
|
// @Summary 创建文件存储通道
|
||||||
|
// @Description 创建文件存储通道,当前主要用于配置 server-main OpenAPI 上传通道。
|
||||||
|
// @Tags system
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param body body store.FileStorageChannelInput true "文件存储通道"
|
||||||
|
// @Success 201 {object} store.FileStorageChannel
|
||||||
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 409 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/system/file-storage/channels [post]
|
||||||
func (s *Server) createFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) createFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
var input store.FileStorageChannelInput
|
var input store.FileStorageChannelInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
@ -70,6 +121,23 @@ func (s *Server) createFileStorageChannel(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, http.StatusCreated, item)
|
writeJSON(w, http.StatusCreated, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateFileStorageChannel godoc
|
||||||
|
// @Summary 更新文件存储通道
|
||||||
|
// @Description 更新指定文件存储通道的名称、凭证、场景、优先级、状态和重试策略。
|
||||||
|
// @Tags system
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param channelID path string true "文件存储通道 ID"
|
||||||
|
// @Param body body store.FileStorageChannelInput true "文件存储通道"
|
||||||
|
// @Success 200 {object} store.FileStorageChannel
|
||||||
|
// @Failure 400 {object} ErrorEnvelope
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 409 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/system/file-storage/channels/{channelID} [patch]
|
||||||
func (s *Server) updateFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) updateFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
var input store.FileStorageChannelInput
|
var input store.FileStorageChannelInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
@ -107,6 +175,19 @@ func (s *Server) updateFileStorageChannel(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, http.StatusOK, item)
|
writeJSON(w, http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteFileStorageChannel godoc
|
||||||
|
// @Summary 删除文件存储通道
|
||||||
|
// @Description 软删除指定文件存储通道。
|
||||||
|
// @Tags system
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param channelID path string true "文件存储通道 ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/admin/system/file-storage/channels/{channelID} [delete]
|
||||||
func (s *Server) deleteFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) deleteFileStorageChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.store.DeleteFileStorageChannel(r.Context(), r.PathValue("channelID")); err != nil {
|
if err := s.store.DeleteFileStorageChannel(r.Context(), r.PathValue("channelID")); err != nil {
|
||||||
if store.IsNotFound(err) {
|
if store.IsNotFound(err) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -210,6 +211,9 @@ func failureMetrics(err error, simulated bool) (string, map[string]any, time.Tim
|
|||||||
metrics["error"] = err.Error()
|
metrics["error"] = err.Error()
|
||||||
metrics["errorCategory"] = info.Category
|
metrics["errorCategory"] = info.Category
|
||||||
metrics["retryable"] = retryable
|
metrics["retryable"] = retryable
|
||||||
|
if detail := rateLimitFailureDetail(err); len(detail) > 0 {
|
||||||
|
metrics["rateLimit"] = detail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if meta.StatusCode > 0 {
|
if meta.StatusCode > 0 {
|
||||||
metrics["statusCode"] = meta.StatusCode
|
metrics["statusCode"] = meta.StatusCode
|
||||||
@ -226,6 +230,47 @@ func failureMetrics(err error, simulated bool) (string, map[string]any, time.Tim
|
|||||||
return meta.RequestID, metrics, meta.ResponseStartedAt, meta.ResponseFinishedAt, meta.ResponseDurationMS
|
return meta.RequestID, metrics, meta.ResponseStartedAt, meta.ResponseFinishedAt, meta.ResponseDurationMS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rateLimitFailureDetail(err error) map[string]any {
|
||||||
|
var limitErr *store.RateLimitExceededError
|
||||||
|
if !errors.As(err, &limitErr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
detail := map[string]any{
|
||||||
|
"scopeType": limitErr.ScopeType,
|
||||||
|
"scopeKey": limitErr.ScopeKey,
|
||||||
|
"scopeName": limitErr.ScopeName,
|
||||||
|
"metric": limitErr.Metric,
|
||||||
|
"limit": limitErr.Limit,
|
||||||
|
"amount": limitErr.Amount,
|
||||||
|
"current": limitErr.Current,
|
||||||
|
"used": limitErr.Used,
|
||||||
|
"reserved": limitErr.Reserved,
|
||||||
|
"projected": limitErr.Projected,
|
||||||
|
"windowSeconds": limitErr.WindowSeconds,
|
||||||
|
"retryable": limitErr.Retryable,
|
||||||
|
"exceeded": map[string]any{
|
||||||
|
"metric": limitErr.Metric,
|
||||||
|
"current": limitErr.Current,
|
||||||
|
"amount": limitErr.Amount,
|
||||||
|
"projected": limitErr.Projected,
|
||||||
|
"limit": limitErr.Limit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if limitErr.RetryAfter > 0 {
|
||||||
|
detail["retryAfterMs"] = limitErr.RetryAfter.Milliseconds()
|
||||||
|
}
|
||||||
|
if !limitErr.ResetAt.IsZero() {
|
||||||
|
detail["resetAt"] = limitErr.ResetAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
if len(limitErr.ScopeMetadata) > 0 {
|
||||||
|
detail["scopeMetadata"] = limitErr.ScopeMetadata
|
||||||
|
}
|
||||||
|
if len(limitErr.Policy) > 0 {
|
||||||
|
detail["rateLimitPolicy"] = limitErr.Policy
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
func mergeMetrics(values ...map[string]any) map[string]any {
|
func mergeMetrics(values ...map[string]any) map[string]any {
|
||||||
out := map[string]any{}
|
out := map[string]any{}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
|
|||||||
@ -55,6 +55,8 @@ func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service {
|
|||||||
"openai": clients.OpenAIClient{HTTPClient: httpClients.none},
|
"openai": clients.OpenAIClient{HTTPClient: httpClients.none},
|
||||||
"gemini": clients.GeminiClient{HTTPClient: httpClients.none},
|
"gemini": clients.GeminiClient{HTTPClient: httpClients.none},
|
||||||
"volces": clients.VolcesClient{HTTPClient: httpClients.none},
|
"volces": clients.VolcesClient{HTTPClient: httpClients.none},
|
||||||
|
"keling": clients.KelingClient{HTTPClient: httpClients.none},
|
||||||
|
"kling": clients.KelingClient{HTTPClient: httpClients.none},
|
||||||
"simulation": clients.SimulationClient{},
|
"simulation": clients.SimulationClient{},
|
||||||
},
|
},
|
||||||
httpClients: httpClients,
|
httpClients: httpClients,
|
||||||
@ -82,6 +84,17 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := validateRequest(task.Kind, body); err != nil {
|
if err := validateRequest(task.Kind, body); err != nil {
|
||||||
|
s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||||
|
Task: task,
|
||||||
|
Body: body,
|
||||||
|
AttemptNo: task.AttemptCount + 1,
|
||||||
|
Code: "bad_request",
|
||||||
|
Cause: err,
|
||||||
|
Simulated: task.RunMode == "simulation",
|
||||||
|
Scope: "request_validation",
|
||||||
|
Reason: "request_validation_failed",
|
||||||
|
ModelType: modelType,
|
||||||
|
})
|
||||||
failed, finishErr := s.failTask(ctx, task.ID, "bad_request", err.Error(), task.RunMode == "simulation", err)
|
failed, finishErr := s.failTask(ctx, task.ID, "bad_request", err.Error(), task.RunMode == "simulation", err)
|
||||||
if finishErr != nil {
|
if finishErr != nil {
|
||||||
return Result{}, finishErr
|
return Result{}, finishErr
|
||||||
@ -90,6 +103,17 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
}
|
}
|
||||||
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType, user)
|
candidates, err := s.store.ListModelCandidates(ctx, task.Model, modelType, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||||
|
Task: task,
|
||||||
|
Body: body,
|
||||||
|
AttemptNo: task.AttemptCount + 1,
|
||||||
|
Code: store.ModelCandidateErrorCode(err),
|
||||||
|
Cause: err,
|
||||||
|
Simulated: task.RunMode == "simulation",
|
||||||
|
Scope: "candidate_selection",
|
||||||
|
Reason: "candidate_selection_failed",
|
||||||
|
ModelType: modelType,
|
||||||
|
})
|
||||||
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err)
|
||||||
if finishErr != nil {
|
if finishErr != nil {
|
||||||
return Result{}, finishErr
|
return Result{}, finishErr
|
||||||
@ -98,6 +122,7 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
}
|
}
|
||||||
firstCandidateBody := body
|
firstCandidateBody := body
|
||||||
normalizedModelType := modelType
|
normalizedModelType := modelType
|
||||||
|
attemptNo := task.AttemptCount
|
||||||
var firstPreprocessing parameterPreprocessingLog
|
var firstPreprocessing parameterPreprocessingLog
|
||||||
if len(candidates) > 0 {
|
if len(candidates) > 0 {
|
||||||
preprocessing := preprocessRequestWithLog(task.Kind, body, candidates[0])
|
preprocessing := preprocessRequestWithLog(task.Kind, body, candidates[0])
|
||||||
@ -106,9 +131,20 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
normalizedModelType = candidates[0].ModelType
|
normalizedModelType = candidates[0].ModelType
|
||||||
if preprocessing.Err != nil {
|
if preprocessing.Err != nil {
|
||||||
clientErr := parameterPreprocessClientError(preprocessing.Err)
|
clientErr := parameterPreprocessClientError(preprocessing.Err)
|
||||||
if logErr := s.recordTaskParameterPreprocessing(ctx, task.ID, "", 0, candidates[0], firstPreprocessing); logErr != nil {
|
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||||
return Result{}, logErr
|
Task: task,
|
||||||
}
|
Body: firstCandidateBody,
|
||||||
|
Candidate: &candidates[0],
|
||||||
|
AttemptNo: attemptNo + 1,
|
||||||
|
Code: clients.ErrorCode(clientErr),
|
||||||
|
Cause: clientErr,
|
||||||
|
Simulated: task.RunMode == "simulation",
|
||||||
|
Scope: "parameter_preprocessing",
|
||||||
|
Reason: "parameter_preprocessing_failed",
|
||||||
|
ExtraMetrics: []map[string]any{parameterPreprocessingMetrics(firstPreprocessing)},
|
||||||
|
Preprocessing: &firstPreprocessing,
|
||||||
|
ModelType: normalizedModelType,
|
||||||
|
})
|
||||||
failed, finishErr := s.failTask(ctx, task.ID, clients.ErrorCode(clientErr), clientErr.Error(), task.RunMode == "simulation", clientErr, parameterPreprocessingMetrics(firstPreprocessing))
|
failed, finishErr := s.failTask(ctx, task.ID, clients.ErrorCode(clientErr), clientErr.Error(), task.RunMode == "simulation", clientErr, parameterPreprocessingMetrics(firstPreprocessing))
|
||||||
if finishErr != nil {
|
if finishErr != nil {
|
||||||
return Result{}, finishErr
|
return Result{}, finishErr
|
||||||
@ -121,9 +157,20 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
estimatedBillings := s.estimatedBillings(ctx, user, task.Kind, firstCandidateBody, candidates[0])
|
estimatedBillings := s.estimatedBillings(ctx, user, task.Kind, firstCandidateBody, candidates[0])
|
||||||
if err := s.ensureWalletBalance(ctx, user, estimatedBillings); err != nil {
|
if err := s.ensureWalletBalance(ctx, user, estimatedBillings); err != nil {
|
||||||
if errors.Is(err, store.ErrInsufficientWalletBalance) {
|
if errors.Is(err, store.ErrInsufficientWalletBalance) {
|
||||||
if logErr := s.recordTaskParameterPreprocessing(ctx, task.ID, "", 0, candidates[0], firstPreprocessing); logErr != nil {
|
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||||
return Result{}, logErr
|
Task: task,
|
||||||
}
|
Body: firstCandidateBody,
|
||||||
|
Candidate: &candidates[0],
|
||||||
|
AttemptNo: attemptNo + 1,
|
||||||
|
Code: "insufficient_balance",
|
||||||
|
Cause: err,
|
||||||
|
Simulated: task.RunMode == "simulation",
|
||||||
|
Scope: "wallet_balance",
|
||||||
|
Reason: "wallet_balance_check_failed",
|
||||||
|
ExtraMetrics: []map[string]any{parameterPreprocessingMetrics(firstPreprocessing)},
|
||||||
|
Preprocessing: &firstPreprocessing,
|
||||||
|
ModelType: normalizedModelType,
|
||||||
|
})
|
||||||
failed, finishErr := s.failTask(ctx, task.ID, "insufficient_balance", err.Error(), task.RunMode == "simulation", err, parameterPreprocessingMetrics(firstPreprocessing))
|
failed, finishErr := s.failTask(ctx, task.ID, "insufficient_balance", err.Error(), task.RunMode == "simulation", err, parameterPreprocessingMetrics(firstPreprocessing))
|
||||||
if finishErr != nil {
|
if finishErr != nil {
|
||||||
return Result{}, finishErr
|
return Result{}, finishErr
|
||||||
@ -143,7 +190,6 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
|
|||||||
}
|
}
|
||||||
maxPlatforms := maxPlatformsForCandidates(candidates, runnerPolicy)
|
maxPlatforms := maxPlatformsForCandidates(candidates, runnerPolicy)
|
||||||
maxFailoverDuration := maxFailoverDurationForCandidates(candidates, runnerPolicy)
|
maxFailoverDuration := maxFailoverDurationForCandidates(candidates, runnerPolicy)
|
||||||
attemptNo := task.AttemptCount
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var lastCandidate store.RuntimeModelCandidate
|
var lastCandidate store.RuntimeModelCandidate
|
||||||
var lastPreprocessing *parameterPreprocessingLog
|
var lastPreprocessing *parameterPreprocessingLog
|
||||||
@ -162,6 +208,20 @@ candidatesLoop:
|
|||||||
lastPreprocessing = &preprocessingLog
|
lastPreprocessing = &preprocessingLog
|
||||||
if preprocessing.Err != nil {
|
if preprocessing.Err != nil {
|
||||||
lastErr = parameterPreprocessClientError(preprocessing.Err)
|
lastErr = parameterPreprocessClientError(preprocessing.Err)
|
||||||
|
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||||
|
Task: task,
|
||||||
|
Body: preprocessing.Body,
|
||||||
|
Candidate: &candidate,
|
||||||
|
AttemptNo: nextAttemptNo,
|
||||||
|
Code: clients.ErrorCode(lastErr),
|
||||||
|
Cause: lastErr,
|
||||||
|
Simulated: isSimulation(task, candidate),
|
||||||
|
Scope: "parameter_preprocessing",
|
||||||
|
Reason: "parameter_preprocessing_failed",
|
||||||
|
ExtraMetrics: []map[string]any{parameterPreprocessingMetrics(preprocessingLog)},
|
||||||
|
Preprocessing: &preprocessingLog,
|
||||||
|
ModelType: candidate.ModelType,
|
||||||
|
})
|
||||||
break candidatesLoop
|
break candidatesLoop
|
||||||
}
|
}
|
||||||
candidateBody := preprocessing.Body
|
candidateBody := preprocessing.Body
|
||||||
@ -222,6 +282,19 @@ candidatesLoop:
|
|||||||
}
|
}
|
||||||
return Result{Task: queued, Output: queued.Result}, &TaskQueuedError{Delay: delay}
|
return Result{Task: queued, Output: queued.Result}, &TaskQueuedError{Delay: delay}
|
||||||
}
|
}
|
||||||
|
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{
|
||||||
|
Task: task,
|
||||||
|
Body: candidateBody,
|
||||||
|
Candidate: &candidate,
|
||||||
|
AttemptNo: nextAttemptNo,
|
||||||
|
Code: clients.ErrorCode(err),
|
||||||
|
Cause: err,
|
||||||
|
Simulated: isSimulation(task, candidate),
|
||||||
|
Scope: "rate_limit",
|
||||||
|
Reason: "local_rate_limit_blocked",
|
||||||
|
ExtraMetrics: []map[string]any{parameterPreprocessingMetrics(preprocessing.Log)},
|
||||||
|
ModelType: candidate.ModelType,
|
||||||
|
})
|
||||||
break candidatesLoop
|
break candidatesLoop
|
||||||
}
|
}
|
||||||
attemptNo = nextAttemptNo
|
attemptNo = nextAttemptNo
|
||||||
@ -616,6 +689,110 @@ func (s *Service) failTask(ctx context.Context, taskID string, code string, mess
|
|||||||
return failed, nil
|
return failed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type failedAttemptRecord struct {
|
||||||
|
Task store.GatewayTask
|
||||||
|
Body map[string]any
|
||||||
|
Candidate *store.RuntimeModelCandidate
|
||||||
|
AttemptNo int
|
||||||
|
Code string
|
||||||
|
Cause error
|
||||||
|
Simulated bool
|
||||||
|
Scope string
|
||||||
|
Reason string
|
||||||
|
ExtraMetrics []map[string]any
|
||||||
|
Preprocessing *parameterPreprocessingLog
|
||||||
|
ModelType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordFailedAttempt(ctx context.Context, input failedAttemptRecord) int {
|
||||||
|
attemptNo := input.AttemptNo
|
||||||
|
if attemptNo <= 0 {
|
||||||
|
attemptNo = input.Task.AttemptCount + 1
|
||||||
|
}
|
||||||
|
code := firstNonEmptyString(input.Code, clients.ErrorCode(input.Cause))
|
||||||
|
message := ""
|
||||||
|
if input.Cause != nil {
|
||||||
|
message = input.Cause.Error()
|
||||||
|
}
|
||||||
|
retryable := clients.IsRetryable(input.Cause)
|
||||||
|
requestID, failure, responseStartedAt, responseFinishedAt, responseDurationMS := failureMetrics(input.Cause, input.Simulated)
|
||||||
|
scope := firstNonEmptyString(input.Scope, "pre_provider")
|
||||||
|
reason := firstNonEmptyString(input.Reason, "pre_provider_failed")
|
||||||
|
trace := failureTraceEntryWithReason(input.Cause, retryable, scope, reason)
|
||||||
|
statusCode := clients.ErrorResponseMetadata(input.Cause).StatusCode
|
||||||
|
category := failureCategory(strings.ToLower(strings.TrimSpace(code)), statusCode, message)
|
||||||
|
if code != "" {
|
||||||
|
failure["errorCode"] = code
|
||||||
|
trace["errorCode"] = code
|
||||||
|
}
|
||||||
|
if category != "" {
|
||||||
|
failure["errorCategory"] = category
|
||||||
|
trace["category"] = category
|
||||||
|
}
|
||||||
|
failure["failureScope"] = scope
|
||||||
|
failure["failureReason"] = reason
|
||||||
|
failure["trace"] = []any{trace}
|
||||||
|
|
||||||
|
baseMetrics := map[string]any{
|
||||||
|
"attempt": attemptNo,
|
||||||
|
"kind": input.Task.Kind,
|
||||||
|
"runMode": input.Task.RunMode,
|
||||||
|
"requestedModel": input.Task.Model,
|
||||||
|
"simulated": input.Simulated,
|
||||||
|
}
|
||||||
|
if input.ModelType != "" {
|
||||||
|
baseMetrics["modelType"] = input.ModelType
|
||||||
|
}
|
||||||
|
var platformID, platformModelID, clientID, queueKey string
|
||||||
|
if input.Candidate != nil {
|
||||||
|
baseMetrics = attemptMetrics(*input.Candidate, attemptNo, input.Simulated)
|
||||||
|
baseMetrics["kind"] = input.Task.Kind
|
||||||
|
baseMetrics["runMode"] = input.Task.RunMode
|
||||||
|
baseMetrics["requestedModel"] = input.Task.Model
|
||||||
|
platformID = input.Candidate.PlatformID
|
||||||
|
platformModelID = input.Candidate.PlatformModelID
|
||||||
|
clientID = input.Candidate.ClientID
|
||||||
|
queueKey = input.Candidate.QueueKey
|
||||||
|
}
|
||||||
|
metrics := mergeMetrics(append([]map[string]any{baseMetrics, failure}, input.ExtraMetrics...)...)
|
||||||
|
attemptID, err := s.store.CreateTaskAttempt(ctx, store.CreateTaskAttemptInput{
|
||||||
|
TaskID: input.Task.ID,
|
||||||
|
AttemptNo: attemptNo,
|
||||||
|
PlatformID: platformID,
|
||||||
|
PlatformModelID: platformModelID,
|
||||||
|
ClientID: clientID,
|
||||||
|
QueueKey: queueKey,
|
||||||
|
Status: "running",
|
||||||
|
Simulated: input.Simulated,
|
||||||
|
RequestSnapshot: input.Body,
|
||||||
|
Metrics: metrics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("record failed task attempt failed", "taskID", input.Task.ID, "attempt", attemptNo, "error", err)
|
||||||
|
return attemptNo
|
||||||
|
}
|
||||||
|
if input.Preprocessing != nil && input.Candidate != nil {
|
||||||
|
if err := s.recordTaskParameterPreprocessing(ctx, input.Task.ID, attemptID, attemptNo, *input.Candidate, *input.Preprocessing); err != nil {
|
||||||
|
s.logger.Warn("record failed attempt parameter preprocessing failed", "taskID", input.Task.ID, "attempt", attemptNo, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||||
|
AttemptID: attemptID,
|
||||||
|
Status: "failed",
|
||||||
|
Retryable: retryable,
|
||||||
|
RequestID: requestID,
|
||||||
|
Metrics: metrics,
|
||||||
|
ResponseStartedAt: responseStartedAt,
|
||||||
|
ResponseFinishedAt: responseFinishedAt,
|
||||||
|
ResponseDurationMS: responseDurationMS,
|
||||||
|
ErrorCode: code,
|
||||||
|
ErrorMessage: message,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("finish failed task attempt failed", "taskID", input.Task.ID, "attempt", attemptNo, "error", err)
|
||||||
|
}
|
||||||
|
return attemptNo
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) requeueRateLimitedTask(ctx context.Context, task store.GatewayTask, cause error, candidate store.RuntimeModelCandidate) (store.GatewayTask, time.Duration, error) {
|
func (s *Service) requeueRateLimitedTask(ctx context.Context, task store.GatewayTask, cause error, candidate store.RuntimeModelCandidate) (store.GatewayTask, time.Duration, error) {
|
||||||
delay := localRateLimitRetryAfter(cause)
|
delay := localRateLimitRetryAfter(cause)
|
||||||
if delay <= 0 {
|
if delay <= 0 {
|
||||||
|
|||||||
@ -7,8 +7,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func failureTraceEntry(err error, retryable bool) map[string]any {
|
func failureTraceEntry(err error, retryable bool) map[string]any {
|
||||||
|
return failureTraceEntryWithReason(err, retryable, "client", "client_call_failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func failureTraceEntryWithReason(err error, retryable bool, scope string, reason string) map[string]any {
|
||||||
info := failureInfoFromError(err)
|
info := failureInfoFromError(err)
|
||||||
entry := policyTraceEntry("failure", "client", "failed", "client_call_failed", policyRuleMatch{}, info)
|
entry := policyTraceEntry("failure", scope, "failed", reason, policyRuleMatch{}, info)
|
||||||
entry["retryable"] = retryable
|
entry["retryable"] = retryable
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|||||||
25
apps/api/migrations/0038_keling_omni_audio_flags.sql
Normal file
25
apps/api/migrations/0038_keling_omni_audio_flags.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
UPDATE base_model_catalog
|
||||||
|
SET capabilities = jsonb_set(
|
||||||
|
jsonb_set(capabilities, '{omni_video,input_audio}', 'false'::jsonb, true),
|
||||||
|
'{omni_video,max_audios}', '0'::jsonb, true
|
||||||
|
),
|
||||||
|
metadata = jsonb_set(
|
||||||
|
jsonb_set(metadata, '{rawModel,capabilities,omni_video,input_audio}', 'false'::jsonb, true),
|
||||||
|
'{rawModel,capabilities,omni_video,max_audios}', '0'::jsonb, true
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE provider_key = 'keling'
|
||||||
|
AND provider_model_name IN ('kling-video-o1', 'kling-v3-omni')
|
||||||
|
AND capabilities ? 'omni_video';
|
||||||
|
|
||||||
|
UPDATE platform_models m
|
||||||
|
SET capabilities = jsonb_set(
|
||||||
|
jsonb_set(m.capabilities, '{omni_video,input_audio}', 'false'::jsonb, true),
|
||||||
|
'{omni_video,max_audios}', '0'::jsonb, true
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
FROM integration_platforms p
|
||||||
|
WHERE m.platform_id = p.id
|
||||||
|
AND p.provider = 'keling'
|
||||||
|
AND COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) IN ('kling-video-o1', 'kling-v3-omni')
|
||||||
|
AND m.capabilities ? 'omni_video';
|
||||||
@ -812,7 +812,7 @@ function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId:
|
|||||||
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
|
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
|
||||||
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
|
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
|
||||||
<TableCell>{chargeText}</TableCell>
|
<TableCell>{chargeText}</TableCell>
|
||||||
<TableCell>{formatDuration(props.task.responseDurationMs)}</TableCell>
|
<TableCell>{formatDuration(taskDurationMs(props.task))}</TableCell>
|
||||||
<TableCell>{formatDateTime(props.task.createdAt)}</TableCell>
|
<TableCell>{formatDateTime(props.task.createdAt)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button type="button" variant="ghost" size="sm" className="taskRecordJsonButton" title={taskErrorText(props.task) || '查看原始 JSON'} onClick={() => props.onOpenJson(props.task)}>
|
<Button type="button" variant="ghost" size="sm" className="taskRecordJsonButton" title={taskErrorText(props.task) || '查看原始 JSON'} onClick={() => props.onOpenJson(props.task)}>
|
||||||
@ -971,28 +971,33 @@ function TaskAttemptPopoverContent(props: { task: GatewayTask }) {
|
|||||||
const attempts = props.task.attempts ?? [];
|
const attempts = props.task.attempts ?? [];
|
||||||
return (
|
return (
|
||||||
<span className="taskRecordAttemptPopover" role="tooltip">
|
<span className="taskRecordAttemptPopover" role="tooltip">
|
||||||
{attempts.map((attempt) => (
|
{attempts.map((attempt) => {
|
||||||
<span
|
const trace = taskAttemptTrace(attempt);
|
||||||
key={attempt.id || `${props.task.id}-${attempt.attemptNo}`}
|
const rateLimitText = taskAttemptRateLimitText(attempt);
|
||||||
className={`taskRecordAttemptDetail ${attempt.status === 'failed' ? 'failed' : attempt.status === 'succeeded' ? 'succeeded' : ''}`}
|
return (
|
||||||
>
|
<span
|
||||||
<span className="taskRecordAttemptDetailHeader">
|
key={attempt.id || `${props.task.id}-${attempt.attemptNo}`}
|
||||||
<strong>#{attempt.attemptNo} {taskAttemptTarget(attempt)}</strong>
|
className={`taskRecordAttemptDetail ${attempt.status === 'failed' ? 'failed' : attempt.status === 'succeeded' ? 'succeeded' : ''}`}
|
||||||
<Badge variant={attempt.status === 'succeeded' ? 'success' : attempt.status === 'failed' ? 'destructive' : 'secondary'}>{taskAttemptStatusText(attempt.status)}</Badge>
|
>
|
||||||
</span>
|
<span className="taskRecordAttemptDetailHeader">
|
||||||
<small>{taskAttemptMeta(attempt)}</small>
|
<strong>#{attempt.attemptNo} {taskAttemptTarget(attempt)}</strong>
|
||||||
{attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>}
|
<Badge variant={attempt.status === 'succeeded' ? 'success' : attempt.status === 'failed' ? 'destructive' : 'secondary'}>{taskAttemptStatusText(attempt.status)}</Badge>
|
||||||
{taskAttemptTrace(attempt).length > 0 && (
|
|
||||||
<span className="taskRecordAttemptTrace">
|
|
||||||
{taskAttemptTrace(attempt).map((entry, index) => (
|
|
||||||
<span key={`${attempt.id || attempt.attemptNo}-trace-${index}`} className="taskRecordAttemptTraceItem">
|
|
||||||
{taskAttemptTraceText(entry)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<small>{taskAttemptMeta(attempt)}</small>
|
||||||
</span>
|
{attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>}
|
||||||
))}
|
{(rateLimitText || trace.length > 0) && (
|
||||||
|
<span className="taskRecordAttemptTrace">
|
||||||
|
{rateLimitText && <span className="taskRecordAttemptTraceItem">{rateLimitText}</span>}
|
||||||
|
{trace.map((entry, index) => (
|
||||||
|
<span key={`${attempt.id || attempt.attemptNo}-trace-${index}`} className="taskRecordAttemptTraceItem">
|
||||||
|
{taskAttemptTraceText(entry)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1024,7 +1029,7 @@ function taskAttemptMeta(attempt: NonNullable<GatewayTask['attempts']>[number])
|
|||||||
attempt.providerModelName || attempt.modelName || attempt.modelAlias,
|
attempt.providerModelName || attempt.modelName || attempt.modelAlias,
|
||||||
attempt.requestId ? `RequestID ${attempt.requestId}` : '',
|
attempt.requestId ? `RequestID ${attempt.requestId}` : '',
|
||||||
statusCode ? `状态码 ${statusCode}` : '',
|
statusCode ? `状态码 ${statusCode}` : '',
|
||||||
attempt.responseDurationMs ? formatDuration(attempt.responseDurationMs) : '',
|
formatDuration(attemptDurationMs(attempt)),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return values.join(' · ') || attempt.clientId || '-';
|
return values.join(' · ') || attempt.clientId || '-';
|
||||||
}
|
}
|
||||||
@ -1055,6 +1060,29 @@ function taskAttemptTrace(attempt: NonNullable<GatewayTask['attempts']>[number])
|
|||||||
return raw.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item));
|
return raw.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskAttemptRateLimitText(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||||||
|
const detail = metadataObject(attempt.metrics, 'rateLimit');
|
||||||
|
if (!Object.keys(detail).length) return '';
|
||||||
|
const scopeName = objectString(detail, 'scopeName') || objectString(detail, 'scopeKey') || '限流对象';
|
||||||
|
const metric = objectString(detail, 'metric') || 'rate_limit';
|
||||||
|
const current = metadataNumber(detail, 'current');
|
||||||
|
const amount = metadataNumber(detail, 'amount');
|
||||||
|
const projected = metadataNumber(detail, 'projected');
|
||||||
|
const limit = metadataNumber(detail, 'limit');
|
||||||
|
const windowSeconds = metadataNumber(detail, 'windowSeconds');
|
||||||
|
const retryAfterMs = metadataNumber(detail, 'retryAfterMs');
|
||||||
|
const values = [
|
||||||
|
`限流 ${scopeName} · ${metric}`,
|
||||||
|
current !== null ? `当前 ${formatCellValue(current)}` : '',
|
||||||
|
amount !== null ? `本次 ${formatCellValue(amount)}` : '',
|
||||||
|
projected !== null ? `预计 ${formatCellValue(projected)}` : '',
|
||||||
|
limit !== null ? `限制 ${formatCellValue(limit)}` : '',
|
||||||
|
windowSeconds !== null ? `窗口 ${Math.trunc(windowSeconds)} 秒` : '',
|
||||||
|
retryAfterMs !== null ? `约 ${formatDuration(Math.trunc(retryAfterMs))} 后可重试` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return values.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
function taskAttemptTraceText(entry: Record<string, unknown>) {
|
function taskAttemptTraceText(entry: Record<string, unknown>) {
|
||||||
const event = objectString(entry, 'event');
|
const event = objectString(entry, 'event');
|
||||||
const action = objectString(entry, 'action');
|
const action = objectString(entry, 'action');
|
||||||
@ -1116,6 +1144,12 @@ function taskAttemptTraceReasonLabel(reason: string) {
|
|||||||
client_retryable: '客户端标记可重试',
|
client_retryable: '客户端标记可重试',
|
||||||
client_non_retryable: '客户端标记不可重试',
|
client_non_retryable: '客户端标记不可重试',
|
||||||
same_client_max_attempts: '达到本平台最大尝试次数',
|
same_client_max_attempts: '达到本平台最大尝试次数',
|
||||||
|
request_validation_failed: '请求校验失败',
|
||||||
|
candidate_selection_failed: '候选模型选择失败',
|
||||||
|
parameter_preprocessing_failed: '参数预处理失败',
|
||||||
|
wallet_balance_check_failed: '余额校验失败',
|
||||||
|
local_rate_limit_blocked: '本地限流拦截',
|
||||||
|
pre_provider_failed: '调用上游前失败',
|
||||||
local_rate_limit_wait_queue: '本地限流排队等待',
|
local_rate_limit_wait_queue: '本地限流排队等待',
|
||||||
failover_time_budget_exceeded: '超过全局切换时间预算',
|
failover_time_budget_exceeded: '超过全局切换时间预算',
|
||||||
runner_policy_disabled: '全局调度策略停用',
|
runner_policy_disabled: '全局调度策略停用',
|
||||||
@ -1321,10 +1355,41 @@ function tokenValue(value: unknown) {
|
|||||||
return Number.isFinite(numericValue) ? numericValue : null;
|
return Number.isFinite(numericValue) ? numericValue : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskDurationMs(task: GatewayTask) {
|
||||||
|
return (
|
||||||
|
positiveDurationMs(task.responseDurationMs) ??
|
||||||
|
elapsedDurationMs(task.responseStartedAt, task.responseFinishedAt) ??
|
||||||
|
elapsedDurationMs(task.createdAt, task.finishedAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptDurationMs(attempt: NonNullable<GatewayTask['attempts']>[number]) {
|
||||||
|
return (
|
||||||
|
positiveDurationMs(attempt.responseDurationMs) ??
|
||||||
|
elapsedDurationMs(attempt.responseStartedAt, attempt.responseFinishedAt) ??
|
||||||
|
elapsedDurationMs(attempt.startedAt, attempt.finishedAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveDurationMs(value?: number) {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
const numericValue = Number(value);
|
||||||
|
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsedDurationMs(start?: string, end?: string) {
|
||||||
|
if (!start || !end) return undefined;
|
||||||
|
const startedAt = new Date(start).getTime();
|
||||||
|
const finishedAt = new Date(end).getTime();
|
||||||
|
if (!Number.isFinite(startedAt) || !Number.isFinite(finishedAt)) return undefined;
|
||||||
|
const elapsed = finishedAt - startedAt;
|
||||||
|
return elapsed > 0 ? Math.max(1, Math.round(elapsed)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(value?: number) {
|
function formatDuration(value?: number) {
|
||||||
if (value === undefined || value === null) return '-';
|
if (value === undefined || value === null) return '-';
|
||||||
const milliseconds = Math.max(0, Math.round(value));
|
const milliseconds = Math.max(0, Math.round(value));
|
||||||
if (milliseconds === 0) return '0秒';
|
if (milliseconds === 0) return '-';
|
||||||
if (milliseconds < 1000) return `${milliseconds}毫秒`;
|
if (milliseconds < 1000) return `${milliseconds}毫秒`;
|
||||||
const totalSeconds = Math.round(milliseconds / 1000);
|
const totalSeconds = Math.round(milliseconds / 1000);
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
|||||||
65
devenv.lock
Normal file
65
devenv.lock
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"devenv": {
|
||||||
|
"locked": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"lastModified": 1778613747,
|
||||||
|
"narHash": "sha256-+FdF9iIvBQIC391Xkoso3IFIl/Iqp2NolSvCOgEIm78=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "c9ee1d61986a6dde1cf45e738b01395cd5bce470",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-src": "nixpkgs-src"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778507786,
|
||||||
|
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-src": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778274207,
|
||||||
|
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
104
devenv.nix
Normal file
104
devenv.nix
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
inputs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
{
|
||||||
|
starship = {
|
||||||
|
enable = true;
|
||||||
|
config = {
|
||||||
|
enable = true;
|
||||||
|
path = ./starship.toml;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
env = {
|
||||||
|
AI_GATEWAY_DATABASE_NAME = "easyai_ai_gateway";
|
||||||
|
AI_GATEWAY_DATABASE_URL = "host=${config.env.DEVENV_RUNTIME}/postgres dbname=easyai_ai_gateway sslmode=disable";
|
||||||
|
AI_GATEWAY_SKIP_DB_CREATE = "1";
|
||||||
|
};
|
||||||
|
|
||||||
|
packages = with pkgs; [
|
||||||
|
curl
|
||||||
|
docker-client
|
||||||
|
git
|
||||||
|
jq
|
||||||
|
lsof
|
||||||
|
postgresql_18
|
||||||
|
ripgrep
|
||||||
|
watchexec
|
||||||
|
];
|
||||||
|
|
||||||
|
scripts = {
|
||||||
|
dev.exec = "pnpm dev";
|
||||||
|
build.exec = "pnpm build";
|
||||||
|
test-all.exec = "pnpm test";
|
||||||
|
lint.exec = "pnpm lint";
|
||||||
|
migrate.exec = "pnpm migrate";
|
||||||
|
db-create.exec = "pnpm db:create";
|
||||||
|
api-test.exec = "pnpm nx run api:test";
|
||||||
|
web-build.exec = "pnpm nx run web:build";
|
||||||
|
};
|
||||||
|
|
||||||
|
services.postgres = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.postgresql_18.withPackages (postgresPackages: [
|
||||||
|
postgresPackages.pgvector
|
||||||
|
]);
|
||||||
|
listen_addresses = "";
|
||||||
|
initialDatabases = [
|
||||||
|
{
|
||||||
|
name = "easyai_ai_gateway";
|
||||||
|
initialSQL = ''
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# https://devenv.sh/languages/
|
||||||
|
languages.go = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.go;
|
||||||
|
};
|
||||||
|
|
||||||
|
languages.javascript = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.nodejs_22;
|
||||||
|
nodejs.enable = true;
|
||||||
|
lsp.enable = true;
|
||||||
|
pnpm = {
|
||||||
|
enable = true;
|
||||||
|
install.enable = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
echo ""
|
||||||
|
echo "EasyAI AI Gateway 开发环境已就绪"
|
||||||
|
echo "当前目录:$PWD"
|
||||||
|
echo ""
|
||||||
|
echo "运行时版本:"
|
||||||
|
echo " go: $(go version | awk '{print $3}')"
|
||||||
|
echo " node: $(node --version)"
|
||||||
|
echo " pnpm: $(pnpm --version)"
|
||||||
|
echo " psql: $(psql --version | awk '{print $3}')"
|
||||||
|
echo ""
|
||||||
|
echo "常用命令:"
|
||||||
|
echo " dev 创建/迁移数据库,并启动 API 和 Web"
|
||||||
|
echo " test-all 运行 API 和 Web 测试目标"
|
||||||
|
echo " build 构建 API 和 Web"
|
||||||
|
echo " lint 运行 Web 与 contracts 类型检查"
|
||||||
|
echo " migrate 执行 API 数据库迁移"
|
||||||
|
echo " db-create 创建 AI Gateway 数据库"
|
||||||
|
echo " api-test 只运行 Go API 测试"
|
||||||
|
echo " web-build 只构建 Web 前端"
|
||||||
|
echo ""
|
||||||
|
echo "提示:根 package.json 是唯一脚本入口;上述短命令由 devenv scripts 转发。"
|
||||||
|
echo ""
|
||||||
|
'';
|
||||||
|
}
|
||||||
18
devenv.yaml
Normal file
18
devenv.yaml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||||
|
inputs:
|
||||||
|
nixpkgs:
|
||||||
|
url: github:cachix/devenv-nixpkgs/rolling
|
||||||
|
|
||||||
|
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||||
|
# allowUnfree: true
|
||||||
|
|
||||||
|
# If you're not willing to allow unsupported packages:
|
||||||
|
# allowUnsupportedSystem: false
|
||||||
|
|
||||||
|
# If you're willing to use a package that's vulnerable
|
||||||
|
# permittedInsecurePackages:
|
||||||
|
# - "openssl-1.1.1w"
|
||||||
|
|
||||||
|
# If you have more than one devenv you can merge them
|
||||||
|
#imports:
|
||||||
|
# - ./backend
|
||||||
@ -68,7 +68,11 @@ export AI_GATEWAY_DATABASE_URL="${AI_GATEWAY_DATABASE_URL:-postgresql://${AI_GAT
|
|||||||
|
|
||||||
echo "[ai-gateway] using database: ${AI_GATEWAY_DATABASE_URL}"
|
echo "[ai-gateway] using database: ${AI_GATEWAY_DATABASE_URL}"
|
||||||
|
|
||||||
scripts/create-database.sh
|
if [[ "${AI_GATEWAY_SKIP_DB_CREATE:-}" == "1" ]]; then
|
||||||
|
echo "[ai-gateway] skipping Docker database creation"
|
||||||
|
else
|
||||||
|
scripts/create-database.sh
|
||||||
|
fi
|
||||||
pnpm nx run api:migrate
|
pnpm nx run api:migrate
|
||||||
stop_stale_api_processes
|
stop_stale_api_processes
|
||||||
exec pnpm nx run-many -t dev -p api web --parallel=2
|
exec pnpm nx run-many -t dev -p api web --parallel=2
|
||||||
|
|||||||
101
starship.toml
Normal file
101
starship.toml
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# --- 全局结构 (极致紧凑,高信息密度,AI友好) ---
|
||||||
|
format = """
|
||||||
|
$directory\
|
||||||
|
$git_branch\
|
||||||
|
$git_status\
|
||||||
|
$nix_shell\
|
||||||
|
$nodejs\
|
||||||
|
$bun\
|
||||||
|
$rust\
|
||||||
|
$golang\
|
||||||
|
$cmd_duration\
|
||||||
|
$memory_usage\
|
||||||
|
$status\
|
||||||
|
$line_break\
|
||||||
|
$character"""
|
||||||
|
|
||||||
|
# --- 目录 ---
|
||||||
|
[directory]
|
||||||
|
style = "bold cyan"
|
||||||
|
truncation_length = 3
|
||||||
|
truncate_to_repo = false
|
||||||
|
truncation_symbol = ".../"
|
||||||
|
home_symbol = "~"
|
||||||
|
read_only = " [RO]"
|
||||||
|
|
||||||
|
# --- Git 状态 (纯文本,避免解析错误与乱码) ---
|
||||||
|
[git_branch]
|
||||||
|
symbol = "git:"
|
||||||
|
style = "bold purple"
|
||||||
|
format = "[$symbol$branch]($style) "
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
format = "[$all_status$ahead_behind]($style) "
|
||||||
|
style = "bold red"
|
||||||
|
conflicted = "="
|
||||||
|
ahead = ">"
|
||||||
|
behind = "<"
|
||||||
|
diverged = "<>"
|
||||||
|
untracked = "?"
|
||||||
|
stashed = "*"
|
||||||
|
modified = "!"
|
||||||
|
staged = "+"
|
||||||
|
renamed = "»"
|
||||||
|
deleted = "x"
|
||||||
|
|
||||||
|
# --- 编程语言与环境 (紧凑标签格式) ---
|
||||||
|
[nodejs]
|
||||||
|
symbol = "node:"
|
||||||
|
style = "bold green"
|
||||||
|
format = "[$symbol$version]($style) "
|
||||||
|
detect_files = ["package.json", ".node-version"]
|
||||||
|
|
||||||
|
[bun]
|
||||||
|
symbol = "bun:"
|
||||||
|
style = "bold blue"
|
||||||
|
format = "[$symbol$version]($style) "
|
||||||
|
|
||||||
|
[rust]
|
||||||
|
symbol = "rust:"
|
||||||
|
style = "bold 208"
|
||||||
|
format = "[$symbol$version]($style) "
|
||||||
|
|
||||||
|
[golang]
|
||||||
|
symbol = "go:"
|
||||||
|
style = "bold cyan"
|
||||||
|
format = "[$symbol$version]($style) "
|
||||||
|
|
||||||
|
[nix_shell]
|
||||||
|
symbol = "nix:"
|
||||||
|
style = "bold blue"
|
||||||
|
format = "[$symbol$state]($style) "
|
||||||
|
impure_msg = "impure"
|
||||||
|
pure_msg = "pure"
|
||||||
|
|
||||||
|
# --- AI 辅助决策信息 (性能与状态反馈) ---
|
||||||
|
[cmd_duration]
|
||||||
|
min_time = 2_000
|
||||||
|
format = "took [$duration]($style) "
|
||||||
|
style = "bold yellow"
|
||||||
|
|
||||||
|
[memory_usage]
|
||||||
|
symbol = "mem:"
|
||||||
|
disabled = false
|
||||||
|
threshold = 75
|
||||||
|
format = "[$symbol$ram_pct]($style) "
|
||||||
|
style = "bold dimmed white"
|
||||||
|
|
||||||
|
[status]
|
||||||
|
disabled = false
|
||||||
|
format = "[ERR:$status]($style) "
|
||||||
|
style = "bold red"
|
||||||
|
|
||||||
|
# --- 交互符号 (带明确状态码) ---
|
||||||
|
[character]
|
||||||
|
success_symbol = "[>](bold green)"
|
||||||
|
error_symbol = "[>](bold red)"
|
||||||
|
vicmd_symbol = "[<](bold green)"
|
||||||
|
|
||||||
|
# --- 兼容性补充 ---
|
||||||
|
[package]
|
||||||
|
disabled = true
|
||||||
Loading…
Reference in New Issue
Block a user