Compare commits

..

No commits in common. "main" and "feature/openapi-docs" have entirely different histories.

39 changed files with 299 additions and 6319 deletions

10
.gitignore vendored
View File

@ -15,13 +15,3 @@ coverage/
.idea .idea
.gitignore .gitignore
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View File

@ -2357,355 +2357,6 @@
} }
} }
}, },
"/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": [
@ -4000,27 +3651,26 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "/api/v1/chat/completions 同步执行stream=true 返回 text/event-stream SSEstream=false 或未传返回兼容 JSON该接口忽略 X-Async。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
"produces": [ "produces": [
"application/json", "application/json"
"text/event-stream"
], ],
"tags": [ "tags": [
"tasks" "tasks"
], ],
"summary": "创建 Chat Completions", "summary": "创建或执行 AI 任务",
"parameters": [ "parameters": [
{ {
"type": "boolean", "type": "boolean",
"description": "该接口忽略此参数", "description": "true 时异步创建任务并返回 202",
"name": "X-Async", "name": "X-Async",
"in": "header" "in": "header"
}, },
{ {
"description": "Chat Completions 请求", "description": "AI 任务请求,字段随任务类型变化",
"name": "input", "name": "input",
"in": "body", "in": "body",
"required": true, "required": true,
@ -4033,7 +3683,13 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse" "$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
} }
}, },
"400": { "400": {
@ -4081,74 +3737,6 @@
} }
} }
}, },
"/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": [
@ -4156,7 +3744,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -4249,7 +3837,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -4648,7 +4236,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -4980,7 +4568,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -5447,7 +5035,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -5560,7 +5148,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -5653,7 +5241,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -5772,7 +5360,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -5858,41 +5446,6 @@
} }
} }
}, },
"/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": "返回本地模拟模式使用的图片、视频封面或短视频资源。",
@ -5929,41 +5482,6 @@
} }
} }
}, },
"/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": [
@ -5971,7 +5489,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6057,74 +5575,6 @@
} }
} }
}, },
"/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": [
@ -6132,7 +5582,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6225,7 +5675,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6318,7 +5768,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", "description": "网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6546,82 +5996,6 @@
} }
} }
}, },
"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": {
@ -6688,46 +6062,6 @@
} }
} }
}, },
"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": {
@ -7281,11 +6615,6 @@
"type": "string", "type": "string",
"example": "A watercolor robot reading a book" "example": "A watercolor robot reading a book"
}, },
"reasoning_effort": {
"description": "ReasoningEffort 推理深度OpenAI-compatible 请求字段;开放字符串,取值随 provider 和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh也可配置 max 等供应商自定义值。",
"type": "string",
"example": "medium"
},
"resolution": { "resolution": {
"type": "string", "type": "string",
"example": "720p" "example": "720p"
@ -8078,121 +7407,6 @@
} }
} }
}, },
"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": {

View File

@ -92,59 +92,6 @@ 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:
@ -191,34 +138,6 @@ 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:
@ -587,11 +506,6 @@ definitions:
prompt: prompt:
example: A watercolor robot reading a book example: A watercolor robot reading a book
type: string type: string
reasoning_effort:
description: ReasoningEffort 推理深度OpenAI-compatible 请求字段;开放字符串,取值随 provider
和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh也可配置 max 等供应商自定义值。
example: medium
type: string
resolution: resolution:
example: 720p example: 720p
type: string type: string
@ -1131,83 +1045,6 @@ 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:
@ -3807,229 +3644,6 @@ 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: 管理端返回网关租户列表。
@ -4858,14 +4472,14 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: /api/v1/chat/completions 同步执行stream=true 返回 text/event-stream description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
SSEstream=false 或未传返回兼容 JSON该接口忽略 X-Async SSE
parameters: parameters:
- description: 该接口忽略此参数 - description: true 时异步创建任务并返回 202
in: header in: header
name: X-Async name: X-Async
type: boolean type: boolean
- description: Chat Completions 请求 - description: AI 任务请求,字段随任务类型变化
in: body in: body
name: input name: input
required: true required: true
@ -4873,12 +4487,15 @@ 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.ChatCompletionCompatibleResponse' $ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
"400": "400":
description: Bad Request description: Bad Request
schema: schema:
@ -4909,59 +4526,15 @@ paths:
$ref: '#/definitions/httpapi.ErrorEnvelope' $ref: '#/definitions/httpapi.ErrorEnvelope'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 创建 Chat Completions summary: 创建或执行 AI 任务
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/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -5021,8 +4594,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -5275,8 +4848,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -5489,8 +5062,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -5790,8 +5363,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -5864,8 +5437,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -5925,8 +5498,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -6003,8 +5576,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -6060,29 +5633,6 @@ 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: 返回本地模拟模式使用的图片、视频封面或短视频资源。
@ -6107,35 +5657,12 @@ 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/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -6191,56 +5718,12 @@ 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/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -6300,8 +5783,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header
@ -6361,8 +5844,8 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 网关任务接口按 model 选择平台模型;/api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible description: 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或
路径同步返回兼容响应或 SSE 流。 SSE 流。
parameters: parameters:
- description: true 时异步创建任务并返回 202 - description: true 时异步创建任务并返回 202
in: header in: header

View File

@ -110,7 +110,6 @@ 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",
@ -146,190 +145,6 @@ 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 TestOpenAIClientChatRequestNormalizesToolContext(t *testing.T) {
var captured map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatalf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-normalized-request",
"object": "chat.completion",
"model": captured["model"],
"choices": []any{map[string]any{
"message": map[string]any{"role": "assistant", "content": "ok"},
}},
})
}))
defer server.Close()
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions",
Model: "openai:gpt-4o-mini",
Body: map[string]any{
"model": "openai:gpt-4o-mini",
"messages": []any{
map[string]any{
"role": "assistant",
"functionCall": map[string]any{
"name": "lookup",
"arguments": map[string]any{"q": "weather"},
},
},
map[string]any{"role": "tool", "toolCallId": "call_0", "content": "sunny"},
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "keep this"},
map[string]any{"type": "tool_result", "tool_use_id": "toolu_1", "content": map[string]any{"ok": true}},
},
},
},
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
ProviderModelName: "openai-compatible-gpt-4o-mini",
Credentials: map[string]any{"apiKey": "test-key"},
},
})
if err != nil {
t.Fatalf("run openai client: %v", err)
}
messages, _ := captured["messages"].([]any)
if len(messages) != 4 {
t.Fatalf("unexpected normalized messages: %+v", messages)
}
assistant, _ := messages[0].(map[string]any)
if _, ok := assistant["functionCall"]; ok {
t.Fatalf("functionCall should be converted away: %+v", assistant)
}
toolCalls, _ := assistant["tool_calls"].([]any)
toolCall, _ := toolCalls[0].(map[string]any)
function, _ := toolCall["function"].(map[string]any)
if function["name"] != "lookup" || function["arguments"] != `{"q":"weather"}` {
t.Fatalf("unexpected normalized tool call: %+v", assistant)
}
toolMessage, _ := messages[1].(map[string]any)
if toolMessage["tool_call_id"] != "call_0" || toolMessage["toolCallId"] != nil {
t.Fatalf("tool message was not normalized: %+v", toolMessage)
}
keptUser, _ := messages[2].(map[string]any)
convertedToolResult, _ := messages[3].(map[string]any)
if keptUser["content"] != "keep this" || convertedToolResult["role"] != "tool" || convertedToolResult["tool_call_id"] != "toolu_1" || convertedToolResult["content"] != `{"ok":true}` {
t.Fatalf("tool_result block was not restored: user=%+v tool=%+v", keptUser, convertedToolResult)
}
}
func TestOpenAIClientChatResponseNormalizesReasoning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-reasoning",
"object": "chat.completion",
"model": "openrouter-test",
"choices": []any{map[string]any{
"message": map[string]any{
"role": "assistant",
"reasoning_details": []any{
map[string]any{"type": "reasoning.text", "text": "detail-"},
map[string]any{"type": "reasoning.summary", "summary": "summary"},
map[string]any{"type": "reasoning.encrypted", "data": "secret"},
},
"content": "<think>tagged</think>answer",
},
}},
})
}))
defer server.Close()
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions",
Model: "OpenRouter-Test",
Body: map[string]any{"model": "OpenRouter-Test", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
ModelName: "openrouter-test",
Credentials: map[string]any{"apiKey": "test-key"},
},
})
if err != nil {
t.Fatalf("run openai client: %v", err)
}
choices, _ := response.Result["choices"].([]any)
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
if message["reasoning_content"] != "detail-summarytagged" || message["content"] != "answer" {
t.Fatalf("reasoning was not normalized: %+v", response.Result)
}
if _, ok := message["reasoning_details"]; ok {
t.Fatalf("reasoning_details should be converted away: %+v", message)
}
}
func TestOpenAIClientChatResponseNormalizesToolCallFormats(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-tools",
"object": "chat.completion",
"model": "tool-format-test",
"choices": []any{map[string]any{
"message": map[string]any{
"role": "assistant",
"content": []any{
map[string]any{"type": "text", "text": "calling tools"},
map[string]any{"type": "tool_use", "id": "toolu_1", "name": "anthropic_lookup", "input": map[string]any{"city": "Boston"}},
},
"toolCalls": []any{map[string]any{"id": "call_camel", "functionCall": map[string]any{"name": "camel_lookup", "args": map[string]any{"city": "SF"}}}},
"function_call": map[string]any{"name": "legacy_lookup", "arguments": "{\"city\":\"NYC\"}"},
},
}},
})
}))
defer server.Close()
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions",
Model: "Tool-Format-Test",
Body: map[string]any{"model": "Tool-Format-Test", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
ModelName: "tool-format-test",
Credentials: map[string]any{"apiKey": "test-key"},
},
})
if err != nil {
t.Fatalf("run openai client: %v", err)
}
choices, _ := response.Result["choices"].([]any)
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
if message["content"] != "calling tools" {
t.Fatalf("tool_use block should be removed from content: %+v", message)
}
for _, key := range []string{"toolCalls", "function_call"} {
if _, ok := message[key]; ok {
t.Fatalf("%s should be converted away: %+v", key, message)
}
}
toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 3 {
t.Fatalf("expected 3 normalized tool calls, got %+v", message)
}
assertToolCall := func(index int, id string, name string, arguments string) {
t.Helper()
toolCall, _ := toolCalls[index].(map[string]any)
function, _ := toolCall["function"].(map[string]any)
if toolCall["id"] != id || toolCall["type"] != "function" || function["name"] != name || function["arguments"] != arguments {
t.Fatalf("unexpected tool call[%d]: %+v", index, toolCall)
}
}
assertToolCall(0, "call_camel", "camel_lookup", "{\"city\":\"SF\"}")
assertToolCall(1, "call_1", "legacy_lookup", "{\"city\":\"NYC\"}")
assertToolCall(2, "toolu_1", "anthropic_lookup", "{\"city\":\"Boston\"}")
} }
func TestOpenAIClientChatStreamContract(t *testing.T) { func TestOpenAIClientChatStreamContract(t *testing.T) {
@ -385,133 +200,6 @@ func TestOpenAIClientChatStreamContract(t *testing.T) {
} }
} }
func TestOpenAIClientChatStreamPreservesStructuredDeltas(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"openrouter-reasoner\",\"choices\":[{\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"detail-\"},{\"type\":\"reasoning.summary\",\"summary\":\"summary\"},{\"type\":\"reasoning.encrypted\",\"data\":\"secret\"}]}}],\"usage\":null}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"openrouter-reasoner\",\"choices\":[{\"delta\":{\"content\":\"<think>tagged</think>answer\"}}],\"usage\":null}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\":\"}}]}}],\"usage\":null}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"weather\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer server.Close()
captured := make([]StreamDeltaEvent, 0)
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions",
Model: "DeepSeek-V4",
Body: map[string]any{
"model": "DeepSeek-V4",
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
"stream": true,
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
ModelName: "deepseek-v4",
Credentials: map[string]any{"apiKey": "test-key"},
},
StreamDelta: func(event StreamDeltaEvent) error {
captured = append(captured, event)
return nil
},
})
if err != nil {
t.Fatalf("run openai structured stream client: %v", err)
}
if len(captured) != 4 || captured[0].ReasoningContent != "detail-summary" || captured[1].ReasoningContent != "tagged" || captured[1].Text != "answer" || captured[2].Event == nil {
t.Fatalf("structured stream events were not preserved: %+v", captured)
}
firstChoices, _ := captured[0].Event["choices"].([]any)
firstChoice, _ := firstChoices[0].(map[string]any)
firstDelta, _ := firstChoice["delta"].(map[string]any)
if firstDelta["reasoning_content"] != "detail-summary" {
t.Fatalf("reasoning_details were not converted in stream event: %+v", captured[0].Event)
}
if _, ok := firstDelta["reasoning_details"]; ok {
t.Fatalf("reasoning_details should be removed from stream event: %+v", captured[0].Event)
}
choices, _ := response.Result["choices"].([]any)
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
if message["reasoning_content"] != "detail-summarytagged" || message["content"] != "answer" || choice["finish_reason"] != "tool_calls" {
t.Fatalf("reasoning or finish reason missing from aggregated result: %+v", response.Result)
}
toolCalls, _ := message["tool_calls"].([]any)
toolCall, _ := toolCalls[0].(map[string]any)
function, _ := toolCall["function"].(map[string]any)
if function["arguments"] != "{\"q\":\"weather\"}" {
t.Fatalf("tool call arguments were not aggregated: %+v", response.Result)
}
}
func TestOpenAIClientChatStreamNormalizesToolCallFormats(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"function_call\":{\"name\":\"legacy_lookup\",\"arguments\":\"{\\\"city\\\":\"}}}],\"usage\":null}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"functionCall\":{\"arguments\":\"\\\"Boston\\\"}\"}}}],\"usage\":null}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"toolCall\":{\"index\":1,\"id\":\"call_camel\",\"functionCall\":{\"name\":\"camel_lookup\",\"args\":{\"city\":\"SF\"}}}},\"finish_reason\":\"tool_calls\"}],\"usage\":null}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer server.Close()
captured := make([]StreamDeltaEvent, 0)
response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions",
Model: "Tool-Format-Test",
Body: map[string]any{
"model": "Tool-Format-Test",
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
"stream": true,
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
ModelName: "tool-format-test",
Credentials: map[string]any{"apiKey": "test-key"},
},
StreamDelta: func(event StreamDeltaEvent) error {
captured = append(captured, event)
return nil
},
})
if err != nil {
t.Fatalf("run openai stream client: %v", err)
}
if len(captured) != 3 {
t.Fatalf("unexpected captured events: %+v", captured)
}
for _, event := range captured {
choices, _ := event.Event["choices"].([]any)
choice, _ := choices[0].(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if _, ok := delta["function_call"]; ok {
t.Fatalf("function_call should be converted away: %+v", event.Event)
}
if _, ok := delta["functionCall"]; ok {
t.Fatalf("functionCall should be converted away: %+v", event.Event)
}
if _, ok := delta["toolCall"]; ok {
t.Fatalf("toolCall should be converted away: %+v", event.Event)
}
}
choices, _ := response.Result["choices"].([]any)
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 2 || choice["finish_reason"] != "tool_calls" {
t.Fatalf("unexpected normalized stream result: %+v", response.Result)
}
legacyCall, _ := toolCalls[0].(map[string]any)
legacyFunction, _ := legacyCall["function"].(map[string]any)
if legacyFunction["name"] != "legacy_lookup" || legacyFunction["arguments"] != "{\"city\":\"Boston\"}" {
t.Fatalf("legacy function_call was not aggregated: %+v", response.Result)
}
camelCall, _ := toolCalls[1].(map[string]any)
camelFunction, _ := camelCall["function"].(map[string]any)
if camelCall["id"] != "call_camel" || camelFunction["name"] != "camel_lookup" || camelFunction["arguments"] != "{\"city\":\"SF\"}" {
t.Fatalf("camel toolCall was not normalized: %+v", response.Result)
}
}
func TestGeminiClientChatContract(t *testing.T) { func TestGeminiClientChatContract(t *testing.T) {
var gotPath string var gotPath string
var gotKey string var gotKey string
@ -569,149 +257,6 @@ func TestGeminiClientChatContract(t *testing.T) {
} }
} }
func TestGeminiClientChatRestoresToolContext(t *testing.T) {
var captured map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatalf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []any{map[string]any{
"content": map[string]any{"parts": []any{map[string]any{"text": "gemini ok"}}},
}},
})
}))
defer server.Close()
_, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions",
Model: "gemini:gemini-2.5-flash",
Body: map[string]any{
"model": "gemini:gemini-2.5-flash",
"messages": []any{
map[string]any{"role": "user", "content": "weather?"},
map[string]any{
"role": "assistant",
"content": "checking",
"tool_calls": []any{map[string]any{
"id": "call_weather",
"type": "function",
"function": map[string]any{
"name": "get_weather",
"arguments": `{"city":"SF"}`,
},
}},
},
map[string]any{"role": "tool", "tool_call_id": "call_weather", "content": `{"temperature":"72F"}`},
},
"tools": []any{map[string]any{
"type": "function",
"function": map[string]any{
"name": "get_weather",
"description": "lookup weather",
"parameters": map[string]any{"type": "object"},
},
}},
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
ProviderModelName: "gemini-2.5-flash",
ModelType: "chat",
Credentials: map[string]any{"apiKey": "gemini-key"},
},
})
if err != nil {
t.Fatalf("run gemini client: %v", err)
}
contents, _ := captured["contents"].([]any)
if len(contents) != 3 {
t.Fatalf("unexpected Gemini contents: %+v", captured)
}
modelTurn, _ := contents[1].(map[string]any)
if modelTurn["role"] != "model" {
t.Fatalf("assistant turn should become Gemini model turn: %+v", modelTurn)
}
modelParts, _ := modelTurn["parts"].([]any)
callPart, _ := modelParts[1].(map[string]any)
functionCall, _ := callPart["functionCall"].(map[string]any)
args, _ := functionCall["args"].(map[string]any)
if functionCall["name"] != "get_weather" || args["city"] != "SF" {
t.Fatalf("tool call was not restored for Gemini: %+v", modelTurn)
}
toolTurn, _ := contents[2].(map[string]any)
toolParts, _ := toolTurn["parts"].([]any)
responsePart, _ := toolParts[0].(map[string]any)
functionResponse, _ := responsePart["functionResponse"].(map[string]any)
response, _ := functionResponse["response"].(map[string]any)
if toolTurn["role"] != "user" || functionResponse["name"] != "get_weather" || response["temperature"] != "72F" {
t.Fatalf("tool result was not restored for Gemini: %+v", toolTurn)
}
tools, _ := captured["tools"].([]any)
declarationGroup, _ := tools[0].(map[string]any)
declarations, _ := declarationGroup["functionDeclarations"].([]any)
declaration, _ := declarations[0].(map[string]any)
if declaration["name"] != "get_weather" || declaration["description"] != "lookup weather" {
t.Fatalf("tool declaration was not converted for Gemini: %+v", captured["tools"])
}
}
func TestGeminiClientChatConvertsFunctionCallResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"candidates": []any{map[string]any{
"finishReason": "STOP",
"content": map[string]any{"parts": []any{
map[string]any{"functionCall": map[string]any{
"name": "get_weather",
"args": map[string]any{"city": "SF"},
}},
}},
}},
})
}))
defer server.Close()
response, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "chat.completions",
Model: "gemini:gemini-2.5-flash",
Body: map[string]any{
"model": "gemini:gemini-2.5-flash",
"messages": []any{map[string]any{"role": "user", "content": "weather?"}},
"tools": []any{map[string]any{
"type": "function",
"function": map[string]any{"name": "get_weather"},
}},
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
ProviderModelName: "gemini-2.5-flash",
ModelType: "chat",
Credentials: map[string]any{"apiKey": "gemini-key"},
},
})
if err != nil {
t.Fatalf("run gemini client: %v", err)
}
choices, _ := response.Result["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("Gemini function call should use tool_calls finish reason: %+v", response.Result)
}
message, _ := choice["message"].(map[string]any)
if message["content"] != nil {
t.Fatalf("tool-only Gemini response should keep nullable content: %+v", message)
}
toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("Gemini function call was not converted: %+v", message)
}
toolCall, _ := toolCalls[0].(map[string]any)
function, _ := toolCall["function"].(map[string]any)
if toolCall["type"] != "function" || toolCall["id"] != "call_0" || function["name"] != "get_weather" || function["arguments"] != `{"city":"SF"}` {
t.Fatalf("unexpected Gemini tool call: %+v", toolCall)
}
}
func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) { func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) {
got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key") got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key")
want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key" want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key"
@ -1117,266 +662,6 @@ 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)

View File

@ -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()
@ -70,7 +70,9 @@ func geminiBody(request Request) map[string]any {
return map[string]any{"contents": contents} return map[string]any{"contents": contents}
} }
prompt := firstNonEmptyPrompt(request.Body, "") prompt := firstNonEmptyPrompt(request.Body, "")
if prompt != "" { if prompt == "" {
prompt = textFromMessages(request.Body)
}
return map[string]any{ return map[string]any{
"contents": []any{map[string]any{ "contents": []any{map[string]any{
"role": "user", "role": "user",
@ -78,178 +80,6 @@ func geminiBody(request Request) map[string]any {
}}, }},
} }
} }
body := map[string]any{"contents": geminiContentsFromMessages(request.Body)}
if tools := geminiToolsFromOpenAITools(request.Body["tools"]); len(tools) > 0 {
body["tools"] = tools
}
contents, _ := body["contents"].([]any)
if len(contents) > 0 {
return body
}
return map[string]any{"contents": []any{map[string]any{
"role": "user",
"parts": []any{map[string]any{"text": textFromMessages(request.Body)}},
}}}
}
func geminiContentsFromMessages(body map[string]any) []any {
normalized := NormalizeChatCompletionRequestBody(body)
messages, _ := normalized["messages"].([]any)
contents := make([]any, 0, len(messages))
toolNames := map[string]string{}
for _, rawMessage := range messages {
message, _ := rawMessage.(map[string]any)
if len(message) == 0 {
continue
}
role := stringFromAny(message["role"])
if role == "tool" {
toolCallID := stringFromAny(message["tool_call_id"])
name := toolNames[toolCallID]
if name == "" {
name = toolCallID
}
if name == "" {
name = "tool"
}
contents = append(contents, map[string]any{
"role": "user",
"parts": []any{map[string]any{"functionResponse": map[string]any{
"name": name,
"response": geminiFunctionResponsePayload(message["content"]),
}}},
})
continue
}
parts := geminiTextParts(message["content"])
if role == "assistant" {
for _, rawToolCall := range toolCallsSlice(message["tool_calls"]) {
toolCall, _ := rawToolCall.(map[string]any)
function, _ := toolCall["function"].(map[string]any)
name := stringFromAny(function["name"])
if name == "" {
continue
}
if id := stringFromAny(toolCall["id"]); id != "" {
toolNames[id] = name
}
parts = append(parts, map[string]any{"functionCall": map[string]any{
"name": name,
"args": geminiFunctionArgs(function["arguments"]),
}})
}
}
if len(parts) == 0 {
continue
}
contents = append(contents, map[string]any{
"role": geminiRole(role),
"parts": parts,
})
}
return contents
}
func geminiRole(role string) string {
if role == "assistant" {
return "model"
}
return "user"
}
func geminiTextParts(content any) []any {
parts := make([]any, 0)
switch typed := content.(type) {
case string:
if strings.TrimSpace(typed) != "" {
parts = append(parts, map[string]any{"text": typed})
}
case []any:
for _, rawPart := range typed {
part, _ := rawPart.(map[string]any)
if text := stringFromAny(firstPresent(part["text"], part["content"])); strings.TrimSpace(text) != "" {
parts = append(parts, map[string]any{"text": text})
}
}
}
return parts
}
func toolCallsSlice(value any) []any {
switch typed := value.(type) {
case []any:
return typed
case map[string]any:
return []any{typed}
default:
return nil
}
}
func geminiFunctionArgs(value any) map[string]any {
if value == nil {
return map[string]any{}
}
if args, ok := value.(map[string]any); ok {
return args
}
if text, ok := value.(string); ok {
if strings.TrimSpace(text) == "" {
return map[string]any{}
}
var args map[string]any
if err := json.Unmarshal([]byte(text), &args); err == nil {
return args
}
return map[string]any{"arguments": text}
}
return map[string]any{"arguments": value}
}
func geminiFunctionResponsePayload(value any) map[string]any {
if payload, ok := value.(map[string]any); ok {
return payload
}
if text, ok := value.(string); ok {
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err == nil {
return payload
}
return map[string]any{"content": text}
}
if value == nil {
return map[string]any{}
}
return map[string]any{"content": value}
}
func geminiToolsFromOpenAITools(value any) []any {
tools, ok := value.([]any)
if !ok || len(tools) == 0 {
return nil
}
declarations := make([]any, 0, len(tools))
for _, rawTool := range tools {
tool, _ := rawTool.(map[string]any)
function, _ := tool["function"].(map[string]any)
name := stringFromAny(function["name"])
if name == "" {
continue
}
declaration := map[string]any{"name": name}
if description := stringFromAny(function["description"]); description != "" {
declaration["description"] = description
}
if parameters, ok := function["parameters"]; ok {
declaration["parameters"] = parameters
}
declarations = append(declarations, declaration)
}
if len(declarations) == 0 {
return nil
}
return []any{map[string]any{"functionDeclarations": declarations}}
}
func geminiResult(request Request, raw map[string]any) map[string]any { func geminiResult(request Request, raw map[string]any) map[string]any {
if request.ModelType == "image" { if request.ModelType == "image" {
@ -265,7 +95,7 @@ func geminiResult(request Request, raw map[string]any) map[string]any {
"raw": raw, "raw": raw,
} }
} }
message, finishReason := geminiChatMessage(raw) content := geminiText(raw)
return map[string]any{ return map[string]any{
"id": "gemini-chat", "id": "gemini-chat",
"object": "chat.completion", "object": "chat.completion",
@ -273,8 +103,8 @@ func geminiResult(request Request, raw map[string]any) map[string]any {
"model": request.Model, "model": request.Model,
"choices": []any{map[string]any{ "choices": []any{map[string]any{
"index": 0, "index": 0,
"finish_reason": finishReason, "finish_reason": "stop",
"message": message, "message": map[string]any{"role": "assistant", "content": content},
}}, }},
"usage": geminiUsageMap(raw), "usage": geminiUsageMap(raw),
"raw": raw, "raw": raw,
@ -303,59 +133,19 @@ func textFromMessages(body map[string]any) string {
} }
func geminiText(raw map[string]any) string { func geminiText(raw map[string]any) string {
message, _ := geminiChatMessage(raw)
content, _ := message["content"].(string)
return content
}
func geminiChatMessage(raw map[string]any) (map[string]any, string) {
candidates, _ := raw["candidates"].([]any) candidates, _ := raw["candidates"].([]any)
for _, candidate := range candidates { for _, candidate := range candidates {
candidateMap, _ := candidate.(map[string]any) candidateMap, _ := candidate.(map[string]any)
content, _ := candidateMap["content"].(map[string]any) content, _ := candidateMap["content"].(map[string]any)
parts, _ := content["parts"].([]any) parts, _ := content["parts"].([]any)
textParts := make([]string, 0, len(parts))
toolCalls := make([]any, 0)
for _, part := range parts { for _, part := range parts {
partMap, _ := part.(map[string]any) partMap, _ := part.(map[string]any)
if text, ok := partMap["text"].(string); ok && text != "" { if text, ok := partMap["text"].(string); ok && text != "" {
textParts = append(textParts, text) return text
}
functionCall := mapFromAny(firstPresent(partMap["functionCall"], partMap["function_call"]))
if len(functionCall) == 0 {
continue
}
if toolCall := normalizeGeminiFunctionCall(functionCall, len(toolCalls), false); toolCall != nil {
toolCalls = append(toolCalls, toolCall)
} }
} }
message := map[string]any{
"role": "assistant",
"content": strings.Join(textParts, ""),
}
if len(toolCalls) > 0 {
message["tool_calls"] = toolCalls
if len(textParts) == 0 {
message["content"] = nil
}
}
return message, geminiFinishReason(candidateMap, len(toolCalls) > 0)
}
return map[string]any{"role": "assistant", "content": ""}, "stop"
}
func geminiFinishReason(candidate map[string]any, hasToolCalls bool) string {
if hasToolCalls {
return "tool_calls"
}
switch strings.ToUpper(stringFromAny(candidate["finishReason"])) {
case "MAX_TOKENS":
return "length"
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII":
return "content_filter"
default:
return "stop"
} }
return ""
} }
func geminiImageData(raw map[string]any) []any { func geminiImageData(raw map[string]any) []any {

View File

@ -8,7 +8,6 @@ import (
"io" "io"
"math" "math"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
) )
@ -88,11 +87,8 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
rawLines := make([]string, 0) rawLines := make([]string, 0)
parts := make([]string, 0) parts := make([]string, 0)
reasoningParts := make([]string, 0)
var last map[string]any var last map[string]any
var usage Usage var usage Usage
finishReason := ""
toolCalls := map[int]map[string]any{}
for scanner.Scan() { for scanner.Scan() {
rawLine := scanner.Text() rawLine := scanner.Text()
rawLines = append(rawLines, rawLine) rawLines = append(rawLines, rawLine)
@ -108,25 +104,15 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
if err := json.Unmarshal([]byte(payload), &event); err != nil { if err := json.Unmarshal([]byte(payload), &event); err != nil {
continue continue
} }
event = NormalizeChatCompletionStreamEvent(event)
last = event last = event
text := streamEventText(event) if text := streamEventText(event); text != "" {
reasoningText := streamEventReasoningContent(event)
if text != "" {
parts = append(parts, text) parts = append(parts, text)
}
if reasoningText != "" {
reasoningParts = append(reasoningParts, reasoningText)
}
aggregateStreamToolCalls(event, toolCalls)
if reason := streamEventFinishReason(event); reason != "" {
finishReason = reason
}
if onDelta != nil { if onDelta != nil {
if err := onDelta(StreamDeltaEvent{Text: text, ReasoningContent: reasoningText, Event: event}); err != nil { if err := onDelta(text); err != nil {
return nil, true, err return nil, true, err
} }
} }
}
if eventUsage := usageFromOpenAI(event); eventUsage.TotalTokens > 0 { if eventUsage := usageFromOpenAI(event); eventUsage.TotalTokens > 0 {
usage = eventUsage usage = eventUsage
} }
@ -145,7 +131,7 @@ func decodeOpenAIStreamReader(reader io.Reader, onDelta StreamDelta) (map[string
} }
return out, true, nil return out, true, nil
} }
return buildOpenAIStreamResult(last, parts, reasoningParts, toolCalls, finishReason, usage), true, nil return buildOpenAIStreamResult(last, parts, usage), true, nil
} }
func decodeOpenAIStream(raw []byte) (map[string]any, bool) { func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
@ -156,23 +142,10 @@ func decodeOpenAIStream(raw []byte) (map[string]any, bool) {
return result, ok && err == nil return result, ok && err == nil
} }
func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts []string, toolCalls map[int]map[string]any, finishReason string, usage Usage) map[string]any { func buildOpenAIStreamResult(last map[string]any, parts []string, usage Usage) map[string]any {
if len(parts) == 0 && len(reasoningParts) == 0 && len(toolCalls) == 0 { if len(parts) == 0 {
return last return last
} }
message := map[string]any{
"role": "assistant",
"content": strings.Join(parts, ""),
}
if len(reasoningParts) > 0 {
message["reasoning_content"] = strings.Join(reasoningParts, "")
}
if len(toolCalls) > 0 {
message["tool_calls"] = sortedStreamToolCalls(toolCalls)
}
if finishReason == "" {
finishReason = "stop"
}
var out map[string]any var out map[string]any
out = map[string]any{ out = map[string]any{
"id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")), "id": stringFromAny(firstPresent(last["id"], "chatcmpl-stream")),
@ -180,8 +153,11 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
"model": stringFromAny(last["model"]), "model": stringFromAny(last["model"]),
"choices": []any{map[string]any{ "choices": []any{map[string]any{
"index": 0, "index": 0,
"message": message, "message": map[string]any{
"finish_reason": finishReason, "role": "assistant",
"content": strings.Join(parts, ""),
},
"finish_reason": "stop",
}}, }},
} }
if usage.TotalTokens > 0 { if usage.TotalTokens > 0 {
@ -194,571 +170,6 @@ func buildOpenAIStreamResult(last map[string]any, parts []string, reasoningParts
return out return out
} }
// NormalizeChatCompletionRequestBody 将后续请求里的工具调用上下文还原为
// OpenAI Chat Completions 标准格式,便于再次发送给 OpenAI-compatible 上游。
func NormalizeChatCompletionRequestBody(body map[string]any) map[string]any {
if body == nil {
return nil
}
out := cloneBody(body)
messages, ok := out["messages"].([]any)
if !ok {
return out
}
normalizedMessages := make([]any, 0, len(messages))
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
normalizedMessages = append(normalizedMessages, rawMessage)
continue
}
copied := cloneMapAny(message)
normalizeToolCallsContainer(copied, false)
normalizeToolMessageFields(copied)
toolMessages, cleanContent, changed := toolResultMessagesFromContent(copied["content"])
if changed {
if cleanContent != nil && contentHasText(cleanContent) {
copied["content"] = cleanContent
normalizedMessages = append(normalizedMessages, copied)
} else if len(copied) > 1 || copied["role"] != nil {
delete(copied, "content")
if len(copied) > 1 {
normalizedMessages = append(normalizedMessages, copied)
}
}
normalizedMessages = append(normalizedMessages, toolMessages...)
continue
}
normalizedMessages = append(normalizedMessages, copied)
}
out["messages"] = normalizedMessages
return out
}
func cloneMapAny(source map[string]any) map[string]any {
if source == nil {
return nil
}
out := make(map[string]any, len(source))
for key, value := range source {
out[key] = value
}
return out
}
// NormalizeChatCompletionResult 将供应商自定义推理字段归一化到
// message.reasoning_content并从最终回答 content 中剥离内联推理块。
// 加密推理载荷不可展示,且不应作为正文输出,因此会被忽略。
func NormalizeChatCompletionResult(result map[string]any) map[string]any {
if result == nil {
return nil
}
choices, _ := result["choices"].([]any)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
if message, ok := choice["message"].(map[string]any); ok {
normalizeToolCallsContainer(message, false)
normalizeReasoningContainer(message, false)
}
if delta, ok := choice["delta"].(map[string]any); ok {
normalizeToolCallsContainer(delta, true)
normalizeReasoningContainer(delta, true)
}
}
return result
}
// NormalizeChatCompletionStreamEvent 将供应商自定义流式推理字段
// (例如 reasoning_details 或 reasoning归一化到 delta.reasoning_content。
func NormalizeChatCompletionStreamEvent(event map[string]any) map[string]any {
if event == nil {
return nil
}
choices, _ := event["choices"].([]any)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
if delta, ok := choice["delta"].(map[string]any); ok {
normalizeToolCallsContainer(delta, true)
normalizeReasoningContainer(delta, true)
}
if message, ok := choice["message"].(map[string]any); ok {
normalizeToolCallsContainer(message, false)
normalizeReasoningContainer(message, false)
}
}
return event
}
func normalizeToolCallsContainer(container map[string]any, stream bool) {
if container == nil {
return
}
toolCalls := make([]any, 0)
for _, rawToolCall := range rawToolCallValues(container) {
for _, normalized := range normalizeRawToolCalls(rawToolCall, len(toolCalls), stream) {
toolCalls = append(toolCalls, normalized)
}
}
if contentToolCalls, cleanContent, changed := toolCallsFromContent(container["content"], len(toolCalls), stream); changed {
toolCalls = append(toolCalls, contentToolCalls...)
setNormalizedContent(container, cleanContent, stream)
}
if partToolCalls := toolCallsFromParts(container["parts"], len(toolCalls), stream); len(partToolCalls) > 0 {
toolCalls = append(toolCalls, partToolCalls...)
delete(container, "parts")
}
if len(toolCalls) > 0 {
container["tool_calls"] = toolCalls
}
for _, key := range []string{"tool_call", "toolCall", "toolCalls", "function_call", "functionCall"} {
delete(container, key)
}
}
func normalizeToolMessageFields(message map[string]any) {
if message == nil {
return
}
if id := firstNonEmptyString(message["tool_call_id"], message["toolCallId"], message["tool_use_id"], message["toolUseId"], message["call_id"], message["callId"]); id != "" {
message["tool_call_id"] = id
}
for _, key := range []string{"toolCallId", "tool_use_id", "toolUseId", "call_id", "callId"} {
delete(message, key)
}
}
func toolResultMessagesFromContent(value any) ([]any, any, bool) {
blocks, ok := value.([]any)
if !ok {
return nil, nil, false
}
toolMessages := make([]any, 0)
remaining := make([]any, 0, len(blocks))
for _, rawBlock := range blocks {
block, _ := rawBlock.(map[string]any)
if len(block) == 0 || stringFromAny(block["type"]) != "tool_result" {
remaining = append(remaining, rawBlock)
continue
}
message := map[string]any{
"role": "tool",
"tool_call_id": firstNonEmptyString(block["tool_call_id"], block["toolCallId"], block["tool_use_id"], block["toolUseId"], block["id"]),
"content": toolResultContent(block["content"]),
}
toolMessages = append(toolMessages, message)
}
if len(toolMessages) == 0 {
return nil, nil, false
}
return toolMessages, contentBlocksText(remaining), true
}
func toolResultContent(value any) any {
if text, ok := value.(string); ok {
return text
}
return jsonStringFromAny(value)
}
func contentHasText(value any) bool {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed) != ""
case []any:
return len(typed) > 0
default:
return value != nil
}
}
func rawToolCallValues(container map[string]any) []any {
values := make([]any, 0, 6)
for _, key := range []string{"tool_calls", "tool_call", "toolCalls", "toolCall", "function_call", "functionCall"} {
if value, ok := container[key]; ok {
values = append(values, value)
}
}
return values
}
func normalizeRawToolCalls(value any, startIndex int, stream bool) []any {
switch typed := value.(type) {
case []any:
out := make([]any, 0, len(typed))
for _, raw := range typed {
if toolCall := normalizeToolCall(raw, startIndex+len(out), stream); toolCall != nil {
out = append(out, toolCall)
}
}
return out
default:
if toolCall := normalizeToolCall(value, startIndex, stream); toolCall != nil {
return []any{toolCall}
}
return nil
}
}
func normalizeToolCall(value any, index int, stream bool) map[string]any {
source, _ := value.(map[string]any)
if len(source) == 0 {
return nil
}
functionSource := mapFromAny(source["function"])
if len(functionSource) == 0 {
functionSource = mapFromAny(firstPresent(source["function_call"], source["functionCall"]))
}
name := firstNonEmptyString(
functionSource["name"], source["name"], source["function_name"], source["functionName"], source["tool_name"], source["toolName"],
)
arguments, hasArguments := toolCallArguments(functionSource)
if !hasArguments {
arguments, hasArguments = toolCallArguments(source)
}
if name == "" && !hasArguments && firstNonEmptyString(source["id"], source["call_id"], source["callId"], source["tool_call_id"], source["toolCallId"]) == "" {
return nil
}
function := map[string]any{}
if name != "" {
function["name"] = name
}
if hasArguments {
function["arguments"] = arguments
}
toolCall := map[string]any{
"type": firstNonEmptyString(source["type"], "function"),
"function": function,
}
if id := firstNonEmptyString(source["id"], source["call_id"], source["callId"], source["tool_call_id"], source["toolCallId"]); id != "" {
toolCall["id"] = id
} else if !stream {
toolCall["id"] = fmt.Sprintf("call_%d", index)
}
if stream {
if rawIndex, ok := firstPresent(source["index"], source["idx"]).(float64); ok {
toolCall["index"] = int(math.Round(rawIndex))
} else if rawIndex, ok := firstPresent(source["index"], source["idx"]).(int); ok {
toolCall["index"] = rawIndex
} else {
toolCall["index"] = index
}
}
return toolCall
}
func toolCallsFromContent(value any, startIndex int, stream bool) ([]any, any, bool) {
blocks, ok := value.([]any)
if !ok {
return nil, nil, false
}
toolCalls := make([]any, 0)
remaining := make([]any, 0, len(blocks))
containsReasoning := false
for _, rawBlock := range blocks {
block, _ := rawBlock.(map[string]any)
if len(block) == 0 {
remaining = append(remaining, rawBlock)
continue
}
switch stringFromAny(block["type"]) {
case "tool_use":
if toolCall := normalizeToolUseBlock(block, startIndex+len(toolCalls), stream); toolCall != nil {
toolCalls = append(toolCalls, toolCall)
}
case "tool_result":
remaining = append(remaining, rawBlock)
default:
if isReasoningContentBlock(block) {
containsReasoning = true
}
remaining = append(remaining, rawBlock)
}
}
if len(toolCalls) == 0 {
return nil, nil, false
}
if containsReasoning {
return toolCalls, remaining, true
}
return toolCalls, contentBlocksText(remaining), true
}
func normalizeToolUseBlock(block map[string]any, index int, stream bool) map[string]any {
toolCall := map[string]any{
"type": "function",
"function": map[string]any{
"name": stringFromAny(block["name"]),
"arguments": jsonStringFromAny(block["input"]),
},
}
if id := firstNonEmptyString(block["id"], block["tool_use_id"], block["toolUseId"]); id != "" {
toolCall["id"] = id
} else if !stream {
toolCall["id"] = fmt.Sprintf("call_%d", index)
}
if stream {
toolCall["index"] = index
}
return toolCall
}
func toolCallsFromParts(value any, startIndex int, stream bool) []any {
parts, ok := value.([]any)
if !ok {
return nil
}
out := make([]any, 0)
for _, rawPart := range parts {
part, _ := rawPart.(map[string]any)
if functionCall := mapFromAny(firstPresent(part["functionCall"], part["function_call"])); len(functionCall) > 0 {
if toolCall := normalizeGeminiFunctionCall(functionCall, startIndex+len(out), stream); toolCall != nil {
out = append(out, toolCall)
}
}
}
return out
}
func normalizeGeminiFunctionCall(functionCall map[string]any, index int, stream bool) map[string]any {
toolCall := map[string]any{
"type": "function",
"function": map[string]any{
"name": stringFromAny(functionCall["name"]),
"arguments": jsonStringFromAny(firstPresent(functionCall["args"], functionCall["arguments"])),
},
}
if id := firstNonEmptyString(functionCall["id"], functionCall["call_id"], functionCall["callId"]); id != "" {
toolCall["id"] = id
} else if !stream {
toolCall["id"] = fmt.Sprintf("call_%d", index)
}
if stream {
toolCall["index"] = index
}
return toolCall
}
func setNormalizedContent(container map[string]any, value any, stream bool) {
if text, ok := value.(string); ok && text == "" {
if stream {
delete(container, "content")
return
}
container["content"] = nil
return
}
container["content"] = value
}
func isReasoningContentBlock(block map[string]any) bool {
switch stringFromAny(block["type"]) {
case "thinking", "redacted_thinking", "reasoning.text", "reasoning.summary", "reasoning.encrypted":
return true
default:
return false
}
}
func contentBlocksText(blocks []any) string {
parts := make([]string, 0, len(blocks))
for _, rawBlock := range blocks {
switch block := rawBlock.(type) {
case string:
parts = append(parts, block)
case map[string]any:
if text := stringFromAny(firstPresent(block["text"], block["content"])); text != "" {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "")
}
func toolCallArguments(source map[string]any) (string, bool) {
for _, key := range []string{"arguments", "args", "input", "parameters"} {
if value, ok := source[key]; ok {
return jsonStringFromAny(value), true
}
}
return "", false
}
func jsonStringFromAny(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return text
}
encoded, err := json.Marshal(value)
if err != nil {
return ""
}
return string(encoded)
}
func normalizeReasoningContainer(container map[string]any, deleteEmptyContent bool) {
if container == nil {
return
}
reasoningParts := make([]string, 0, 3)
if reasoning := reasoningDetailsText(container["reasoning_details"]); reasoning != "" {
reasoningParts = append(reasoningParts, reasoning)
} else if reasoning := stringFromAny(container["reasoning_content"]); reasoning != "" {
reasoningParts = append(reasoningParts, reasoning)
} else if reasoning := stringFromAny(container["reasoning"]); reasoning != "" {
reasoningParts = append(reasoningParts, reasoning)
}
if content, ok := container["content"]; ok {
cleanContent, contentReasoning, changed := normalizeReasoningContentValue(content)
if changed {
if deleteEmptyContent {
if text, ok := cleanContent.(string); ok && text == "" {
delete(container, "content")
} else {
container["content"] = cleanContent
}
} else {
container["content"] = cleanContent
}
}
if contentReasoning != "" {
reasoningParts = append(reasoningParts, contentReasoning)
}
}
if len(reasoningParts) > 0 {
container["reasoning_content"] = strings.Join(reasoningParts, "")
}
delete(container, "reasoning_details")
delete(container, "reasoning")
}
func reasoningDetailsText(value any) string {
rawItems, ok := value.([]any)
if !ok {
return ""
}
parts := make([]string, 0, len(rawItems))
for _, rawItem := range rawItems {
item, _ := rawItem.(map[string]any)
switch stringFromAny(item["type"]) {
case "reasoning.text":
if text := stringFromAny(item["text"]); text != "" {
parts = append(parts, text)
}
case "reasoning.summary":
if summary := stringFromAny(item["summary"]); summary != "" {
parts = append(parts, summary)
}
}
}
return strings.Join(parts, "")
}
func normalizeReasoningContentValue(value any) (any, string, bool) {
switch typed := value.(type) {
case string:
cleanContent, reasoning, changed := splitTaggedReasoningText(typed)
return cleanContent, reasoning, changed
case []any:
contentParts := make([]string, 0, len(typed))
reasoningParts := make([]string, 0)
changed := false
for _, rawItem := range typed {
switch item := rawItem.(type) {
case string:
contentParts = append(contentParts, item)
case map[string]any:
switch stringFromAny(item["type"]) {
case "thinking":
if thinking := stringFromAny(item["thinking"]); thinking != "" {
reasoningParts = append(reasoningParts, thinking)
}
changed = true
case "redacted_thinking", "reasoning.encrypted":
changed = true
case "reasoning.text":
if text := stringFromAny(item["text"]); text != "" {
reasoningParts = append(reasoningParts, text)
}
changed = true
case "reasoning.summary":
if summary := stringFromAny(item["summary"]); summary != "" {
reasoningParts = append(reasoningParts, summary)
}
changed = true
case "text", "output_text":
if text := stringFromAny(firstPresent(item["text"], item["content"])); text != "" {
cleanText, reasoning, tagged := splitTaggedReasoningText(text)
contentParts = append(contentParts, cleanText)
if reasoning != "" {
reasoningParts = append(reasoningParts, reasoning)
}
changed = changed || tagged
}
default:
if text := stringFromAny(firstPresent(item["text"], item["content"])); text != "" {
contentParts = append(contentParts, text)
}
}
}
}
if !changed {
return value, "", false
}
return strings.Join(contentParts, ""), strings.Join(reasoningParts, ""), true
default:
return value, "", false
}
}
func splitTaggedReasoningText(text string) (string, string, bool) {
lower := strings.ToLower(text)
clean := strings.Builder{}
reasoning := strings.Builder{}
changed := false
for offset := 0; offset < len(text); {
start, tag := nextReasoningOpenTag(lower, offset)
if start < 0 {
clean.WriteString(text[offset:])
break
}
clean.WriteString(text[offset:start])
openEnd := start + len("<"+tag+">")
closeToken := "</" + tag + ">"
closeStart := strings.Index(lower[openEnd:], closeToken)
if closeStart < 0 {
reasoning.WriteString(text[openEnd:])
offset = len(text)
changed = true
break
}
closeStart += openEnd
reasoning.WriteString(text[openEnd:closeStart])
offset = closeStart + len(closeToken)
changed = true
}
return clean.String(), reasoning.String(), changed
}
func nextReasoningOpenTag(lower string, offset int) (int, string) {
bestStart := -1
bestTag := ""
for _, tag := range []string{"think", "reasoning", "analysis"} {
needle := "<" + tag + ">"
idx := strings.Index(lower[offset:], needle)
if idx < 0 {
continue
}
absolute := offset + idx
if bestStart < 0 || absolute < bestStart {
bestStart = absolute
bestTag = tag
}
}
return bestStart, bestTag
}
func streamEventText(event map[string]any) string { func streamEventText(event map[string]any) string {
if choices, ok := event["choices"].([]any); ok { if choices, ok := event["choices"].([]any); ok {
for _, rawChoice := range choices { for _, rawChoice := range choices {
@ -784,91 +195,6 @@ func streamEventText(event map[string]any) string {
return "" return ""
} }
func streamEventReasoningContent(event map[string]any) string {
if choices, ok := event["choices"].([]any); ok {
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
if delta, ok := choice["delta"].(map[string]any); ok {
if content, ok := delta["reasoning_content"].(string); ok {
return content
}
if content, ok := delta["reasoning"].(string); ok {
return content
}
}
if message, ok := choice["message"].(map[string]any); ok {
if content, ok := message["reasoning_content"].(string); ok {
return content
}
}
}
}
return ""
}
func streamEventFinishReason(event map[string]any) string {
if choices, ok := event["choices"].([]any); ok {
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
return reason
}
}
}
return ""
}
func aggregateStreamToolCalls(event map[string]any, toolCalls map[int]map[string]any) {
choices, _ := event["choices"].([]any)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
rawToolCalls, _ := delta["tool_calls"].([]any)
for _, rawToolCall := range rawToolCalls {
incoming, _ := rawToolCall.(map[string]any)
index := intFromAny(incoming["index"])
current := toolCalls[index]
if current == nil {
current = map[string]any{}
toolCalls[index] = current
}
for _, key := range []string{"id", "type"} {
if value, ok := incoming[key].(string); ok && value != "" {
current[key] = value
}
}
incomingFn, _ := incoming["function"].(map[string]any)
if len(incomingFn) == 0 {
continue
}
currentFn, _ := current["function"].(map[string]any)
if currentFn == nil {
currentFn = map[string]any{}
current["function"] = currentFn
}
if name, ok := incomingFn["name"].(string); ok && name != "" {
currentFn["name"] = stringFromAny(currentFn["name"]) + name
}
if arguments, ok := incomingFn["arguments"].(string); ok && arguments != "" {
currentFn["arguments"] = stringFromAny(currentFn["arguments"]) + arguments
}
}
}
}
func sortedStreamToolCalls(toolCalls map[int]map[string]any) []any {
indices := make([]int, 0, len(toolCalls))
for index := range toolCalls {
indices = append(indices, index)
}
sort.Ints(indices)
out := make([]any, 0, len(indices))
for _, index := range indices {
out = append(out, toolCalls[index])
}
return out
}
func usageFromOpenAI(result map[string]any) Usage { func usageFromOpenAI(result map[string]any) Usage {
usage, _ := result["usage"].(map[string]any) usage, _ := result["usage"].(map[string]any)
input := intFromAny(firstPresent(usage["prompt_tokens"], usage["input_tokens"])) input := intFromAny(firstPresent(usage["prompt_tokens"], usage["input_tokens"]))
@ -928,15 +254,6 @@ func stringFromAny(value any) string {
return "" return ""
} }
func firstNonEmptyString(values ...any) string {
for _, value := range values {
if text := strings.TrimSpace(stringFromAny(value)); text != "" {
return text
}
}
return ""
}
func firstPresent(values ...any) any { func firstPresent(values ...any) any {
for _, value := range values { for _, value := range values {
if value != nil { if value != nil {

View File

@ -1,960 +0,0 @@
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 ""
}
}

View File

@ -23,9 +23,6 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported openai request kind", Retryable: false} return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported openai request kind", Retryable: false}
} }
body := cloneBody(request.Body) body := cloneBody(request.Body)
if request.Kind == "chat.completions" {
body = NormalizeChatCompletionRequestBody(body)
}
body["model"] = upstreamModelName(request.Candidate) body["model"] = upstreamModelName(request.Candidate)
stream := request.Stream || boolValue(body, "stream") stream := request.Stream || boolValue(body, "stream")
ensureOpenAIStreamUsage(body, request.Kind, stream) ensureOpenAIStreamUsage(body, request.Kind, stream)
@ -36,16 +33,13 @@ 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)
if err == nil && request.Kind == "chat.completions" {
result = NormalizeChatCompletionResult(result)
}
responseFinishedAt := time.Now() responseFinishedAt := time.Now()
if err != nil { if err != nil {
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt) return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)

View File

@ -48,13 +48,7 @@ type Progress struct {
Payload map[string]any Payload map[string]any
} }
type StreamDeltaEvent struct { type StreamDelta func(text string) error
Text string
ReasoningContent string
Event map[string]any
}
type StreamDelta func(event StreamDeltaEvent) error
type Client interface { type Client interface {
Run(ctx context.Context, request Request) (Response, error) Run(ctx context.Context, request Request) (Response, error)
@ -152,8 +146,5 @@ 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
} }

View File

@ -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()

View File

@ -1,150 +0,0 @@
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, 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: []clients.StreamDeltaEvent{{Text: "hel"}, {Text: "lo"}},
output: map[string]any{"id": "chatcmpl-test", "object": "chat.completion", "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}},
}
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, 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{`data: {`, `"role":"assistant"`, `"created":`, `"system_fingerprint":`, `"content":"hel"`, `"content":"lo"`, `"finish_reason":"stop"`, `"usage":{"completion_tokens":2,"prompt_tokens":1,"total_tokens":3}`, "data: [DONE]"} {
if !strings.Contains(body, want) {
t.Fatalf("SSE body missing %s: %s", want, body)
}
}
if strings.Contains(body, "event: message") {
t.Fatalf("chat completions stream should use OpenAI data-only SSE frames: %s", body)
}
}
func TestWriteCompatibleTaskResponseStreamsStructuredToolAndReasoningDeltas(t *testing.T) {
executor := &fakeTaskExecutor{
deltas: []clients.StreamDeltaEvent{
{Event: map[string]any{"id": "chatcmpl-upstream", "object": "chat.completion.chunk", "created": float64(1710000000), "model": "deepseek-v4", "system_fingerprint": "fp-test", "choices": []any{map[string]any{"index": float64(0), "delta": map[string]any{"reasoning_details": []any{map[string]any{"type": "reasoning.text", "text": "detail-"}, map[string]any{"type": "reasoning.summary", "summary": "summary"}, map[string]any{"type": "reasoning.encrypted", "data": "secret"}}}, "finish_reason": nil}}}},
{Event: map[string]any{"id": "chatcmpl-upstream", "object": "chat.completion.chunk", "created": float64(1710000000), "model": "deepseek-v4", "system_fingerprint": "fp-test", "choices": []any{map[string]any{"index": float64(0), "delta": map[string]any{"content": "<think>tagged</think>answer"}, "finish_reason": nil}}}},
{Event: map[string]any{"id": "chatcmpl-upstream", "object": "chat.completion.chunk", "created": float64(1710000000), "model": "deepseek-v4", "system_fingerprint": "fp-test", "choices": []any{map[string]any{"index": float64(0), "delta": map[string]any{"functionCall": map[string]any{"name": "legacy_lookup", "arguments": "{\"city\":\"Boston\"}"}}, "finish_reason": nil}}}},
{Event: map[string]any{"id": "chatcmpl-upstream", "object": "chat.completion.chunk", "created": float64(1710000000), "model": "deepseek-v4", "system_fingerprint": "fp-test", "choices": []any{map[string]any{"index": float64(0), "delta": map[string]any{"tool_calls": []any{map[string]any{"index": float64(0), "id": "call_1", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"q\":"}}}}, "finish_reason": nil}}}},
{Event: map[string]any{"id": "chatcmpl-upstream", "object": "chat.completion.chunk", "created": float64(1710000000), "model": "deepseek-v4", "system_fingerprint": "fp-test", "choices": []any{map[string]any{"index": float64(0), "delta": map[string]any{"tool_calls": []any{map[string]any{"index": float64(0), "function": map[string]any{"arguments": "\"weather\"}"}}}}, "finish_reason": "tool_calls"}}}},
{Event: map[string]any{"id": "chatcmpl-upstream", "object": "chat.completion.chunk", "created": float64(1710000000), "model": "deepseek-v4", "choices": []any{}, "usage": map[string]any{"prompt_tokens": float64(4), "completion_tokens": float64(5), "total_tokens": float64(9)}}},
},
output: map[string]any{"id": "chatcmpl-upstream", "object": "chat.completion", "model": "deepseek-v4"},
}
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, true)
body := recorder.Body.String()
roleIndex := strings.Index(body, `"role":"assistant"`)
reasoningIndex := strings.Index(body, `"reasoning_content":"detail-summary"`)
if roleIndex < 0 || reasoningIndex < 0 || roleIndex > reasoningIndex {
t.Fatalf("assistant role should be emitted before structured deltas: %s", body)
}
for _, want := range []string{`"system_fingerprint":"fp-test"`, `"created":1710000000`, `"reasoning_content":"tagged"`, `"content":"answer"`, `"tool_calls":[{"function":{"arguments":"{\"city\":\"Boston\"}","name":"legacy_lookup"}`, `"tool_calls":[{"function":{"arguments":"{\"q\":"`, `"finish_reason":"tool_calls"`, `"choices":[],"created":1710000000`, `"usage":{"completion_tokens":5,"prompt_tokens":4,"total_tokens":9}`, "data: [DONE]"} {
if !strings.Contains(body, want) {
t.Fatalf("SSE body missing %s: %s", want, body)
}
}
if strings.Contains(body, "reasoning_details") || strings.Contains(body, "<think>") || strings.Contains(body, "functionCall") {
t.Fatalf("provider-specific reasoning/tool fields should be converted away: %s", body)
}
}
type fakeTaskExecutor struct {
executeCalls int
streamCalls int
deltas []clients.StreamDeltaEvent
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
}

View File

@ -316,17 +316,13 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
} `json:"task"` } `json:"task"`
} }
defaultTextModel := "openai:gpt-4o-mini" defaultTextModel := "openai:gpt-4o-mini"
var apiV1Chat map[string]any 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": 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"}},
}, "default-chat-"+suffixText, http.StatusOK, &apiV1Chat, &taskResponse.Task) }, http.StatusAccepted, &taskResponse)
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)
} }
@ -517,13 +513,13 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
ErrorCode string `json:"errorCode"` ErrorCode string `json:"errorCode"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "permission-deny-"+suffixText, http.StatusNotFound, nil, &deniedTask.Task) }, http.StatusAccepted, &deniedTask)
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)
} }
@ -565,13 +561,13 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
ErrorCode string `json:"errorCode"` ErrorCode string `json:"errorCode"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "permission-allow-block-"+suffixText, http.StatusNotFound, nil, &blockedControlledTask.Task) }, http.StatusAccepted, &blockedControlledTask)
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)
} }
@ -590,13 +586,13 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
Status string `json:"status"` Status string `json:"status"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "permission-allow-pass-"+suffixText, http.StatusOK, nil, &allowedControlledTask.Task) }, http.StatusAccepted, &allowedControlledTask)
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)
} }
@ -649,13 +645,13 @@ WHERE gateway_user_id = $1::uuid
FinalChargeAmount float64 `json:"finalChargeAmount"` FinalChargeAmount float64 `json:"finalChargeAmount"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "pricing-chat-"+suffixText, http.StatusOK, nil, &pricingTask.Task) }, http.StatusAccepted, &pricingTask)
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)
} }
@ -761,14 +757,14 @@ WHERE reference_type = 'gateway_task'
ErrorCode string `json:"errorCode"` ErrorCode string `json:"errorCode"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "rate-limit-failed-first-"+suffixText, http.StatusBadGateway, nil, &rateLimitFailedTask.Task) }, http.StatusAccepted, &rateLimitFailedTask)
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)
} }
@ -778,13 +774,13 @@ WHERE reference_type = 'gateway_task'
Status string `json:"status"` Status string `json:"status"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "rate-limit-first-"+suffixText, http.StatusOK, nil, &rateLimitTaskOne.Task) }, http.StatusAccepted, &rateLimitTaskOne)
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)
} }
@ -794,13 +790,13 @@ WHERE reference_type = 'gateway_task'
ErrorCode string `json:"errorCode"` ErrorCode string `json:"errorCode"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "rate-limit-second-"+suffixText, http.StatusTooManyRequests, nil, &rateLimitTaskTwo.Task) }, http.StatusAccepted, &rateLimitTaskTwo)
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)
} }
@ -812,12 +808,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/responses", apiKeyResponse.Secret, map[string]any{ doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
"model": rateLimitedModel, "model": rateLimitedModel,
"runMode": "simulation", "runMode": "simulation",
"simulation": true, "simulation": true,
"simulationDurationMs": 5, "simulationDurationMs": 5,
"input": "async queued", "messages": []map[string]any{{"role": "user", "content": "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)
@ -988,11 +984,11 @@ WHERE reference_type = 'gateway_task'
Status string `json:"status"` Status string `json:"status"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "failover-chat-"+suffixText, http.StatusOK, nil, &failoverTask.Task) }, http.StatusAccepted, &failoverTask)
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)
} }
@ -1107,13 +1103,13 @@ WHERE failed.id = $1::uuid`, failedPlatform.ID, successPlatform.ID, unrelatedPri
Status string `json:"status"` Status string `json:"status"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "degrade-chat-"+suffixText, http.StatusOK, nil, &degradeTask.Task) }, http.StatusAccepted, &degradeTask)
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)
} }
@ -1174,13 +1170,13 @@ WHERE m.platform_id = $1::uuid
ErrorCode string `json:"errorCode"` ErrorCode string `json:"errorCode"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "auto-disable-chat-"+suffixText, http.StatusBadGateway, nil, &autoDisableTask.Task) }, http.StatusAccepted, &autoDisableTask)
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)
} }
@ -1297,12 +1293,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/responses", apiKeyResponse.Secret, map[string]any{ doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{
"model": defaultTextModel, "model": defaultTextModel,
"runMode": "simulation", "runMode": "simulation",
"simulation": true, "simulation": true,
"simulationDurationMs": 2000, "simulationDurationMs": 2000,
"input": "river worker restart", "messages": []map[string]any{{"role": "user", "content": "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)
@ -1457,20 +1453,6 @@ 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"`
@ -1499,11 +1481,6 @@ 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) {
@ -1511,15 +1488,15 @@ func waitForTaskIDByRequestField(t *testing.T, ctx context.Context, pool *pgxpoo
err := pool.QueryRow(ctx, ` err := pool.QueryRow(ctx, `
SELECT id::text SELECT id::text
FROM gateway_tasks FROM gateway_tasks
WHERE request->>$1 = $2 WHERE request->>'cancelTestId' = $1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1`, key, value).Scan(&taskID) LIMIT 1`, marker).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 %s=%s was not created within %s", key, value, timeout) t.Fatalf("task with request marker %s was not created within %s", marker, timeout)
return "" return ""
} }
@ -1600,13 +1577,13 @@ func assertLoadAvoidanceSimulatedRetryChain(t *testing.T, ctx context.Context, t
ErrorCode string `json:"errorCode"` ErrorCode string `json:"errorCode"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, baseURL, runtimeToken, map[string]any{ doJSON(t, baseURL, http.MethodPost, "/api/v1/chat/completions", 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"}},
}, "load-avoidance-"+suffixText, http.StatusBadGateway, nil, &taskResponse.Task) }, http.StatusAccepted, &taskResponse)
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)
} }

View File

@ -11,22 +11,6 @@ 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 {

View File

@ -13,7 +13,6 @@ 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"
) )
@ -805,7 +804,7 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user) estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user)
if err != nil { if err != nil {
if errors.Is(err, store.ErrNoModelCandidate) { if errors.Is(err, store.ErrNoModelCandidate) {
writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), store.ModelCandidateErrorCode(err)) writeError(w, statusFromRunError(err), err.Error(), store.ModelCandidateErrorCode(err))
return return
} }
s.logger.Error("estimate pricing failed", "error", err) s.logger.Error("estimate pricing failed", "error", err)
@ -859,7 +858,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// createTask godoc // createTask godoc
// @Summary 创建或执行 AI 任务 // @Summary 创建或执行 AI 任务
// @Description 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。 // @Description 网关任务接口按 model 选择平台模型;/api/v1 路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。
// @Tags tasks // @Tags tasks
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -875,6 +874,7 @@ 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
} }
responsePlan := planTaskResponse(kind, compatible, body, r) asyncMode := asyncRequest(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: responsePlan.asyncMode, Async: 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 responsePlan.asyncMode { if 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,84 +933,14 @@ 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 responsePlan.compatibleMode { if compatible {
writeCompatibleTaskResponse(runCtx, w, r, s.runner, kind, model, task, user, responsePlan.streamMode, streamIncludeUsage(body)) if boolValue(body, "stream") {
return
}
result, runErr := s.runner.Execute(runCtx, task, user)
if runErr != nil {
s.logger.Warn("task completed with failure", "kind", kind, "taskId", task.ID, "error", runErr)
}
if !requestStillConnected(r) {
return
}
writeTaskAccepted(w, result.Task)
})
}
// createAPIV1ChatCompletions godoc
// @Summary 创建 Chat Completions
// @Description /api/v1/chat/completions 同步执行stream=true 返回 text/event-stream SSEstream=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) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
return base, func() {}
}
ctx, cancel := context.WithCancel(base)
go func() {
select {
case <-s.ctx.Done():
cancel()
case <-ctx.Done():
}
}()
return ctx, cancel
}
func requestStillConnected(r *http.Request) bool {
select {
case <-r.Context().Done():
return false
default:
return true
}
}
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, includeUsage bool) {
if streamMode {
flusher := prepareCompatibleStream(w) flusher := prepareCompatibleStream(w)
streamWriter := newCompatibleStreamWriter(kind, model, includeUsage) result, runErr := s.runner.ExecuteStream(runCtx, task, user, func(delta string) error {
result, runErr := executor.ExecuteStream(runCtx, task, user, func(delta clients.StreamDeltaEvent) error {
if !requestStillConnected(r) { if !requestStillConnected(r) {
return nil return nil
} }
streamWriter.writeDelta(w, delta) writeCompatibleDelta(w, kind, model, delta)
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
} }
@ -1044,14 +974,13 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
if !requestStillConnected(r) { if !requestStillConnected(r) {
return return
} }
streamWriter.writeDone(w, result.Output) writeCompatibleDone(w, kind, model, result.Output)
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
} }
return return
} }
result, runErr := s.runner.Execute(runCtx, task, user)
result, runErr := executor.Execute(runCtx, task, user)
if runErr != nil { if runErr != nil {
if !requestStillConnected(r) { if !requestStillConnected(r) {
return return
@ -1063,12 +992,43 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
return return
} }
writeJSON(w, http.StatusOK, result.Output) writeJSON(w, http.StatusOK, result.Output)
return
}
result, runErr := s.runner.Execute(runCtx, task, user)
if runErr != nil {
s.logger.Warn("task completed with failure", "kind", kind, "taskId", task.ID, "error", runErr)
} }
func streamIncludeUsage(body map[string]any) bool { if !requestStillConnected(r) {
streamOptions, _ := body["stream_options"].(map[string]any) return
includeUsage, _ := streamOptions["include_usage"].(bool) }
return includeUsage writeTaskAccepted(w, result.Task)
})
}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
return base, func() {}
}
ctx, cancel := context.WithCancel(base)
go func() {
select {
case <-s.ctx.Done():
cancel()
case <-ctx.Done():
}
}()
return ctx, cancel
}
func requestStillConnected(r *http.Request) bool {
select {
case <-r.Context().Done():
return false
default:
return true
}
} }
func asyncRequest(r *http.Request) bool { func asyncRequest(r *http.Request) bool {
@ -1076,26 +1036,6 @@ func asyncRequest(r *http.Request) bool {
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,
@ -1175,9 +1115,6 @@ func runErrorDetails(err error) map[string]any {
if detail := rateLimitErrorDetail(err); len(detail) > 0 { if detail := rateLimitErrorDetail(err); len(detail) > 0 {
return map[string]any{"rateLimit": detail} return map[string]any{"rateLimit": detail}
} }
if detail := store.ModelCandidateErrorDetails(err); len(detail) > 0 {
return map[string]any{"modelCandidate": detail}
}
return nil return nil
} }

View File

@ -123,19 +123,6 @@ 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"`
} }
@ -179,8 +166,6 @@ type TaskRequest struct {
Stream bool `json:"stream,omitempty" example:"false"` Stream bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"` RunMode string `json:"runMode,omitempty" example:"simulation"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"` MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理深度OpenAI-compatible 请求字段;开放字符串,取值随 provider 和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh也可配置 max 等供应商自定义值。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
Size string `json:"size,omitempty" example:"1024x1024"` Size string `json:"size,omitempty" example:"1024x1024"`
Duration int `json:"duration,omitempty" example:"5"` Duration int `json:"duration,omitempty" example:"5"`
Resolution string `json:"resolution,omitempty" example:"720p"` Resolution string `json:"resolution,omitempty" example:"720p"`
@ -191,8 +176,6 @@ type ChatCompletionRequest struct {
Messages []ChatMessage `json:"messages"` Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"` Temperature float64 `json:"temperature,omitempty" example:"0.7"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"` MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理深度OpenAI-compatible 请求字段;开放字符串,取值随 provider 和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh也可配置 max 等供应商自定义值。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
Stream bool `json:"stream,omitempty" example:"false"` Stream bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"` RunMode string `json:"runMode,omitempty" example:"simulation"`
} }
@ -246,32 +229,6 @@ 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"`

View File

@ -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.createAPIV1ChatCompletions())) mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", false)))
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)))

View File

@ -9,28 +9,10 @@ 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)
} }

View File

@ -1,13 +1,6 @@
package httpapi package httpapi
import ( import "net/http"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
)
func prepareCompatibleStream(w http.ResponseWriter) http.Flusher { func prepareCompatibleStream(w http.ResponseWriter) http.Flusher {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
@ -17,180 +10,55 @@ func prepareCompatibleStream(w http.ResponseWriter) http.Flusher {
return flusher return flusher
} }
type compatibleStreamWriter struct { func writeCompatibleDelta(w http.ResponseWriter, kind string, model string, content string) {
kind string if kind == "responses" {
model string sendSSE(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "delta": content})
includeUsage bool
id string
created int64
systemFingerprint any
sentRole bool
sentFinish bool
sentUsage bool
}
func newCompatibleStreamWriter(kind string, model string, includeUsage bool) *compatibleStreamWriter {
return &compatibleStreamWriter{
kind: kind,
model: model,
includeUsage: includeUsage,
id: "chatcmpl-stream",
created: time.Now().Unix(),
}
}
func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients.StreamDeltaEvent) {
if s.kind == "responses" {
if event.Text != "" {
sendSSE(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "delta": event.Text})
}
return return
} }
sendSSE(w, "message", map[string]any{
if event.Event != nil && isChatCompletionChunk(event.Event) { "id": "chatcmpl-stream",
s.writeChatChunk(w, event.Event) "object": "chat.completion.chunk",
return "model": model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": content}, "finish_reason": nil}},
})
} }
if event.Text == "" && event.ReasoningContent == "" { func writeCompatibleDone(w http.ResponseWriter, kind string, model string, output map[string]any) {
return if kind == "responses" {
}
s.ensureRoleChunk(w)
if event.ReasoningContent != "" {
s.writeChatData(w, s.chatChunk([]any{map[string]any{"index": 0, "delta": map[string]any{"reasoning_content": event.ReasoningContent}, "finish_reason": nil}}, nil))
}
if event.Text != "" {
s.writeChatData(w, s.chatChunk([]any{map[string]any{"index": 0, "delta": map[string]any{"content": event.Text}, "finish_reason": nil}}, nil))
}
}
func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[string]any) {
if s.kind == "responses" {
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "response": output}) sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "response": output})
return return
} }
s.captureOutputMetadata(output) sendSSE(w, "message", map[string]any{
if !s.sentRole { "id": firstString(output["id"], "chatcmpl-stream"),
s.ensureRoleChunk(w)
}
if !s.sentFinish {
s.writeChatData(w, s.chatChunk([]any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": finishReasonFromOutput(output)}}, nil))
s.sentFinish = true
}
if s.includeUsage && !s.sentUsage {
if usage, ok := output["usage"].(map[string]any); ok && len(usage) > 0 {
s.writeChatData(w, s.chatChunk([]any{}, usage))
s.sentUsage = true
}
}
s.writeDoneMarker(w)
}
func (s *compatibleStreamWriter) writeChatChunk(w http.ResponseWriter, chunk map[string]any) {
chunk = clients.NormalizeChatCompletionStreamEvent(chunk)
s.captureChunkMetadata(chunk)
choices, _ := chunk["choices"].([]any)
usage, hasUsage := chunk["usage"].(map[string]any)
if len(choices) == 0 && hasUsage {
if !s.includeUsage {
return
}
s.writeChatData(w, s.chatChunk([]any{}, usage))
s.sentUsage = true
return
}
if len(choices) > 0 && !chunkHasRole(choices) && !s.sentRole {
s.ensureRoleChunk(w)
}
if chunkHasRole(choices) {
s.sentRole = true
}
if chunkHasFinishReason(choices) {
s.sentFinish = true
}
normalized := cloneMap(chunk)
normalized["id"] = s.id
normalized["object"] = "chat.completion.chunk"
normalized["created"] = s.created
normalized["model"] = firstString(normalized["model"], s.model)
normalized["system_fingerprint"] = s.systemFingerprint
s.writeChatData(w, normalized)
}
func (s *compatibleStreamWriter) ensureRoleChunk(w http.ResponseWriter) {
if s.sentRole {
return
}
s.writeChatData(w, s.chatChunk([]any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}}, nil))
s.sentRole = true
}
func (s *compatibleStreamWriter) chatChunk(choices []any, usage map[string]any) map[string]any {
chunk := map[string]any{
"id": s.id,
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
"created": s.created, "model": model,
"model": s.model, "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}},
"system_fingerprint": s.systemFingerprint, })
"choices": choices,
}
if usage != nil {
chunk["usage"] = usage
} else {
chunk["usage"] = nil
}
return chunk
}
func (s *compatibleStreamWriter) writeChatData(w http.ResponseWriter, payload map[string]any) {
bytes, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "data: %s\n\n", bytes)
}
func (s *compatibleStreamWriter) writeDoneMarker(w http.ResponseWriter) {
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
}
func (s *compatibleStreamWriter) captureChunkMetadata(chunk map[string]any) {
if id := firstString(chunk["id"], ""); id != "" {
s.id = id
}
if model := firstString(chunk["model"], ""); model != "" {
s.model = model
}
if created := int64FromAny(chunk["created"]); created > 0 {
s.created = created
}
if value, ok := chunk["system_fingerprint"]; ok {
s.systemFingerprint = value
}
}
func (s *compatibleStreamWriter) captureOutputMetadata(output map[string]any) {
if id := firstString(output["id"], ""); id != "" {
s.id = id
}
if model := firstString(output["model"], ""); model != "" {
s.model = model
}
if created := int64FromAny(output["created"]); created > 0 {
s.created = created
}
if value, ok := output["system_fingerprint"]; ok {
s.systemFingerprint = value
}
} }
func writeCompatibleStream(w http.ResponseWriter, kind string, model string, output map[string]any) { func writeCompatibleStream(w http.ResponseWriter, kind string, model string, output map[string]any) {
prepareCompatibleStream(w) prepareCompatibleStream(w)
writer := newCompatibleStreamWriter(kind, model, true)
content := extractOutputText(output) content := extractOutputText(output)
if content == "" { if content == "" {
content = "done" content = "done"
} }
writer.writeDelta(w, clients.StreamDeltaEvent{Text: content}) if kind == "responses" {
writer.writeDone(w, output) sendSSE(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "delta": content})
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "response": output})
return
}
sendSSE(w, "message", map[string]any{
"id": output["id"],
"object": "chat.completion.chunk",
"model": model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": content}, "finish_reason": nil}},
})
sendSSE(w, "message", map[string]any{
"id": output["id"],
"object": "chat.completion.chunk",
"model": model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}},
})
} }
func firstString(value any, fallback string) string { func firstString(value any, fallback string) string {
@ -200,68 +68,6 @@ func firstString(value any, fallback string) string {
return fallback return fallback
} }
func int64FromAny(value any) int64 {
switch typed := value.(type) {
case int64:
return typed
case int:
return int64(typed)
case float64:
return int64(typed)
default:
return 0
}
}
func isChatCompletionChunk(event map[string]any) bool {
object, _ := event["object"].(string)
if object == "chat.completion.chunk" {
return true
}
_, hasChoices := event["choices"].([]any)
return hasChoices
}
func chunkHasRole(choices []any) bool {
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if role, ok := delta["role"].(string); ok && role != "" {
return true
}
}
return false
}
func chunkHasFinishReason(choices []any) bool {
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
return true
}
}
return false
}
func finishReasonFromOutput(output map[string]any) string {
choices, _ := output["choices"].([]any)
for _, rawChoice := range choices {
choice, _ := rawChoice.(map[string]any)
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
return reason
}
}
return "stop"
}
func cloneMap(value map[string]any) map[string]any {
out := map[string]any{}
for key, item := range value {
out[key] = item
}
return out
}
func extractOutputText(output map[string]any) string { func extractOutputText(output map[string]any) string {
if text, ok := output["output_text"].(string); ok { if text, ok := output["output_text"].(string); ok {
return text return text

View File

@ -8,17 +8,6 @@ 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 {
@ -29,17 +18,6 @@ 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 {
@ -54,20 +32,6 @@ 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 {
@ -83,21 +47,6 @@ 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 {
@ -121,23 +70,6 @@ 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 {
@ -175,19 +107,6 @@ 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) {

View File

@ -1,355 +0,0 @@
package runner
import (
"fmt"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const unsupportedRequestResolutionCode = "unsupported_request_resolution"
type requestResolutionRequirement struct {
Kind string
RequestedModel string
ModelType string
Resolution string
Source string
Scopes []string
}
type videoResolutionReferenceStats struct {
HasFirstFrame bool
HasLastFrame bool
ReferenceImages int
HasReferenceVideo bool
HasReferenceAudio bool
HasAnyMedia bool
HasExplicitContent bool
}
func filterRuntimeCandidatesByRequest(kind string, requestedModel string, modelType string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]store.RuntimeModelCandidate, map[string]any, error) {
requirement, ok := requestResolutionRequirementFor(kind, requestedModel, modelType, body)
if !ok || len(candidates) == 0 {
return candidates, nil, nil
}
filtered := make([]store.RuntimeModelCandidate, 0, len(candidates))
rejected := make([]map[string]any, 0)
supportedResolutions := make([]string, 0)
for _, candidate := range candidates {
supported, detail := candidateSupportsRequestResolution(candidate, requirement)
if supported {
filtered = append(filtered, candidate)
for _, value := range stringListFromAny(detail["allowedResolutions"]) {
appendUniqueString(&supportedResolutions, value)
}
continue
}
rejected = append(rejected, detail)
for _, value := range stringListFromAny(detail["allowedResolutions"]) {
appendUniqueString(&supportedResolutions, value)
}
}
summary := requestResolutionFilterSummary(requirement, len(candidates), len(filtered), rejected, supportedResolutions)
if len(filtered) == 0 {
return nil, summary, &store.ModelCandidateUnavailableError{
Code: unsupportedRequestResolutionCode,
Message: unsupportedRequestResolutionMessage(requirement, rejected),
Details: summary,
}
}
return filtered, summary, nil
}
func requestResolutionRequirementFor(kind string, requestedModel string, modelType string, body map[string]any) (requestResolutionRequirement, bool) {
if !isResolutionFilteredModelType(modelType) {
return requestResolutionRequirement{}, false
}
resolution, source := requestResolutionValue(body, modelType)
if resolution == "" {
return requestResolutionRequirement{}, false
}
return requestResolutionRequirement{
Kind: kind,
RequestedModel: requestedModel,
ModelType: modelType,
Resolution: resolution,
Source: source,
Scopes: requestResolutionScopes(body, modelType),
}, true
}
func requestResolutionValue(body map[string]any, modelType string) (string, string) {
if value := normalizedRequestResolution(stringFromAny(body["resolution"])); value != "" {
return value, "resolution"
}
size := normalizedRequestResolution(stringFromAny(body["size"]))
if size == "" {
return "", ""
}
if isImageResolution(modelType, size) || isVideoResolution(modelType, size) {
return size, "size"
}
return "", ""
}
func normalizedRequestResolution(value string) string {
value = strings.TrimSpace(value)
if value == "" || isEmptyParamString(value) {
return ""
}
switch strings.ToLower(value) {
case "auto", "automatic", "adaptive", "default":
return ""
default:
return value
}
}
func isResolutionFilteredModelType(modelType string) bool {
return modelType == "image_generate" || modelType == "image_edit" || isVideoModelType(modelType)
}
func candidateSupportsRequestResolution(candidate store.RuntimeModelCandidate, requirement requestResolutionRequirement) (bool, map[string]any) {
modelType := firstNonEmptyString(candidate.ModelType, requirement.ModelType)
capability := capabilityForType(effectiveModelCapability(candidate), modelType)
detail := candidateResolutionDetail(candidate, requirement, modelType)
if capability == nil {
detail["reason"] = "capability_missing"
detail["message"] = "候选平台模型未配置对应模型类型能力。"
detail["capabilityPath"] = capabilityPath(modelType, "output_resolutions")
return false, detail
}
allowed, configured := outputResolutionAllowedValues(capability["output_resolutions"], requirement.Scopes)
detail["allowedResolutions"] = allowed
detail["capabilityPath"] = capabilityPath(modelType, "output_resolutions")
detail["capabilityValue"] = cloneAny(capability["output_resolutions"])
if !configured {
detail["reason"] = "output_resolutions_missing"
detail["message"] = "候选平台模型未声明 output_resolutions。"
return false, detail
}
if containsResolution(allowed, requirement.Resolution) {
detail["reason"] = "supported"
return true, detail
}
detail["reason"] = "resolution_not_allowed"
detail["message"] = "请求分辨率不在候选平台模型 output_resolutions 中。"
return false, detail
}
func outputResolutionAllowedValues(value any, scopes []string) ([]string, bool) {
switch typed := value.(type) {
case []any, []string, string:
return uniqueStringList(stringListFromAny(typed)), true
case map[string]any:
for _, scope := range append(scopes, "default", "*", "all") {
if scope == "" {
continue
}
if raw, ok := typed[scope]; ok {
return uniqueStringList(stringListFromAny(raw)), true
}
}
if len(scopes) == 0 {
values := make([]string, 0)
for _, raw := range typed {
values = append(values, stringListFromAny(raw)...)
}
return uniqueStringList(values), len(values) > 0
}
return nil, true
default:
return nil, false
}
}
func containsResolution(values []string, target string) bool {
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) {
return true
}
}
return false
}
func candidateResolutionDetail(candidate store.RuntimeModelCandidate, requirement requestResolutionRequirement, modelType string) map[string]any {
return map[string]any{
"platformId": candidate.PlatformID,
"platformKey": candidate.PlatformKey,
"platformName": candidate.PlatformName,
"provider": candidate.Provider,
"platformModelId": candidate.PlatformModelID,
"modelName": candidate.ModelName,
"modelAlias": candidate.ModelAlias,
"displayName": candidate.DisplayName,
"providerModelName": candidate.ProviderModelName,
"modelType": modelType,
"requested": map[string]any{
"resolution": requirement.Resolution,
"source": requirement.Source,
"scopes": requirement.Scopes,
},
}
}
func requestResolutionFilterSummary(requirement requestResolutionRequirement, candidateCount int, supportedCandidateCount int, rejected []map[string]any, supportedResolutions []string) map[string]any {
return map[string]any{
"code": unsupportedRequestResolutionCode,
"filter": "request_resolution",
"kind": requirement.Kind,
"requestedModel": requirement.RequestedModel,
"modelType": requirement.ModelType,
"requestedResolution": requirement.Resolution,
"resolutionSource": requirement.Source,
"resolutionScopes": requirement.Scopes,
"capabilityPath": capabilityPath(requirement.ModelType, "output_resolutions"),
"candidateCount": candidateCount,
"supportedCandidateCount": supportedCandidateCount,
"filteredCandidateCount": len(rejected),
"supportedResolutions": uniqueStringList(supportedResolutions),
"rejectedCandidates": rejected,
}
}
func unsupportedRequestResolutionMessage(requirement requestResolutionRequirement, rejected []map[string]any) string {
resource := "媒体"
if requirement.ModelType == "image_generate" || requirement.ModelType == "image_edit" {
resource = "图像"
} else if isVideoModelType(requirement.ModelType) {
resource = "视频"
}
message := fmt.Sprintf("请求的%s分辨率 %s 没有可用平台模型支持,已过滤 %d 个候选平台模型", resource, requirement.Resolution, len(rejected))
if summaries := rejectedResolutionSummaries(rejected, 3); len(summaries) > 0 {
message += ";候选支持:" + strings.Join(summaries, "")
}
return message
}
func rejectedResolutionSummaries(rejected []map[string]any, limit int) []string {
summaries := make([]string, 0, limit)
for _, item := range rejected {
if len(summaries) >= limit {
break
}
allowed := stringListFromAny(item["allowedResolutions"])
if len(allowed) == 0 {
continue
}
name := firstNonEmptyString(stringFromAny(item["platformName"]), stringFromAny(item["platformKey"]), stringFromAny(item["provider"]))
model := firstNonEmptyString(stringFromAny(item["displayName"]), stringFromAny(item["modelAlias"]), stringFromAny(item["modelName"]))
if model != "" {
name = firstNonEmptyString(name, model)
if name != model {
name += "/" + model
}
}
if name == "" {
name = "候选"
}
summaries = append(summaries, fmt.Sprintf("%s=%s", name, strings.Join(allowed, "/")))
}
return summaries
}
func requestResolutionScopes(body map[string]any, modelType string) []string {
if !isVideoModelType(modelType) {
return nil
}
scopes := make([]string, 0)
for _, key := range []string{"videoMode", "video_mode", "mode", "generation_mode", "generate_mode", "supported_mode"} {
appendUniqueString(&scopes, stringFromMap(body, key))
}
stats := videoResolutionReferenceStatsFromBody(body)
if stats.HasFirstFrame && stats.HasLastFrame {
appendUniqueString(&scopes, "input_first_last_frame")
appendUniqueString(&scopes, "first_last_frame")
} else if stats.HasFirstFrame {
appendUniqueString(&scopes, "input_first_frame")
} else if stats.HasLastFrame {
appendUniqueString(&scopes, "input_last_frame")
}
if stats.ReferenceImages > 1 {
appendUniqueString(&scopes, "input_reference_generate_multiple")
appendUniqueString(&scopes, "image_reference")
} else if stats.ReferenceImages == 1 {
appendUniqueString(&scopes, "input_reference_generate_single")
appendUniqueString(&scopes, "image_reference")
}
if stats.HasReferenceVideo {
appendUniqueString(&scopes, "video_reference")
}
if stats.HasReferenceAudio {
appendUniqueString(&scopes, "audio_reference")
}
if !stats.HasAnyMedia {
appendUniqueString(&scopes, "text_to_video")
}
return scopes
}
func videoResolutionReferenceStatsFromBody(body map[string]any) videoResolutionReferenceStats {
stats := videoResolutionReferenceStats{}
content := contentItems(body["content"])
stats.HasExplicitContent = len(content) > 0
for _, item := range content {
if isImageContent(item) {
stats.HasAnyMedia = true
switch strings.TrimSpace(stringFromAny(item["role"])) {
case "first_frame":
stats.HasFirstFrame = true
case "last_frame":
stats.HasLastFrame = true
default:
stats.ReferenceImages++
}
}
if isVideoContent(item) {
stats.HasAnyMedia = true
stats.HasReferenceVideo = true
}
if isAudioContent(item) {
stats.HasAnyMedia = true
stats.HasReferenceAudio = true
}
}
if hasAnyString(body, "first_frame", "firstFrame") {
stats.HasAnyMedia = true
stats.HasFirstFrame = true
}
if hasAnyString(body, "last_frame", "lastFrame") {
stats.HasAnyMedia = true
stats.HasLastFrame = true
}
if hasAnyString(body, "reference_image", "referenceImage") {
stats.HasAnyMedia = true
stats.ReferenceImages++
}
if hasAnyString(body, "video", "video_url", "videoUrl", "reference_video", "referenceVideo") {
stats.HasAnyMedia = true
stats.HasReferenceVideo = true
}
if hasAnyString(body, "audio_url", "audioUrl", "reference_audio", "referenceAudio") {
stats.HasAnyMedia = true
stats.HasReferenceAudio = true
}
if hasAnyString(body, "image", "images", "image_url", "imageUrl", "image_urls", "imageUrls") {
stats.HasAnyMedia = true
if !stats.HasFirstFrame && !stats.HasExplicitContent {
stats.HasFirstFrame = true
} else {
stats.ReferenceImages++
}
}
return stats
}
func candidateCapabilityFilterMetrics(summary map[string]any) map[string]any {
if len(summary) == 0 {
return nil
}
return map[string]any{"candidateCapabilityFilter": summary}
}

View File

@ -1,191 +0,0 @@
package runner
import (
"errors"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestFilterRuntimeCandidatesByRequestResolutionKeepsSupportedCandidate(t *testing.T) {
candidates := []store.RuntimeModelCandidate{
candidateWithResolutions("low", "720p"),
candidateWithResolutions("high", "1080p"),
}
filtered, summary, err := filterRuntimeCandidatesByRequest("videos.generations", "demo-video", "video_generate", map[string]any{
"resolution": "1080p",
}, candidates)
if err != nil {
t.Fatalf("filter should keep a supported candidate: %v", err)
}
if len(filtered) != 1 || filtered[0].PlatformKey != "high" {
t.Fatalf("expected only high resolution candidate, got %+v", filtered)
}
if summary["filteredCandidateCount"] != 1 || summary["supportedCandidateCount"] != 1 {
t.Fatalf("unexpected filter summary: %+v", summary)
}
}
func TestFilterRuntimeCandidatesByScopedVideoResolution(t *testing.T) {
candidates := []store.RuntimeModelCandidate{
{
PlatformID: "platform-first",
PlatformKey: "first",
PlatformName: "First Frame Platform",
PlatformModelID: "model-first",
ModelName: "demo-video",
ModelType: "image_to_video",
Capabilities: map[string]any{
"image_to_video": map[string]any{
"output_resolutions": map[string]any{
"input_first_frame": []any{"1080p"},
},
},
},
},
{
PlatformID: "platform-first-last",
PlatformKey: "first-last",
PlatformName: "First Last Platform",
PlatformModelID: "model-first-last",
ModelName: "demo-video",
ModelType: "image_to_video",
Capabilities: map[string]any{
"image_to_video": map[string]any{
"output_resolutions": map[string]any{
"input_first_last_frame": []any{"1080p"},
},
},
},
},
}
filtered, _, err := filterRuntimeCandidatesByRequest("videos.generations", "demo-video", "image_to_video", map[string]any{
"resolution": "1080p",
"content": []any{
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"}},
},
}, candidates)
if err != nil {
t.Fatalf("filter should keep first-last scoped candidate: %v", err)
}
if len(filtered) != 1 || filtered[0].PlatformKey != "first-last" {
t.Fatalf("expected first-last scoped candidate only, got %+v", filtered)
}
}
func TestFilterRuntimeCandidatesByRequestResolutionFailsWithDetails(t *testing.T) {
candidates := []store.RuntimeModelCandidate{
candidateWithImageResolutions("jimeng-v3", "1K", "2K"),
candidateWithImageResolutions("jimeng-v4", "1K"),
}
filtered, summary, err := filterRuntimeCandidatesByRequest("images.generations", "demo-image", "image_generate", map[string]any{
"resolution": "4K",
}, candidates)
if len(filtered) != 0 {
t.Fatalf("expected no candidates, got %+v", filtered)
}
var candidateErr *store.ModelCandidateUnavailableError
if !errors.As(err, &candidateErr) {
t.Fatalf("expected model candidate error, got %T %v", err, err)
}
if candidateErr.Code != unsupportedRequestResolutionCode {
t.Fatalf("unexpected error code: %s", candidateErr.Code)
}
if !strings.Contains(candidateErr.Message, "4K") {
t.Fatalf("message should include requested resolution, got %q", candidateErr.Message)
}
if summary["filteredCandidateCount"] != 2 || candidateErr.Details["requestedResolution"] != "4K" {
t.Fatalf("unexpected filter detail summary=%+v details=%+v", summary, candidateErr.Details)
}
if details := store.ModelCandidateErrorDetails(err); details["requestedResolution"] != "4K" {
t.Fatalf("store detail helper should expose requested resolution, got %+v", details)
}
}
func TestFilterRuntimeCandidatesSkipsPixelSizeCompatibility(t *testing.T) {
candidates := []store.RuntimeModelCandidate{{
PlatformID: "openai",
PlatformKey: "openai",
PlatformModelID: "gpt-image-1",
ModelName: "gpt-image-1",
ModelType: "image_generate",
Capabilities: map[string]any{
"image_generate": map[string]any{
"aspect_ratio_allowed": []any{"1:1"},
},
},
}}
filtered, summary, err := filterRuntimeCandidatesByRequest("images.generations", "gpt-image-1", "image_generate", map[string]any{
"size": "1024x1024",
}, candidates)
if err != nil {
t.Fatalf("pixel size compatibility should skip resolution filtering: %v", err)
}
if len(filtered) != 1 || summary != nil {
t.Fatalf("expected unchanged candidates and no summary, got filtered=%+v summary=%+v", filtered, summary)
}
}
func TestBuildFailureResultIncludesModelCandidateDetails(t *testing.T) {
cause := &store.ModelCandidateUnavailableError{
Code: unsupportedRequestResolutionCode,
Message: "unsupported resolution",
Details: map[string]any{
"requestedResolution": "4K",
"candidateCount": 2,
},
}
result := buildFailureResult(store.ModelCandidateErrorCode(cause), cause.Error(), "", cause)
errorPayload, _ := result["error"].(map[string]any)
modelCandidate, _ := errorPayload["modelCandidate"].(map[string]any)
if errorPayload["code"] != unsupportedRequestResolutionCode || modelCandidate["requestedResolution"] != "4K" {
t.Fatalf("failure result should persist candidate details, got %+v", result)
}
}
func candidateWithResolutions(platformKey string, resolutions ...string) store.RuntimeModelCandidate {
return store.RuntimeModelCandidate{
PlatformID: "platform-" + platformKey,
PlatformKey: platformKey,
PlatformName: "Platform " + platformKey,
PlatformModelID: "model-" + platformKey,
ModelName: "demo-video",
ModelType: "video_generate",
Capabilities: map[string]any{
"video_generate": map[string]any{
"output_resolutions": stringsToAny(resolutions),
},
},
}
}
func candidateWithImageResolutions(platformKey string, resolutions ...string) store.RuntimeModelCandidate {
return store.RuntimeModelCandidate{
PlatformID: "platform-" + platformKey,
PlatformKey: platformKey,
PlatformName: "Platform " + platformKey,
PlatformModelID: "model-" + platformKey,
ModelName: "demo-image",
ModelType: "image_generate",
Capabilities: map[string]any{
"image_generate": map[string]any{
"output_resolutions": stringsToAny(resolutions),
},
},
}
}
func stringsToAny(values []string) []any {
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
}

View File

@ -470,7 +470,7 @@ func isEmptyParamString(value string) bool {
} }
func isImageResolution(modelType string, value string) bool { func isImageResolution(modelType string, value string) bool {
return (modelType == "image_generate" || modelType == "image_edit") && containsString([]string{"1K", "2K", "3K", "4K", "8K"}, value) return (modelType == "image_generate" || modelType == "image_edit") && containsString([]string{"1K", "2K", "4K", "8K"}, value)
} }
func isVideoResolution(modelType string, value string) bool { func isVideoResolution(modelType string, value string) bool {

View File

@ -19,12 +19,7 @@ type EstimateResult struct {
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) { func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
body = normalizeRequest(kind, body) body = normalizeRequest(kind, body)
modelType := modelTypeFromKind(kind, body) candidates, err := s.store.ListModelCandidates(ctx, model, modelTypeFromKind(kind, body), user)
candidates, err := s.store.ListModelCandidates(ctx, model, modelType, user)
if err != nil {
return EstimateResult{}, err
}
candidates, _, err = filterRuntimeCandidatesByRequest(kind, model, modelType, body, candidates)
if err != nil { if err != nil {
return EstimateResult{}, err return EstimateResult{}, err
} }
@ -82,23 +77,19 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
resource = "video" resource = "video"
unit = "5s_video" unit = "5s_video"
baseKey = "videoBase" baseKey = "videoBase"
duration, durationSource := billingDurationSeconds(body, response) duration := requestDurationSeconds(body)
audioEnabled, audioSource := billingAudioEnabled(body, response)
durationUnits := math.Max(1, math.Ceil(duration/5)) durationUnits := math.Max(1, math.Ceil(duration/5))
amount := float64(count) * amount := float64(count) *
durationUnits * durationUnits *
resourcePrice(config, resource, baseKey, "basePrice") * resourcePrice(config, resource, baseKey, "basePrice") *
resourceWeight(config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))) * resourceWeight(config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))) *
resourceWeight(config, resource, "audioWeights", boolWeightKey(audioEnabled)) * resourceWeight(config, resource, "audioWeights", boolWeightKey(boolishValue(body["audio"]))) *
resourceWeight(config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))) * resourceWeight(config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))) *
resourceWeight(config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled))) * resourceWeight(config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body))) *
discount discount
return []any{billingLineWithDetails(candidate, resource, unit, count*int(durationUnits), roundPrice(amount), discount, simulated, map[string]any{ return []any{billingLineWithDetails(candidate, resource, unit, count*int(durationUnits), roundPrice(amount), discount, simulated, map[string]any{
"count": count, "count": count,
"audio": audioEnabled,
"audioSource": audioSource,
"durationSeconds": duration, "durationSeconds": duration,
"durationSource": durationSource,
"durationUnit": "5s", "durationUnit": "5s",
"durationUnitCount": durationUnits, "durationUnitCount": durationUnits,
})} })}
@ -349,54 +340,6 @@ func requestDurationSeconds(body map[string]any) float64 {
return 5 return 5
} }
func billingDurationSeconds(body map[string]any, response clients.Response) (float64, string) {
if duration, ok := generatedVideoDurationSeconds(response.Result); ok {
return duration, "generated_video"
}
return requestDurationSeconds(body), "preprocessed_request"
}
func generatedVideoDurationSeconds(result map[string]any) (float64, bool) {
data, _ := result["data"].([]any)
for _, raw := range data {
item, _ := raw.(map[string]any)
if len(item) == 0 {
continue
}
duration := floatFromAny(item["duration"])
if duration <= 0 {
continue
}
rounded := math.Round(duration)
if rounded <= 0 {
rounded = 1
}
return rounded, true
}
return 0, false
}
func billingAudioEnabled(body map[string]any, response clients.Response) (bool, string) {
if value, ok := generatedVideoHasAudio(response.Result); ok {
return value, "generated_video"
}
return boolishValue(body["audio"]), "preprocessed_request"
}
func generatedVideoHasAudio(result map[string]any) (bool, bool) {
data, _ := result["data"].([]any)
for _, raw := range data {
item, _ := raw.(map[string]any)
if len(item) == 0 {
continue
}
if value, ok := boolishOptional(firstPresentValue(item, "has_audio", "hasAudio")); ok {
return value, true
}
}
return false, false
}
func requestHasReferenceVideo(body map[string]any) bool { func requestHasReferenceVideo(body map[string]any) bool {
if hasNonEmptyArray(body["video_list"]) || hasNonEmptyArray(body["videoList"]) { if hasNonEmptyArray(body["video_list"]) || hasNonEmptyArray(body["videoList"]) {
return true return true
@ -419,8 +362,8 @@ func requestHasReferenceVideo(body map[string]any) bool {
return false return false
} }
func requestHasVoiceID(body map[string]any, audioEnabled bool) bool { func requestHasVoiceID(body map[string]any) bool {
return audioEnabled && firstNonEmptyStringValue(body, "voice_id", "voiceId") != "" return boolishValue(body["audio"]) && firstNonEmptyStringValue(body, "voice_id", "voiceId") != ""
} }
func boolWeightKey(value bool) string { func boolWeightKey(value bool) string {
@ -431,38 +374,25 @@ func boolWeightKey(value bool) string {
} }
func boolishValue(value any) bool { func boolishValue(value any) bool {
result, _ := boolishOptional(value)
return result
}
func boolishOptional(value any) (bool, bool) {
switch typed := value.(type) { switch typed := value.(type) {
case bool: case bool:
return typed, true return typed
case string: case string:
switch strings.ToLower(strings.TrimSpace(typed)) { switch strings.ToLower(strings.TrimSpace(typed)) {
case "true", "1", "yes", "on": case "true", "1", "yes", "on":
return true, true return true
case "false", "0", "no", "off": default:
return false, true return false
} }
case int: case int:
return typed != 0, true return typed != 0
case int64: case int64:
return typed != 0, true return typed != 0
case float64: case float64:
return typed != 0, true return typed != 0
default:
return false
} }
return false, false
}
func firstPresentValue(record map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := record[key]; ok {
return value
}
}
return nil
} }
func hasNonEmptyArray(value any) bool { func hasNonEmptyArray(value any) bool {

View File

@ -76,100 +76,6 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T)
if got, want := line["quantity"], 3; got != want { if got, want := line["quantity"], 3; got != want {
t.Fatalf("video quantity = %v, want %v", got, want) t.Fatalf("video quantity = %v, want %v", got, want)
} }
if got, want := line["durationSource"], "preprocessed_request"; got != want {
t.Fatalf("video duration source = %v, want %v", got, want)
}
if got, want := line["audioSource"], "preprocessed_request"; got != want {
t.Fatalf("video audio source = %v, want %v", got, want)
}
}
func TestVideoBillingPrefersGeneratedDuration(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{
ModelName: "video-model",
BaseBillingConfig: map[string]any{
"video": map[string]any{"basePrice": 100},
},
}
items := service.billings(context.Background(), nil, "videos.generations", map[string]any{
"duration": 12,
"resolution": "720p",
}, candidate, clients.Response{
Result: map[string]any{
"data": []any{map[string]any{"type": "video", "duration": 6.6}},
},
}, false)
line := firstBillingLine(t, items)
if got, want := floatFromAny(line["durationSeconds"]), 7.0; got != want {
t.Fatalf("video generated duration = %v, want %v", got, want)
}
if got, want := floatFromAny(line["durationUnitCount"]), 2.0; got != want {
t.Fatalf("video generated duration units = %v, want %v", got, want)
}
if got, want := floatFromAny(line["amount"]), 200.0; got != want {
t.Fatalf("video generated duration amount = %v, want %v", got, want)
}
if got, want := line["durationSource"], "generated_video"; got != want {
t.Fatalf("video duration source = %v, want %v", got, want)
}
}
func TestVideoBillingPrefersGeneratedAudio(t *testing.T) {
service := &Service{}
candidate := store.RuntimeModelCandidate{
ModelName: "video-model",
BaseBillingConfig: map[string]any{
"video": map[string]any{
"basePrice": 100,
"audioWeights": map[string]any{"true": 2, "false": 0.5},
"voiceSpecifiedWeights": map[string]any{"true": 4},
},
},
}
items := service.billings(context.Background(), nil, "videos.generations", map[string]any{
"audio": false,
"duration": 5,
}, candidate, clients.Response{
Result: map[string]any{
"data": []any{map[string]any{"type": "video", "has_audio": true}},
},
}, false)
line := firstBillingLine(t, items)
if got, want := floatFromAny(line["amount"]), 200.0; got != want {
t.Fatalf("video generated audio amount = %v, want %v", got, want)
}
if got, want := line["audio"], true; got != want {
t.Fatalf("video generated audio = %v, want %v", got, want)
}
if got, want := line["audioSource"], "generated_video"; got != want {
t.Fatalf("video audio source = %v, want %v", got, want)
}
items = service.billings(context.Background(), nil, "videos.generations", map[string]any{
"audio": true,
"duration": 5,
"voice_id": "voice-a",
}, candidate, clients.Response{
Result: map[string]any{
"data": []any{map[string]any{"type": "video", "hasAudio": false}},
},
}, false)
line = firstBillingLine(t, items)
if got, want := floatFromAny(line["amount"]), 50.0; got != want {
t.Fatalf("video generated no-audio amount = %v, want %v", got, want)
}
if got, want := line["audio"], false; got != want {
t.Fatalf("video generated no-audio = %v, want %v", got, want)
}
if got, want := line["audioSource"], "generated_video"; got != want {
t.Fatalf("video no-audio source = %v, want %v", got, want)
}
} }
func TestVideoBillingEstimateSupportsServerMainStyleDynamicKeys(t *testing.T) { func TestVideoBillingEstimateSupportsServerMainStyleDynamicKeys(t *testing.T) {

View File

@ -1,7 +1,6 @@
package runner package runner
import ( import (
"errors"
"strings" "strings"
"time" "time"
@ -211,12 +210,6 @@ 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 detail := store.ModelCandidateErrorDetails(err); len(detail) > 0 {
metrics["modelCandidate"] = detail
}
} }
if meta.StatusCode > 0 { if meta.StatusCode > 0 {
metrics["statusCode"] = meta.StatusCode metrics["statusCode"] = meta.StatusCode
@ -233,64 +226,6 @@ 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 buildFailureResult(code string, message string, requestID string, err error) map[string]any {
errorPayload := map[string]any{
"code": code,
"message": message,
}
if requestID != "" {
errorPayload["requestId"] = requestID
}
if detail := rateLimitFailureDetail(err); len(detail) > 0 {
errorPayload["rateLimit"] = detail
}
if detail := store.ModelCandidateErrorDetails(err); len(detail) > 0 {
errorPayload["modelCandidate"] = detail
}
return map[string]any{"error": errorPayload}
}
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 {

View File

@ -55,8 +55,6 @@ 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,
@ -84,17 +82,6 @@ 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
@ -103,48 +90,14 @@ 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
} }
return Result{Task: failed, Output: failed.Result}, err return Result{Task: failed, Output: failed.Result}, err
} }
var candidateFilterSummary map[string]any
candidates, candidateFilterSummary, err = filterRuntimeCandidatesByRequest(task.Kind, task.Model, modelType, body, candidates)
if err != nil {
candidateFilterMetrics := candidateCapabilityFilterMetrics(candidateFilterSummary)
s.recordFailedAttempt(ctx, failedAttemptRecord{
Task: task,
Body: body,
AttemptNo: task.AttemptCount + 1,
Code: store.ModelCandidateErrorCode(err),
Cause: err,
Simulated: task.RunMode == "simulation",
Scope: "candidate_request_filter",
Reason: store.ModelCandidateErrorCode(err),
ExtraMetrics: []map[string]any{candidateFilterMetrics},
ModelType: modelType,
})
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err, candidateFilterMetrics)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, err
}
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])
@ -153,20 +106,9 @@ 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)
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{ if logErr := s.recordTaskParameterPreprocessing(ctx, task.ID, "", 0, candidates[0], firstPreprocessing); logErr != nil {
Task: task, return Result{}, logErr
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
@ -179,20 +121,9 @@ 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) {
attemptNo = s.recordFailedAttempt(ctx, failedAttemptRecord{ if logErr := s.recordTaskParameterPreprocessing(ctx, task.ID, "", 0, candidates[0], firstPreprocessing); logErr != nil {
Task: task, return Result{}, logErr
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
@ -212,6 +143,7 @@ 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
@ -230,20 +162,6 @@ 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
@ -252,7 +170,6 @@ candidatesLoop:
attemptNo = nextAttemptNo attemptNo = nextAttemptNo
billings := s.billings(ctx, user, task.Kind, candidateBody, candidate, response, isSimulation(task, candidate)) billings := s.billings(ctx, user, task.Kind, candidateBody, candidate, response, isSimulation(task, candidate))
record := buildSuccessRecord(task, user, candidateBody, candidate, response, billings, isSimulation(task, candidate)) record := buildSuccessRecord(task, user, candidateBody, candidate, response, billings, isSimulation(task, candidate))
record.Metrics = mergeMetrics(record.Metrics, candidateCapabilityFilterMetrics(candidateFilterSummary))
record.Metrics = mergeMetrics(record.Metrics, parameterPreprocessingMetrics(preprocessing.Log)) record.Metrics = mergeMetrics(record.Metrics, parameterPreprocessingMetrics(preprocessing.Log))
record.Metrics = s.withAttemptHistory(ctx, task.ID, record.Metrics) record.Metrics = s.withAttemptHistory(ctx, task.ID, record.Metrics)
finished, finishErr := s.store.FinishTaskSuccess(ctx, store.FinishTaskSuccessInput{ finished, finishErr := s.store.FinishTaskSuccess(ctx, store.FinishTaskSuccessInput{
@ -305,19 +222,6 @@ 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,7 +520,6 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
return clients.Response{}, err return clients.Response{}, err
} }
response.Result = uploadedResult response.Result = uploadedResult
response.Result = s.enrichGeneratedVideoMetadata(ctx, task.Kind, response.Result)
for _, progress := range response.Progress { for _, progress := range response.Progress {
if err := s.emit(ctx, task.ID, "task.progress", "running", progress.Phase, progress.Progress, progress.Message, progress.Payload, simulated); err != nil { if err := s.emit(ctx, task.ID, "task.progress", "running", progress.Phase, progress.Progress, progress.Message, progress.Payload, simulated); err != nil {
return clients.Response{}, fmt.Errorf("emit task progress: %w", err) return clients.Response{}, fmt.Errorf("emit task progress: %w", err)
@ -698,7 +601,6 @@ func (s *Service) failTask(ctx context.Context, taskID string, code string, mess
TaskID: taskID, TaskID: taskID,
Code: code, Code: code,
Message: message, Message: message,
Result: buildFailureResult(code, message, requestID, cause),
RequestID: requestID, RequestID: requestID,
Metrics: metrics, Metrics: metrics,
ResponseStartedAt: responseStartedAt, ResponseStartedAt: responseStartedAt,
@ -714,110 +616,6 @@ 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 {

View File

@ -7,12 +7,8 @@ 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", scope, "failed", reason, policyRuleMatch{}, info) entry := policyTraceEntry("failure", "client", "failed", "client_call_failed", policyRuleMatch{}, info)
entry["retryable"] = retryable entry["retryable"] = retryable
return entry return entry
} }

View File

@ -1,144 +0,0 @@
package runner
import (
"context"
"encoding/json"
"fmt"
"math"
"os/exec"
"strconv"
"strings"
"time"
)
const generatedVideoMetadataProbeTimeout = 8 * time.Second
type generatedVideoMetadata struct {
Duration float64
HasAudio bool
HasAudioKnown bool
}
type ffprobeVideoMetadata struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
} `json:"streams"`
}
func (s *Service) enrichGeneratedVideoMetadata(ctx context.Context, taskKind string, result map[string]any) map[string]any {
if taskKind != "videos.generations" {
return result
}
data, _ := result["data"].([]any)
if len(data) == 0 {
return result
}
for _, raw := range data {
item, _ := raw.(map[string]any)
if len(item) == 0 || !isGeneratedVideoItem(item) {
continue
}
needsDuration := floatFromAny(item["duration"]) <= 0
_, hasAudioMetadata := boolishOptional(firstPresentValue(item, "has_audio", "hasAudio"))
if !needsDuration && hasAudioMetadata {
continue
}
urlValue := firstNonEmptyStringValue(item, "video_url", "videoUrl", "url")
if urlValue == "" {
continue
}
metadata, err := s.probeVideoMetadata(ctx, urlValue)
if err != nil {
if s.logger != nil {
s.logger.Debug("probe generated video metadata failed", "url", trimForLog(urlValue), "error", err)
}
continue
}
if needsDuration && metadata.Duration > 0 {
item["duration"] = metadata.Duration
}
if !hasAudioMetadata && metadata.HasAudioKnown {
item["has_audio"] = metadata.HasAudio
}
}
return result
}
func isGeneratedVideoItem(item map[string]any) bool {
itemType := strings.TrimSpace(stringFromAny(item["type"]))
if itemType == "video" {
return true
}
if firstNonEmptyStringValue(item, "video_url", "videoUrl") != "" {
return true
}
urlValue := strings.ToLower(firstNonEmptyStringValue(item, "url"))
return strings.Contains(urlValue, ".mp4") ||
strings.Contains(urlValue, ".mov") ||
strings.Contains(urlValue, ".webm") ||
strings.Contains(urlValue, ".m3u8")
}
func (s *Service) probeVideoMetadata(ctx context.Context, rawURL string) (generatedVideoMetadata, error) {
if _, err := exec.LookPath("ffprobe"); err != nil {
return generatedVideoMetadata{}, err
}
probeURL := rawURL
if s != nil {
if resolved, err := s.generatedAssetFetchURL(rawURL); err == nil && strings.TrimSpace(resolved) != "" {
probeURL = resolved
}
}
probeCtx, cancel := context.WithTimeout(ctx, generatedVideoMetadataProbeTimeout)
defer cancel()
cmd := exec.CommandContext(
probeCtx,
"ffprobe",
"-v", "error",
"-show_entries", "format=duration:stream=codec_type",
"-of", "json",
probeURL,
)
output, err := cmd.Output()
if err != nil {
return generatedVideoMetadata{}, err
}
var probed ffprobeVideoMetadata
if err := json.Unmarshal(output, &probed); err != nil {
return generatedVideoMetadata{}, err
}
metadata := generatedVideoMetadata{}
if durationText := strings.TrimSpace(probed.Format.Duration); durationText != "" {
if duration, err := strconv.ParseFloat(durationText, 64); err == nil && duration > 0 && !math.IsNaN(duration) && !math.IsInf(duration, 0) {
rounded := math.Round(duration)
if rounded <= 0 {
rounded = 1
}
metadata.Duration = rounded
}
}
if probed.Streams != nil {
metadata.HasAudioKnown = true
for _, stream := range probed.Streams {
if strings.TrimSpace(stream.CodecType) == "audio" {
metadata.HasAudio = true
break
}
}
}
if metadata.Duration <= 0 && !metadata.HasAudioKnown {
return metadata, fmt.Errorf("invalid video metadata: %q", trimForLog(string(output)))
}
return metadata, nil
}
func trimForLog(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 120 {
return value
}
return value[:120] + "..."
}

View File

@ -14,7 +14,6 @@ var (
type ModelCandidateUnavailableError struct { type ModelCandidateUnavailableError struct {
Code string Code string
Message string Message string
Details map[string]any
} }
func (e *ModelCandidateUnavailableError) Error() string { func (e *ModelCandidateUnavailableError) Error() string {
@ -33,14 +32,6 @@ func ModelCandidateErrorCode(err error) string {
return "no_model_candidate" return "no_model_candidate"
} }
func ModelCandidateErrorDetails(err error) map[string]any {
var candidateErr *ModelCandidateUnavailableError
if errors.As(err, &candidateErr) && len(candidateErr.Details) > 0 {
return candidateErr.Details
}
return nil
}
type RateLimitExceededError struct { type RateLimitExceededError struct {
ScopeType string ScopeType string
ScopeKey string ScopeKey string
@ -256,7 +247,6 @@ type FinishTaskFailureInput struct {
TaskID string TaskID string
Code string Code string
Message string Message string
Result map[string]any
RequestID string RequestID string
Metrics map[string]any Metrics map[string]any
ResponseStartedAt time.Time ResponseStartedAt time.Time

View File

@ -778,7 +778,6 @@ func taskBillingString(value any) string {
func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureInput) (GatewayTask, error) { func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureInput) (GatewayTask, error) {
metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics)) metricsJSON, _ := json.Marshal(emptyObjectIfNil(input.Metrics))
resultJSON, _ := json.Marshal(emptyObjectIfNil(input.Result))
if _, err := s.pool.Exec(ctx, ` if _, err := s.pool.Exec(ctx, `
UPDATE gateway_tasks UPDATE gateway_tasks
SET status = 'failed', SET status = 'failed',
@ -790,7 +789,6 @@ func (s *Store) FinishTaskFailure(ctx context.Context, input FinishTaskFailureIn
response_started_at = $6::timestamptz, response_started_at = $6::timestamptz,
response_finished_at = $7::timestamptz, response_finished_at = $7::timestamptz,
response_duration_ms = $8, response_duration_ms = $8,
result = $9::jsonb,
locked_by = NULL, locked_by = NULL,
locked_at = NULL, locked_at = NULL,
heartbeat_at = NULL, heartbeat_at = NULL,
@ -805,7 +803,6 @@ WHERE id = $1::uuid`,
nullableTime(input.ResponseStartedAt), nullableTime(input.ResponseStartedAt),
nullableTime(input.ResponseFinishedAt), nullableTime(input.ResponseFinishedAt),
input.ResponseDurationMS, input.ResponseDurationMS,
string(resultJSON),
); err != nil { ); err != nil {
return GatewayTask{}, err return GatewayTask{}, err
} }

View File

@ -1,25 +0,0 @@
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';

View File

@ -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(taskDurationMs(props.task))}</TableCell> <TableCell>{formatDuration(props.task.responseDurationMs)}</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,10 +971,7 @@ 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) => (
const trace = taskAttemptTrace(attempt);
const rateLimitText = taskAttemptRateLimitText(attempt);
return (
<span <span
key={attempt.id || `${props.task.id}-${attempt.attemptNo}`} key={attempt.id || `${props.task.id}-${attempt.attemptNo}`}
className={`taskRecordAttemptDetail ${attempt.status === 'failed' ? 'failed' : attempt.status === 'succeeded' ? 'succeeded' : ''}`} className={`taskRecordAttemptDetail ${attempt.status === 'failed' ? 'failed' : attempt.status === 'succeeded' ? 'succeeded' : ''}`}
@ -985,10 +982,9 @@ function TaskAttemptPopoverContent(props: { task: GatewayTask }) {
</span> </span>
<small>{taskAttemptMeta(attempt)}</small> <small>{taskAttemptMeta(attempt)}</small>
{attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>} {attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>}
{(rateLimitText || trace.length > 0) && ( {taskAttemptTrace(attempt).length > 0 && (
<span className="taskRecordAttemptTrace"> <span className="taskRecordAttemptTrace">
{rateLimitText && <span className="taskRecordAttemptTraceItem">{rateLimitText}</span>} {taskAttemptTrace(attempt).map((entry, index) => (
{trace.map((entry, index) => (
<span key={`${attempt.id || attempt.attemptNo}-trace-${index}`} className="taskRecordAttemptTraceItem"> <span key={`${attempt.id || attempt.attemptNo}-trace-${index}`} className="taskRecordAttemptTraceItem">
{taskAttemptTraceText(entry)} {taskAttemptTraceText(entry)}
</span> </span>
@ -996,8 +992,7 @@ function TaskAttemptPopoverContent(props: { task: GatewayTask }) {
</span> </span>
)} )}
</span> </span>
); ))}
})}
</span> </span>
); );
} }
@ -1029,7 +1024,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}` : '',
formatDuration(attemptDurationMs(attempt)), attempt.responseDurationMs ? formatDuration(attempt.responseDurationMs) : '',
].filter(Boolean); ].filter(Boolean);
return values.join(' · ') || attempt.clientId || '-'; return values.join(' · ') || attempt.clientId || '-';
} }
@ -1060,29 +1055,6 @@ 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');
@ -1144,12 +1116,6 @@ 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: '全局调度策略停用',
@ -1355,41 +1321,10 @@ 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 '-'; if (milliseconds === 0) return '0秒';
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);

View File

@ -40,14 +40,14 @@ type ValueOption = { label: string; value: string };
const textFields: FieldDefinition[] = [ const textFields: FieldDefinition[] = [
{ key: 'supportTool', label: '工具调用', hint: 'function calling / tools', type: 'boolean' }, { key: 'supportTool', label: '工具调用', hint: 'function calling / tools', type: 'boolean' },
{ key: 'supportStructuredOutput', label: '结构化输出', hint: 'JSON Schema 等输出', type: 'boolean' }, { key: 'supportStructuredOutput', label: '结构化输出', hint: 'JSON Schema 等输出', type: 'boolean' },
{ key: 'supportThinking', label: '推理能力', hint: '支持 reasoning / thinking 参数', type: 'boolean' }, { key: 'supportThinking', label: '思考能力', hint: '支持 thinking 参数', type: 'boolean' },
{ key: 'supportThinkingModeSwitch', label: '思考开关', hint: '可按请求切换', type: 'boolean' }, { key: 'supportThinkingModeSwitch', label: '思考开关', hint: '可按请求切换', type: 'boolean' },
{ key: 'supportWebSearch', label: '联网搜索', type: 'boolean' }, { key: 'supportWebSearch', label: '联网搜索', type: 'boolean' },
{ key: 'max_context_tokens', label: '上下文 Token', placeholder: '128000', type: 'number' }, { key: 'max_context_tokens', label: '上下文 Token', placeholder: '128000', type: 'number' },
{ key: 'max_input_tokens', label: '最大输入 Token', placeholder: '64000', type: 'number' }, { key: 'max_input_tokens', label: '最大输入 Token', placeholder: '64000', type: 'number' },
{ key: 'max_output_tokens', label: '最大输出 Token', placeholder: '8192', type: 'number' }, { key: 'max_output_tokens', label: '最大输出 Token', placeholder: '8192', type: 'number' },
{ key: 'max_thinking_tokens', label: '最大思考 Token', placeholder: '32768', type: 'number' }, { key: 'max_thinking_tokens', label: '最大思考 Token', placeholder: '32768', type: 'number' },
{ key: 'thinkingEffortLevels', label: '推理深度', hint: '声明模型支持的 reasoning_effort 取值,可填写 max 等供应商自定义值', placeholder: 'none, minimal, low, medium, high, xhigh, max', type: 'list' }, { key: 'thinkingEffortLevels', label: '思考强度', placeholder: 'minimal, low, medium, high', type: 'list' },
]; ];
const embeddingFields: FieldDefinition[] = [ const embeddingFields: FieldDefinition[] = [
@ -535,7 +535,7 @@ const imageAspectRatioOptions = [
'7:4', '7:4',
'4:7', '4:7',
]; ];
const thinkingEffortOptions = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; const thinkingEffortOptions = ['minimal', 'low', 'medium', 'high'];
const omniVideoModeOptions = ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot']; const omniVideoModeOptions = ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'];
const durationOptionValues = ['1', '2', '3', '4', '5', '6', '8', '10', '15', '20', '25', '30']; const durationOptionValues = ['1', '2', '3', '4', '5', '6', '8', '10', '15', '20', '25', '30'];
const exclusiveCapabilityFields: Record<string, string> = { const exclusiveCapabilityFields: Record<string, string> = {

View File

@ -1,65 +0,0 @@
{
"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
}

View File

@ -1,104 +0,0 @@
{
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 ""
'';
}

View File

@ -1,18 +0,0 @@
# 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

View File

@ -1505,22 +1505,6 @@ type ModelClient interface {
- progress event snapshot确保前端进度面板兼容。 - progress event snapshot确保前端进度面板兼容。
- billing snapshot确保预估扣费和最终 billings 语义一致。 - billing snapshot确保预估扣费和最终 billings 语义一致。
OpenAI-compatible 文本请求中的推理深度统一使用 `reasoning_effort` 表达。该字段是请求参数,不是响应中的推理内容;模型能力中用 `thinkingEffortLevels` 声明该模型支持的可选取值。`reasoning_effort` 必须按开放字符串处理,不在网关层写死枚举;实际可用集合必须以 provider 和模型能力为准。常见取值定义如下:
| 值 | 含义 |
| --- | --- |
| `none` | 不启用额外推理,适用于不需要思考链路的低延迟请求。 |
| `minimal` | 最小推理预算,优先降低延迟和成本。 |
| `low` | 较低推理预算,用于简单推理任务。 |
| `medium` | 默认/均衡推理深度,在质量、延迟和成本之间折中。 |
| `high` | 较高推理预算,用于复杂规划、代码和多步推理。 |
| `xhigh` | 最高推理预算,仅在模型和 provider 明确支持时使用,通常成本和延迟最高。 |
| `max` | 供应商自定义最高档示例,例如 DeepSeek V4 类模型可能使用该值;语义以 provider 文档为准。 |
除上表外,`thinkingEffortLevels` 可以保存任意供应商自定义值,例如 `max`、`ultra` 或后续模型新增档位。管理端只提供常见值作为快捷选项,不应阻止自定义输入;请求透传时按模型能力校验或直接交由上游 provider 返回错误。
`reasoning_content`、推理过程 delta 或思考摘要在 Chat Completions 中不是 OpenAI 标准必需字段;如需兼容 DeepSeek、Qwen 等供应商扩展,应在 adapter 层作为可选扩展透传,并避免把 hidden reasoning 默认暴露给普通兼容客户端。
## 11. 队列持久化、恢复与限流执行 ## 11. 队列持久化、恢复与限流执行
### 11.1 持久化队列原则 ### 11.1 持久化队列原则

View File

@ -68,11 +68,7 @@ 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}"
if [[ "${AI_GATEWAY_SKIP_DB_CREATE:-}" == "1" ]]; then
echo "[ai-gateway] skipping Docker database creation"
else
scripts/create-database.sh 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

View File

@ -1,101 +0,0 @@
# --- 全局结构 (极致紧凑高信息密度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