feat: 完善模型请求适配与输出限制

This commit is contained in:
wangbo 2026-07-17 13:52:00 +08:00
parent a24eb1aeb0
commit 5ee267ecbd
31 changed files with 3287 additions and 232 deletions

View File

@ -4521,7 +4521,7 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/httpapi.TaskRequest"
"$ref": "#/definitions/httpapi.ChatCompletionRequest"
}
}
],
@ -5513,31 +5513,26 @@
"BearerAuth": []
}
],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"description": "公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态Gateway 以本轮 input/messages 为准且不追加本地历史。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
"application/json",
"text/event-stream"
],
"tags": [
"tasks"
"responses"
],
"summary": "创建或执行 AI 任务",
"summary": "创建 OpenAI Responses",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"description": "AI 任务请求,字段随任务类型变化",
"description": "Responses 请求Chat 回退只支持自定义 function tools",
"name": "input",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/httpapi.TaskRequest"
"$ref": "#/definitions/httpapi.ResponsesRequest"
}
}
],
@ -5545,17 +5540,17 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
"$ref": "#/definitions/httpapi.ResponsesCompatibleResponse"
},
"headers": {
"X-Gateway-Task-Id": {
"type": "string",
"description": "网关审计任务 ID"
}
}
},
"400": {
"description": "Bad Request",
"description": "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
@ -5578,20 +5573,8 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"503": {
"description": "response_chain_unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
@ -6938,31 +6921,26 @@
"BearerAuth": []
}
],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"description": "OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
"application/json",
"text/event-stream"
],
"tags": [
"tasks"
"chat"
],
"summary": "创建或执行 AI 任务",
"summary": "创建 OpenAI Chat Completions",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"description": "AI 任务请求,字段随任务类型变化",
"description": "Chat Completions 请求",
"name": "input",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/httpapi.TaskRequest"
"$ref": "#/definitions/httpapi.ChatCompletionRequest"
}
}
],
@ -6970,17 +6948,11 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
"$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse"
}
},
"400": {
"description": "Bad Request",
"description": "invalid_parameter",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
@ -7003,12 +6975,6 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
@ -8220,31 +8186,26 @@
"BearerAuth": []
}
],
"description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible 路径同步返回兼容响应或 SSE 流。",
"description": "OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
"application/json",
"text/event-stream"
],
"tags": [
"tasks"
"chat"
],
"summary": "创建或执行 AI 任务",
"summary": "创建 OpenAI Chat Completions",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"description": "AI 任务请求,字段随任务类型变化",
"description": "Chat Completions 请求",
"name": "input",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/httpapi.TaskRequest"
"$ref": "#/definitions/httpapi.ChatCompletionRequest"
}
}
],
@ -8252,17 +8213,11 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
"$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse"
}
},
"400": {
"description": "Bad Request",
"description": "invalid_parameter",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
@ -8285,12 +8240,6 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
@ -9752,6 +9701,158 @@
}
}
},
"httpapi.ChatCompletionRequest": {
"type": "object",
"properties": {
"audio": {
"type": "object",
"additionalProperties": true
},
"frequency_penalty": {
"type": "number",
"example": 0
},
"function_call": {},
"functions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"logit_bias": {
"type": "object",
"additionalProperties": true
},
"logprobs": {
"type": "boolean"
},
"max_completion_tokens": {
"type": "integer",
"example": 512
},
"max_tokens": {
"type": "integer",
"example": 512
},
"messages": {
"type": "array",
"items": {
"$ref": "#/definitions/httpapi.ChatMessage"
}
},
"metadata": {
"type": "object",
"additionalProperties": true
},
"modalities": {
"type": "array",
"items": {
"type": "string"
}
},
"model": {
"type": "string",
"example": "gpt-4o-mini"
},
"moderation": {},
"n": {
"type": "integer",
"example": 1
},
"parallel_tool_calls": {
"type": "boolean"
},
"prediction": {},
"presence_penalty": {
"type": "number",
"example": 0
},
"prompt_cache_key": {
"type": "string"
},
"prompt_cache_options": {
"type": "object",
"additionalProperties": true
},
"prompt_cache_retention": {
"type": "string",
"enum": [
"in_memory",
"24h"
]
},
"reasoning_effort": {
"description": "ReasoningEffort 推理强度OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。",
"type": "string",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
],
"example": "medium"
},
"response_format": {},
"runMode": {
"type": "string",
"example": "simulation"
},
"safety_identifier": {
"type": "string"
},
"seed": {
"type": "integer"
},
"service_tier": {
"type": "string"
},
"stop": {},
"store": {
"type": "boolean"
},
"stream": {
"type": "boolean",
"example": false
},
"stream_options": {
"type": "object",
"additionalProperties": true
},
"temperature": {
"type": "number",
"example": 0.7
},
"tool_choice": {},
"tools": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"top_logprobs": {
"type": "integer"
},
"top_p": {
"type": "number",
"example": 1
},
"user": {
"type": "string"
},
"verbosity": {
"type": "string"
},
"web_search_options": {
"type": "object",
"additionalProperties": true
}
}
},
"httpapi.ChatCompletionUsage": {
"type": "object",
"properties": {
@ -9787,14 +9888,19 @@
"httpapi.ChatMessage": {
"type": "object",
"properties": {
"content": {
"type": "string",
"example": "Hello"
"content": {},
"function_call": {},
"name": {
"type": "string"
},
"role": {
"type": "string",
"example": "user"
}
},
"tool_call_id": {
"type": "string"
},
"tool_calls": {}
}
},
"httpapi.ChatPromptTokensDetails": {
@ -9853,9 +9959,14 @@
"type": "string",
"example": "invalid json body"
},
"param": {},
"status": {
"type": "integer",
"example": 400
},
"type": {
"type": "string",
"example": "invalid_request_error"
}
}
},
@ -10397,6 +10508,23 @@
"httpapi.ResponsesRequest": {
"type": "object",
"properties": {
"background": {
"type": "boolean"
},
"context_management": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"conversation": {},
"include": {
"type": "array",
"items": {
"type": "string"
}
},
"input": {},
"instructions": {
"type": "string",
@ -10406,10 +10534,18 @@
"type": "integer",
"example": 512
},
"max_tool_calls": {
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": true
},
"model": {
"type": "string",
"example": "Doubao Seed 2.0 Pro"
},
"moderation": {},
"parallel_tool_calls": {
"type": "boolean",
"example": true
@ -10418,10 +10554,31 @@
"type": "string",
"example": "resp_0123456789abcdef0123456789abcdef"
},
"prompt": {},
"prompt_cache_key": {
"type": "string"
},
"prompt_cache_options": {
"type": "object",
"additionalProperties": true
},
"prompt_cache_retention": {
"type": "string",
"enum": [
"in_memory",
"24h"
]
},
"reasoning": {
"type": "object",
"additionalProperties": true
},
"safety_identifier": {
"type": "string"
},
"service_tier": {
"type": "string"
},
"store": {
"type": "boolean"
},
@ -10429,6 +10586,10 @@
"type": "boolean",
"example": false
},
"stream_options": {
"type": "object",
"additionalProperties": true
},
"temperature": {
"type": "number",
"example": 0.7
@ -10445,9 +10606,18 @@
"additionalProperties": true
}
},
"top_logprobs": {
"type": "integer"
},
"top_p": {
"type": "number",
"example": 1
},
"truncation": {
"type": "string"
},
"user": {
"type": "string"
}
}
},
@ -10610,14 +10780,20 @@
"type": "string",
"example": "happy"
},
"input": {
"type": "string",
"example": "Tell me a short story"
},
"input": {},
"makeInstrumental": {
"type": "boolean",
"example": false
},
"max_completion_tokens": {
"description": "MaxCompletionTokens includes visible output and reasoning tokens.",
"type": "integer",
"example": 512
},
"max_output_tokens": {
"type": "integer",
"example": 512
},
"max_tokens": {
"type": "integer",
"example": 512
@ -10645,8 +10821,17 @@
"example": "A watercolor robot reading a book"
},
"reasoning_effort": {
"description": "ReasoningEffort 推理强度OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。",
"description": "ReasoningEffort 推理强度OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。",
"type": "string",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
],
"example": "medium"
},
"resolution": {

View File

@ -133,6 +133,118 @@ definitions:
usage:
$ref: '#/definitions/httpapi.ChatCompletionUsage'
type: object
httpapi.ChatCompletionRequest:
properties:
audio:
additionalProperties: true
type: object
frequency_penalty:
example: 0
type: number
function_call: {}
functions:
items:
additionalProperties: true
type: object
type: array
logit_bias:
additionalProperties: true
type: object
logprobs:
type: boolean
max_completion_tokens:
example: 512
type: integer
max_tokens:
example: 512
type: integer
messages:
items:
$ref: '#/definitions/httpapi.ChatMessage'
type: array
metadata:
additionalProperties: true
type: object
modalities:
items:
type: string
type: array
model:
example: gpt-4o-mini
type: string
moderation: {}
"n":
example: 1
type: integer
parallel_tool_calls:
type: boolean
prediction: {}
presence_penalty:
example: 0
type: number
prompt_cache_key:
type: string
prompt_cache_options:
additionalProperties: true
type: object
prompt_cache_retention:
enum:
- in_memory
- 24h
type: string
reasoning_effort:
description: ReasoningEffort 推理强度OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
enum:
- none
- minimal
- low
- medium
- high
- xhigh
- max
example: medium
type: string
response_format: {}
runMode:
example: simulation
type: string
safety_identifier:
type: string
seed:
type: integer
service_tier:
type: string
stop: {}
store:
type: boolean
stream:
example: false
type: boolean
stream_options:
additionalProperties: true
type: object
temperature:
example: 0.7
type: number
tool_choice: {}
tools:
items:
additionalProperties: true
type: object
type: array
top_logprobs:
type: integer
top_p:
example: 1
type: number
user:
type: string
verbosity:
type: string
web_search_options:
additionalProperties: true
type: object
type: object
httpapi.ChatCompletionUsage:
properties:
completion_tokens:
@ -157,12 +269,16 @@ definitions:
type: object
httpapi.ChatMessage:
properties:
content:
example: Hello
content: {}
function_call: {}
name:
type: string
role:
example: user
type: string
tool_call_id:
type: string
tool_calls: {}
type: object
httpapi.ChatPromptTokensDetails:
properties:
@ -203,9 +319,13 @@ definitions:
message:
example: invalid json body
type: string
param: {}
status:
example: 400
type: integer
type:
example: invalid_request_error
type: string
type: object
httpapi.FileStorageChannelListResponse:
properties:
@ -567,6 +687,18 @@ definitions:
type: object
httpapi.ResponsesRequest:
properties:
background:
type: boolean
context_management:
items:
additionalProperties: true
type: object
type: array
conversation: {}
include:
items:
type: string
type: array
input: {}
instructions:
example: Answer concisely
@ -574,23 +706,47 @@ definitions:
max_output_tokens:
example: 512
type: integer
max_tool_calls:
type: integer
metadata:
additionalProperties: true
type: object
model:
example: Doubao Seed 2.0 Pro
type: string
moderation: {}
parallel_tool_calls:
example: true
type: boolean
previous_response_id:
example: resp_0123456789abcdef0123456789abcdef
type: string
prompt: {}
prompt_cache_key:
type: string
prompt_cache_options:
additionalProperties: true
type: object
prompt_cache_retention:
enum:
- in_memory
- 24h
type: string
reasoning:
additionalProperties: true
type: object
safety_identifier:
type: string
service_tier:
type: string
store:
type: boolean
stream:
example: false
type: boolean
stream_options:
additionalProperties: true
type: object
temperature:
example: 0.7
type: number
@ -603,9 +759,15 @@ definitions:
additionalProperties: true
type: object
type: array
top_logprobs:
type: integer
top_p:
example: 1
type: number
truncation:
type: string
user:
type: string
type: object
httpapi.RuntimePolicySetListResponse:
properties:
@ -718,12 +880,17 @@ definitions:
emotion:
example: happy
type: string
input:
example: Tell me a short story
type: string
input: {}
makeInstrumental:
example: false
type: boolean
max_completion_tokens:
description: MaxCompletionTokens includes visible output and reasoning tokens.
example: 512
type: integer
max_output_tokens:
example: 512
type: integer
max_tokens:
example: 512
type: integer
@ -744,7 +911,15 @@ definitions:
example: A watercolor robot reading a book
type: string
reasoning_effort:
description: ReasoningEffort 推理强度OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
description: ReasoningEffort 推理强度OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
enum:
- none
- minimal
- low
- medium
- high
- xhigh
- max
example: medium
type: string
resolution:
@ -5460,7 +5635,7 @@ paths:
name: input
required: true
schema:
$ref: '#/definitions/httpapi.TaskRequest'
$ref: '#/definitions/httpapi.ChatCompletionRequest'
produces:
- application/json
- text/event-stream
@ -6107,32 +6282,31 @@ paths:
post:
consumes:
- application/json
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible
路径同步返回兼容响应或 SSE 流。
description: 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用
Chat Completions 转换store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供
previous_response_id 时由调用方管理完整状态Gateway 以本轮 input/messages 为准且不追加本地历史。
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: AI 任务请求,字段随任务类型变化
- description: Responses 请求Chat 回退只支持自定义 function tools
in: body
name: input
required: true
schema:
$ref: '#/definitions/httpapi.TaskRequest'
$ref: '#/definitions/httpapi.ResponsesRequest'
produces:
- application/json
- text/event-stream
responses:
"200":
description: OK
headers:
X-Gateway-Task-Id:
description: 网关审计任务 ID
type: string
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
$ref: '#/definitions/httpapi.ResponsesCompatibleResponse'
"400":
description: Bad Request
description: invalid_previous_response_id / unsupported_response_tool /
unsupported_response_parameter
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
@ -6147,23 +6321,15 @@ paths:
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"429":
description: Too Many Requests
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"502":
description: Bad Gateway
"503":
description: response_chain_unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 创建或执行 AI 任务
summary: 创建 OpenAI Responses
tags:
- tasks
- responses
/api/v1/song/generations:
post:
consumes:
@ -7027,32 +7193,25 @@ paths:
post:
consumes:
- application/json
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible
路径同步返回兼容响应或 SSE 流
description: OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回
400 invalid_parameter
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: AI 任务请求,字段随任务类型变化
- description: Chat Completions 请求
in: body
name: input
required: true
schema:
$ref: '#/definitions/httpapi.TaskRequest'
$ref: '#/definitions/httpapi.ChatCompletionRequest'
produces:
- application/json
- text/event-stream
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
$ref: '#/definitions/httpapi.ChatCompletionCompatibleResponse'
"400":
description: Bad Request
description: invalid_parameter
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
@ -7067,10 +7226,6 @@ paths:
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"429":
description: Too Many Requests
schema:
@ -7081,9 +7236,9 @@ paths:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 创建或执行 AI 任务
summary: 创建 OpenAI Chat Completions
tags:
- tasks
- chat
/embeddings:
post:
consumes:
@ -7866,32 +8021,25 @@ paths:
post:
consumes:
- application/json
description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果OpenAI-compatible
路径同步返回兼容响应或 SSE 流
description: OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回
400 invalid_parameter
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: AI 任务请求,字段随任务类型变化
- description: Chat Completions 请求
in: body
name: input
required: true
schema:
$ref: '#/definitions/httpapi.TaskRequest'
$ref: '#/definitions/httpapi.ChatCompletionRequest'
produces:
- application/json
- text/event-stream
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
$ref: '#/definitions/httpapi.ChatCompletionCompatibleResponse'
"400":
description: Bad Request
description: invalid_parameter
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"401":
@ -7906,10 +8054,6 @@ paths:
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"429":
description: Too Many Requests
schema:
@ -7920,9 +8064,9 @@ paths:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 创建或执行 AI 任务
summary: 创建 OpenAI Chat Completions
tags:
- tasks
- chat
/v1/embeddings:
post:
consumes:

View File

@ -9,7 +9,7 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh"
const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max"
var (
openAIReasoningEfforts = map[string]struct{}{
@ -19,6 +19,7 @@ var (
"medium": {},
"high": {},
"xhigh": {},
"max": {},
}
volcesChatReasoningEfforts = map[string]struct{}{
"minimal": {},
@ -212,13 +213,16 @@ func isZhipuReasoningEffortModel(model string) bool {
}
func highMaxReasoningEffort(effort string) string {
if effort == "xhigh" {
if effort == "xhigh" || effort == "max" {
return "max"
}
return "high"
}
func zhipuReasoningEffort(effort string) string {
if effort == "max" {
return "xhigh"
}
if _, ok := zhipuReasoningEfforts[effort]; ok {
return effort
}
@ -229,7 +233,7 @@ func volcesChatReasoningEffort(effort string) string {
switch effort {
case "none":
return "minimal"
case "xhigh":
case "xhigh", "max":
return "high"
default:
if _, ok := volcesChatReasoningEfforts[effort]; ok {

View File

@ -0,0 +1,29 @@
package clients
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestCurrentOpenAIReasoningEffortMaxProviderMapping(t *testing.T) {
tests := []struct {
name string
candidate store.RuntimeModelCandidate
expected string
}{
{name: "generic", candidate: store.RuntimeModelCandidate{Provider: "openai"}, expected: "max"},
{name: "deepseek", candidate: store.RuntimeModelCandidate{Provider: "deepseek-openai"}, expected: "max"},
{name: "zhipu", candidate: store.RuntimeModelCandidate{Provider: "zhipu-openai", ProviderModelName: "glm-5.2"}, expected: "xhigh"},
{name: "volces", candidate: store.RuntimeModelCandidate{Provider: "volces-openai", ProviderModelName: "doubao-seed-2-0-pro-260215"}, expected: "high"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body := map[string]any{"model": test.candidate.ProviderModelName, "reasoning_effort": "max"}
applyOpenAIChatReasoningParams(body, test.candidate)
if body["reasoning_effort"] != test.expected {
t.Fatalf("expected reasoning_effort %q, got %#v", test.expected, body["reasoning_effort"])
}
})
}
}

View File

@ -2294,6 +2294,256 @@ func TestKelingClientVideoSubmitsAndPollsImageTask(t *testing.T) {
}
}
func TestKelingClient30TurboUsesModelEndpointAndTasksAPI(t *testing.T) {
var submitPath string
var pollPath string
var pollQuery string
var gotAuth string
var submittedPayload map[string]any
var submittedTaskPayload 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 /text-to-video/kling-3.0-turbo":
submitPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&submittedPayload); err != nil {
t.Fatalf("decode keling 3.0 turbo submit: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "req-turbo-submit",
"data": map[string]any{
"id": "turbo-task-1",
"status": "submitted",
},
})
case "GET /tasks":
pollPath = r.URL.Path
pollQuery = r.URL.RawQuery
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "req-turbo-poll",
"data": []any{
map[string]any{
"id": "turbo-task-1",
"status": "succeeded",
"create_time": 789,
"outputs": []any{
map[string]any{
"type": "video",
"url": "https://example.com/turbo.mp4",
"watermark_url": "https://example.com/turbo-watermark.mp4",
"duration": "8",
},
},
},
},
})
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: "video_generate",
Model: "可灵3.0 Turbo",
Body: map[string]any{
"prompt": "A cinematic city reveal",
"duration": 8,
"resolution": "1080p",
"aspect_ratio": "9:16",
"callback_url": "https://example.com/callback",
"external_task_id": "external-1",
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL + "/v1",
Provider: "keling",
AuthType: "APIKey",
ModelName: "可灵3.0 Turbo",
ProviderModelName: "kling-3.0-turbo",
Credentials: map[string]any{"apiKey": "kling-api-key"},
PlatformConfig: map[string]any{
"kelingPollIntervalMs": 100,
"kelingPollTimeoutSeconds": 1,
},
},
OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
if remoteTaskID != "turbo-task-1" {
t.Fatalf("unexpected remote task id: %s", remoteTaskID)
}
submittedTaskPayload = payload
return nil
},
})
if err != nil {
t.Fatalf("run keling 3.0 turbo video: %v", err)
}
if submitPath != "/text-to-video/kling-3.0-turbo" ||
pollPath != "/tasks" ||
pollQuery != "task_ids=turbo-task-1" ||
gotAuth != "Bearer kling-api-key" {
t.Fatalf("unexpected keling 3.0 turbo paths/auth submit=%s poll=%s?%s auth=%s", submitPath, pollPath, pollQuery, gotAuth)
}
if submittedTaskPayload["endpoint"] != "/text-to-video/kling-3.0-turbo" ||
submittedTaskPayload["taskApi"] != "keling_tasks_v2" {
t.Fatalf("unexpected submitted task payload: %+v", submittedTaskPayload)
}
settings, _ := submittedPayload["settings"].(map[string]any)
options, _ := submittedPayload["options"].(map[string]any)
if submittedPayload["prompt"] != "A cinematic city reveal" ||
numericValue(settings["duration"], 0) != 8 ||
settings["resolution"] != "1080p" ||
settings["aspect_ratio"] != "9:16" ||
options["callback_url"] != "https://example.com/callback" ||
options["external_task_id"] != "external-1" {
t.Fatalf("unexpected keling 3.0 turbo payload: %+v", submittedPayload)
}
data, _ := response.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if response.Result["upstream_task_id"] != "turbo-task-1" ||
item["url"] != "https://example.com/turbo.mp4" ||
item["watermark_url"] != "https://example.com/turbo-watermark.mp4" {
t.Fatalf("unexpected keling 3.0 turbo response: %+v", response.Result)
}
}
func TestKelingClient30TurboRejectsLegacyCredentials(t *testing.T) {
_, err := (KelingClient{}).Run(context.Background(), Request{
Kind: "videos.generations",
Body: map[string]any{"prompt": "A cinematic city reveal"},
Candidate: store.RuntimeModelCandidate{
Provider: "keling",
AuthType: "AccessKey-SecretKey",
ProviderModelName: "kling-3.0-turbo",
Credentials: map[string]any{"accessKey": "ak", "secretKey": "sk"},
},
})
if err == nil || !strings.Contains(err.Error(), "new API key") {
t.Fatalf("expected keling 3.0 turbo API key requirement, got %v", err)
}
}
func TestKelingClient30TurboResumePollsWithoutSubmitting(t *testing.T) {
var submitCalled bool
var pollQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /text-to-video/kling-3.0-turbo":
submitCalled = true
t.Fatalf("resume should not submit a new keling 3.0 turbo task")
case "GET /tasks":
pollQuery = r.URL.RawQuery
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "req-turbo-resume",
"data": []any{
map[string]any{
"id": "turbo-existing",
"status": "succeeded",
"outputs": []any{
map[string]any{"type": "video", "url": "https://example.com/resumed-turbo.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: "video_generate",
RemoteTaskID: "turbo-existing",
Body: map[string]any{},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL + "/v1",
Provider: "keling",
AuthType: "APIKey",
ProviderModelName: "kling-3.0-turbo",
Credentials: map[string]any{"apiKey": "kling-api-key"},
PlatformConfig: map[string]any{
"kelingPollIntervalMs": 100,
"kelingPollTimeoutSeconds": 1,
},
},
})
if err != nil {
t.Fatalf("resume keling 3.0 turbo video: %v", err)
}
if submitCalled || pollQuery != "task_ids=turbo-existing" {
t.Fatalf("resume should only poll existing task, submit=%v query=%s", submitCalled, pollQuery)
}
data, _ := response.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if item["url"] != "https://example.com/resumed-turbo.mp4" {
t.Fatalf("unexpected resumed keling 3.0 turbo response: %+v", response.Result)
}
}
func TestKeling30TurboPayloadBuildsFirstFrameAndMultiShotRequests(t *testing.T) {
imagePayload, endpoint, err := keling30TurboPayload(Request{
Body: map[string]any{
"duration": 5,
"resolution": "720p",
"content": []any{
map[string]any{"type": "text", "text": "The subject looks toward the camera"},
map[string]any{
"type": "image_url",
"role": "first_frame",
"image_url": map[string]any{"url": "https://example.com/first.png"},
},
},
},
})
if err != nil {
t.Fatalf("build keling 3.0 turbo image payload: %v", err)
}
if endpoint != "/image-to-video/kling-3.0-turbo" {
t.Fatalf("unexpected image endpoint: %s", endpoint)
}
if _, ok := mapFromAny(imagePayload["settings"])["aspect_ratio"]; ok {
t.Fatalf("image-to-video settings should not contain aspect_ratio: %+v", imagePayload)
}
contents, _ := imagePayload["contents"].([]any)
frame := mapFromAny(contents[1])
if frame["type"] != "first_frame" || frame["url"] != "https://example.com/first.png" {
t.Fatalf("unexpected image contents: %+v", imagePayload["contents"])
}
shotPayload, _, err := keling30TurboPayload(Request{
Body: map[string]any{
"resolution": "720p",
"content": []any{
map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 1, "duration": 4, "text": "A car enters the tunnel"},
map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 2, "duration": 3, "text": "The headlights fill the frame"},
},
},
})
if err != nil {
t.Fatalf("build keling 3.0 turbo shot payload: %v", err)
}
if shotPayload["prompt"] != "shot 1, 4s, A car enters the tunnel; shot 2, 3s, The headlights fill the frame;" ||
numericValue(mapFromAny(shotPayload["settings"])["duration"], 0) != 7 {
t.Fatalf("unexpected shot payload: %+v", shotPayload)
}
}
func TestKeling30TurboPayloadRejectsLastFrame(t *testing.T) {
_, _, err := keling30TurboPayload(Request{
Body: map[string]any{
"prompt": "Move forward",
"last_frame": "https://example.com/last.png",
},
})
if err == nil || !strings.Contains(err.Error(), "last frame") {
t.Fatalf("expected unsupported last frame error, got %v", err)
}
}
func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
Kind: "videos.generations",

View File

@ -9,9 +9,11 @@ import (
"io"
"math"
"net/http"
"net/url"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/golang-jwt/jwt/v5"
@ -32,14 +34,34 @@ 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)
token, err := kelingAuthTokenForRequest(request)
if err != nil {
return Response{}, err
}
return c.runVideo(ctx, request, token)
}
func kelingAuthTokenForRequest(request Request) (string, error) {
if kelingIs30TurboRequest(request) {
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
if apiKey == "" {
return "", &ClientError{
Code: "missing_credentials",
Message: "keling 3.0 turbo requires the new API key; legacy accessKey/secretKey credentials do not support new models",
Retryable: false,
StatusCode: http.StatusBadRequest,
}
}
return apiKey, nil
}
return kelingAuthToken(request.Candidate)
}
func (c KelingClient) runVideo(ctx context.Context, request Request, token string) (Response, error) {
if kelingIs30TurboRequest(request) {
return c.runTaskAPIVideo(ctx, request, token)
}
submitStartedAt := time.Now()
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
@ -143,6 +165,105 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
}
}
func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, token string) (Response, error) {
submitStartedAt := time.Now()
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
taskAPIBaseURL := kelingTaskAPIBaseURL(request.Candidate.BaseURL)
if upstreamTaskID == "" {
payload, endpoint, err := keling30TurboPayload(request)
if err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload)
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
}
upstreamTaskID = strings.TrimSpace(stringFromAny(kelingData(submitResult)["id"]))
if upstreamTaskID == "" {
return Response{}, &ClientError{Code: "invalid_response", Message: "keling 3.0 turbo task id is missing", RequestID: submitRequestID, Retryable: false}
}
if request.OnRemoteTaskSubmitted != nil {
if err := request.OnRemoteTaskSubmitted(upstreamTaskID, map[string]any{
"endpoint": endpoint,
"taskApi": "keling_tasks_v2",
"submit": submitResult,
}); err != nil {
return Response{}, err
}
}
}
interval := kelingPollInterval(request)
timeout := kelingPollTimeout(request)
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastStatus string
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.getJSONAt(
ctx,
request,
taskAPIBaseURL,
"/tasks?task_ids="+url.QueryEscape(upstreamTaskID),
token,
)
pollFinishedAt := time.Now()
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
if err != nil {
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
}
task := kelingTaskAPITask(pollResult, upstreamTaskID)
lastStatus = strings.ToLower(strings.TrimSpace(stringFromAny(task["status"])))
switch lastStatus {
case "succeeded", "succeed":
return Response{
Result: kelingTaskAPIVideoSuccessResult(request, upstreamTaskID, task, pollResult),
RequestID: requestID,
Progress: kelingVideoProgress(request, upstreamTaskID),
ResponseStartedAt: submitStartedAt,
ResponseFinishedAt: pollFinishedAt,
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
}, nil
case "failed":
return Response{}, &ClientError{
Code: "keling_task_failed",
Message: kelingTaskAPIErrorMessage(request.Candidate, task, 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 3.0 turbo task %s did not finish before timeout; last status: %s", upstreamTaskID, lastStatus),
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)
@ -400,8 +521,12 @@ func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request
}
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) {
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body)
}
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, 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))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
if err != nil {
return nil, "", err
}
@ -423,7 +548,11 @@ func (c KelingClient) postJSON(ctx context.Context, request Request, path string
}
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)
return c.getJSONAt(ctx, request, request.Candidate.BaseURL, path, token)
}
func (c KelingClient) getJSONAt(ctx context.Context, request Request, baseURL string, path string, token string) (map[string]any, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
if err != nil {
return nil, "", err
}
@ -560,6 +689,127 @@ func kelingIsOmniRequest(request Request) bool {
request.Candidate.Capabilities["omni"] != nil
}
func kelingIs30TurboRequest(request Request) bool {
switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) {
case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo":
return true
default:
return false
}
}
func kelingTaskAPIBaseURL(baseURL string) string {
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(strings.ToLower(trimmed), "/v1") {
return trimmed[:len(trimmed)-len("/v1")]
}
return trimmed
}
func keling30TurboPayload(request Request) (map[string]any, string, error) {
body := cleanProviderBody(request.Body)
content := contentItems(body["content"])
if len(content) == 0 {
content = buildVolcesContentFromBody(body)
}
shots := kelingShotPrompts(content)
if len(shots) > 6 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo supports at most 6 shots", StatusCode: 400, Retryable: false}
}
prompt := firstKelingPrompt(content)
duration := numericValue(body["duration"], 5)
if len(shots) > 0 {
var promptBuilder strings.Builder
duration = 0
for index, shot := range shots {
if shot.duration < 1 || math.Abs(shot.duration-math.Round(shot.duration)) > 1e-9 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo shot duration must be an integer of at least 1 second", StatusCode: 400, Retryable: false}
}
duration += shot.duration
fmt.Fprintf(&promptBuilder, "shot %d, %ds, %s; ", index+1, int(math.Round(shot.duration)), shot.text)
}
prompt = strings.TrimSpace(promptBuilder.String())
}
if prompt == "" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo video prompt is required", StatusCode: 400, Retryable: false}
}
if math.Abs(duration-math.Round(duration)) > 1e-9 || duration < 3 || duration > 15 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo duration must be an integer between 3 and 15 seconds", StatusCode: 400, Retryable: false}
}
firstFrame, lastFrame, referenceImages := kelingImageInputs(content)
if lastFrame != "" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports first frame only; last frame is not supported", StatusCode: 400, Retryable: false}
}
imageCount := len(referenceImages)
if firstFrame != "" {
imageCount++
}
if imageCount > 1 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports exactly one first-frame image", StatusCode: 400, Retryable: false}
}
if firstFrame == "" && len(referenceImages) == 1 {
firstFrame = referenceImages[0]
}
isImageToVideo := firstFrame != ""
resolution := strings.TrimSpace(firstNonEmptyStringValue(body, "resolution", "size"))
if resolution == "" {
resolution = "720p"
}
if resolution != "720p" && resolution != "1080p" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo resolution must be 720p or 1080p", StatusCode: 400, Retryable: false}
}
promptLimit := 3072
if isImageToVideo {
promptLimit = 2500
}
if utf8.RuneCountInString(prompt) > promptLimit {
return nil, "", &ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("keling 3.0 turbo prompt exceeds %d characters", promptLimit), StatusCode: 400, Retryable: false}
}
settings := map[string]any{
"duration": int(math.Round(duration)),
"resolution": resolution,
}
options := map[string]any{
"watermark_info": map[string]any{"enabled": boolValue(body, "watermark")},
}
if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" {
options["callback_url"] = callbackURL
}
if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" {
options["external_task_id"] = externalTaskID
}
if isImageToVideo {
return map[string]any{
"contents": []any{
map[string]any{"type": "prompt", "text": prompt},
map[string]any{"type": "first_frame", "url": firstFrame},
},
"settings": settings,
"options": options,
}, "/image-to-video/kling-3.0-turbo", nil
}
aspectRatio := strings.TrimSpace(firstNonEmptyStringValue(body, "aspect_ratio", "aspectRatio", "ratio"))
if aspectRatio == "" || aspectRatio == "adaptive" || aspectRatio == "keep_ratio" {
aspectRatio = "16:9"
}
if aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
}
settings["aspect_ratio"] = aspectRatio
return map[string]any{
"prompt": prompt,
"settings": settings,
"options": options,
}, "/text-to-video/kling-3.0-turbo", 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 {
@ -822,6 +1072,30 @@ func kelingTaskStatus(result map[string]any) string {
return strings.ToLower(strings.TrimSpace(stringFromAny(kelingData(result)["task_status"])))
}
func kelingTaskAPITask(result map[string]any, taskID string) map[string]any {
tasks := mapListFromAny(result["data"])
for _, task := range tasks {
if strings.TrimSpace(stringFromAny(task["id"])) == taskID {
return task
}
}
if len(tasks) > 0 {
return tasks[0]
}
return map[string]any{}
}
func kelingTaskAPIErrorMessage(candidate store.RuntimeModelCandidate, task map[string]any, result map[string]any) string {
message := strings.TrimSpace(stringFromAny(task["message"]))
if message == "" {
message = strings.TrimSpace(stringFromAny(result["message"]))
}
if message == "" {
message = "keling 3.0 turbo video task failed"
}
return fmt.Sprintf("Platform:%s,Code:%v,requestId:%s,message:%s", candidate.Provider, result["code"], stringFromAny(result["request_id"]), message)
}
func kelingTaskErrorCode(result map[string]any) string {
if code := intFromAny(result["code"]); code != 0 {
return fmt.Sprintf("keling_%d", code)
@ -887,6 +1161,42 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
}
}
func kelingTaskAPIVideoSuccessResult(request Request, upstreamTaskID string, task map[string]any, raw map[string]any) map[string]any {
outputs := mapListFromAny(task["outputs"])
items := make([]any, 0, len(outputs))
for _, output := range outputs {
if strings.ToLower(strings.TrimSpace(stringFromAny(output["type"]))) != "video" {
continue
}
videoURL := strings.TrimSpace(stringFromAny(output["url"]))
if videoURL == "" {
continue
}
item := map[string]any{"url": videoURL, "video_url": videoURL, "type": "video"}
if duration := numericValue(output["duration"], 0); duration > 0 {
item["duration"] = duration
}
if watermarkURL := strings.TrimSpace(stringFromAny(output["watermark_url"])); watermarkURL != "" {
item["watermark_url"] = watermarkURL
}
items = append(items, item)
}
created := intFromAny(task["create_time"])
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{

View File

@ -43,7 +43,14 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
if endpointKind == "chat.completions" {
body = NormalizeChatCompletionRequestBody(body)
applyOpenAIChatReasoningParams(body, request.Candidate)
body = FilterOpenAIChatRequestBody(body)
} else if request.Kind == "responses" {
body = FilterOpenAIResponsesRequestBody(body)
if _, hasInput := body["input"]; !hasInput {
if messages, hasMessages := request.Body["messages"]; hasMessages {
body["input"] = messages
}
}
delete(body, "messages")
if request.UpstreamPreviousResponseID != "" {
body["previous_response_id"] = request.UpstreamPreviousResponseID

View File

@ -0,0 +1,108 @@
package clients
import (
"fmt"
"net/http"
"sort"
)
// Keep these lists aligned with openai-node 6.47.0 and the public OpenAI API
// reference. The Gateway accepts a small, explicit set of routing extensions at
// ingress, but only protocol fields (plus controlled provider adaptations) are
// allowed across the upstream boundary.
var openAIChatRequestParameters = stringSet(
"messages", "model", "audio", "frequency_penalty", "function_call", "functions",
"logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "metadata",
"modalities", "moderation", "n", "parallel_tool_calls", "prediction",
"presence_penalty", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention",
"reasoning_effort", "response_format", "safety_identifier", "seed", "service_tier",
"stop", "store", "stream", "stream_options", "temperature", "tool_choice", "tools",
"top_logprobs", "top_p", "user", "verbosity", "web_search_options",
)
var openAIResponsesRequestParameters = stringSet(
"background", "context_management", "conversation", "include", "input", "instructions",
"max_output_tokens", "max_tool_calls", "metadata", "model", "moderation",
"parallel_tool_calls", "previous_response_id", "prompt", "prompt_cache_key",
"prompt_cache_options", "prompt_cache_retention", "reasoning", "safety_identifier",
"service_tier", "store", "stream", "stream_options", "temperature", "text",
"tool_choice", "tools", "top_logprobs", "top_p", "truncation", "user",
)
var gatewayOpenAIRequestExtensions = stringSet(
"runMode", "run_mode", "conversationId", "conversation_id", "sessionId", "session_id",
"requestId", "request_id", "signal", "userMessage", "user_message", "platformId",
"platform_id", "options", "enable_thinking", "thinking_budget_tokens", "enable_web_search",
"modelType", "model_type", "capability", "capabilityType", "mode", "simulation", "testMode",
)
var gatewayResponsesRequestExtensions = stringSet("messages", "presence_penalty", "frequency_penalty")
var controlledOpenAIChatProviderParameters = stringSet(
"enable_thinking", "thinking_budget", "thinking", "enable_web_search",
)
var controlledOpenAIResponsesProviderParameters = stringSet("presence_penalty", "frequency_penalty")
func ValidateOpenAIRequestParameters(kind string, body map[string]any) error {
allowed := openAIChatRequestParameters
if kind == "responses" {
allowed = openAIResponsesRequestParameters
}
unknown := make([]string, 0)
for key := range body {
if _, ok := allowed[key]; ok {
continue
}
if _, ok := gatewayOpenAIRequestExtensions[key]; ok {
continue
}
if kind == "responses" {
if _, ok := gatewayResponsesRequestExtensions[key]; ok {
continue
}
}
unknown = append(unknown, key)
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
return &ClientError{
Code: "invalid_parameter",
Message: fmt.Sprintf("Unknown parameter: %s", unknown[0]),
Param: unknown[0],
StatusCode: http.StatusBadRequest,
Retryable: false,
}
}
func FilterOpenAIChatRequestBody(body map[string]any) map[string]any {
return filterOpenAIRequestBody(body, openAIChatRequestParameters, controlledOpenAIChatProviderParameters)
}
func FilterOpenAIResponsesRequestBody(body map[string]any) map[string]any {
return filterOpenAIRequestBody(body, openAIResponsesRequestParameters, controlledOpenAIResponsesProviderParameters)
}
func filterOpenAIRequestBody(body map[string]any, allowed map[string]struct{}, extensions map[string]struct{}) map[string]any {
out := make(map[string]any, len(body))
for key, value := range body {
if _, ok := allowed[key]; ok {
out[key] = value
continue
}
if _, ok := extensions[key]; ok {
out[key] = value
}
}
return out
}
func stringSet(values ...string) map[string]struct{} {
out := make(map[string]struct{}, len(values))
for _, value := range values {
out[value] = struct{}{}
}
return out
}

View File

@ -0,0 +1,89 @@
package clients
import (
"strings"
"testing"
)
func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
body := map[string]any{}
for key := range openAIChatRequestParameters {
body[key] = "sentinel-" + key
}
body["conversationId"] = "internal"
body["unknown"] = "must-not-leak"
filtered := FilterOpenAIChatRequestBody(body)
for key := range openAIChatRequestParameters {
if _, ok := filtered[key]; !ok {
t.Fatalf("official Chat parameter %q was removed", key)
}
}
for _, key := range []string{"conversationId", "unknown"} {
if _, ok := filtered[key]; ok {
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
}
}
}
func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) {
body := map[string]any{}
for key := range openAIResponsesRequestParameters {
body[key] = "sentinel-" + key
}
body["request_id"] = "internal"
body["unknown"] = "must-not-leak"
filtered := FilterOpenAIResponsesRequestBody(body)
for key := range openAIResponsesRequestParameters {
if _, ok := filtered[key]; !ok {
t.Fatalf("official Responses parameter %q was removed", key)
}
}
for _, key := range []string{"request_id", "unknown"} {
if _, ok := filtered[key]; ok {
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
}
}
}
func TestValidateOpenAIRequestParametersRejectsUnknownTopLevelField(t *testing.T) {
err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "rogue": true})
if err == nil || ErrorCode(err) != "invalid_parameter" || !strings.Contains(err.Error(), "rogue") {
t.Fatalf("expected OpenAI-style invalid_parameter for rogue field, got %v", err)
}
if ErrorParam(err) != "rogue" {
t.Fatalf("expected rogue parameter attribution, got %q", ErrorParam(err))
}
if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "messages": []any{}, "request_id": "internal"}); err != nil {
t.Fatalf("expected controlled Responses extensions to remain accepted, got %v", err)
}
}
func TestResponsesFallbackMapsEquivalentCurrentParameters(t *testing.T) {
body, err := ResponsesRequestToChat(map[string]any{
"input": "hello", "store": false, "metadata": map[string]any{"trace": "1"},
"request_id": "internal-request", "platform_id": "internal-platform",
"moderation": map[string]any{"type": "auto"}, "prompt_cache_key": "cache-key",
"prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory",
"safety_identifier": "safe", "service_tier": "priority", "top_logprobs": 3,
"stream_options": map[string]any{"include_usage": true},
"text": map[string]any{"format": map[string]any{"type": "text"}, "verbosity": "low"},
}, nil)
if err != nil {
t.Fatalf("convert Responses request: %v", err)
}
for _, key := range []string{"store", "metadata", "moderation", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier", "top_logprobs", "stream_options", "verbosity"} {
if _, ok := body[key]; !ok {
t.Fatalf("equivalent parameter %q was not mapped", key)
}
}
if body["logprobs"] != true {
t.Fatalf("top_logprobs fallback must enable Chat logprobs: %+v", body)
}
for _, key := range []string{"request_id", "platform_id"} {
if _, ok := body[key]; ok {
t.Fatalf("internal Responses parameter %q leaked into Chat fallback: %+v", key, body)
}
}
}

View File

@ -21,11 +21,16 @@ var supportedResponseFallbackParameters = map[string]struct{}{
"model": {}, "input": {}, "messages": {}, "instructions": {}, "tools": {}, "tool_choice": {},
"parallel_tool_calls": {}, "max_output_tokens": {}, "temperature": {}, "top_p": {},
"presence_penalty": {}, "frequency_penalty": {}, "reasoning": {}, "text": {},
"stream": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {},
"stream": {}, "stream_options": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {},
"moderation": {}, "prompt_cache_key": {}, "prompt_cache_options": {}, "prompt_cache_retention": {},
"safety_identifier": {}, "service_tier": {}, "top_logprobs": {},
}
func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[string]any, error) {
for key := range body {
if _, internal := gatewayOpenAIRequestExtensions[key]; internal {
continue
}
if _, ok := supportedResponseFallbackParameters[key]; !ok {
return nil, unsupportedResponseParameter(key)
}
@ -61,11 +66,19 @@ func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[st
return nil, &ClientError{Code: "invalid_parameter", Message: "input is required", StatusCode: http.StatusBadRequest}
}
out := map[string]any{"messages": messages}
for _, key := range []string{"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls", "stream", "user"} {
for _, key := range []string{
"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls",
"stream", "stream_options", "store", "metadata", "user", "moderation", "prompt_cache_key",
"prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier",
} {
if value, ok := body[key]; ok {
out[key] = value
}
}
if value, ok := body["top_logprobs"]; ok {
out["top_logprobs"] = value
out["logprobs"] = true
}
if value, ok := body["max_output_tokens"]; ok {
out["max_tokens"] = value
}
@ -84,13 +97,16 @@ func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[st
}
}
if rawText, ok := body["text"]; ok {
responseFormat, err := responseTextFormat(rawText)
responseFormat, verbosity, err := responseTextParams(rawText)
if err != nil {
return nil, err
}
if responseFormat != nil {
out["response_format"] = responseFormat
}
if verbosity != nil {
out["verbosity"] = verbosity
}
}
if rawTools, ok := body["tools"]; ok {
tools, err := responseToolsToChat(rawTools)
@ -212,31 +228,32 @@ func responseToolChoiceToChat(value any) (any, error) {
return map[string]any{"type": "function", "function": map[string]any{"name": choice["name"]}}, nil
}
func responseTextFormat(value any) (map[string]any, error) {
func responseTextParams(value any) (map[string]any, any, error) {
text, ok := value.(map[string]any)
if !ok {
return nil, unsupportedResponseParameter("text")
return nil, nil, unsupportedResponseParameter("text")
}
for key := range text {
if key != "format" {
return nil, unsupportedResponseParameter("text." + key)
if key != "format" && key != "verbosity" {
return nil, nil, unsupportedResponseParameter("text." + key)
}
}
verbosity := text["verbosity"]
format, ok := text["format"].(map[string]any)
if !ok || len(format) == 0 {
return nil, nil
return nil, verbosity, nil
}
switch stringFromAny(format["type"]) {
case "text":
return map[string]any{"type": "text"}, nil
return map[string]any{"type": "text"}, verbosity, nil
case "json_object":
return map[string]any{"type": "json_object"}, nil
return map[string]any{"type": "json_object"}, verbosity, nil
case "json_schema":
return map[string]any{"type": "json_schema", "json_schema": map[string]any{
"name": format["name"], "schema": format["schema"], "strict": format["strict"],
}}, nil
}}, verbosity, nil
default:
return nil, unsupportedResponseParameter("text.format.type")
return nil, nil, unsupportedResponseParameter("text.format.type")
}
}

View File

@ -23,6 +23,10 @@ func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *test
if body["messages"] != nil {
t.Fatalf("native Responses request must not contain messages: %+v", body)
}
input, _ := body["input"].([]any)
if len(input) != 1 {
t.Fatalf("native Responses request must translate controlled messages to input: %+v", body)
}
if body["previous_response_id"] != "resp_upstream_parent" {
t.Fatalf("expected translated upstream previous id, got %+v", body["previous_response_id"])
}
@ -41,7 +45,7 @@ func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *test
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo",
Body: map[string]any{"input": "hello", "messages": []any{map[string]any{"role": "user", "content": "illegal"}}},
Body: map[string]any{"messages": []any{map[string]any{"role": "user", "content": "hello"}}},
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
PublicPreviousResponseID: "resp_abcdefghijklmnopqrstuvwxyz123456", UpstreamPreviousResponseID: "resp_upstream_parent",

View File

@ -103,6 +103,7 @@ type VoiceCloneDeleter interface {
type ClientError struct {
Code string
Message string
Param string
StatusCode int
RequestID string
ResponseStartedAt time.Time
@ -111,6 +112,14 @@ type ClientError struct {
Retryable bool
}
func ErrorParam(err error) string {
var clientErr *ClientError
if errors.As(err, &clientErr) {
return clientErr.Param
}
return ""
}
func (e *ClientError) Error() string {
if e.Message != "" {
return e.Message

View File

@ -74,12 +74,13 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
submitStartedAt := time.Now()
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
taskPath := volcesVideoTaskPath(request)
if upstreamTaskID == "" {
body := volcesVideoBody(request)
if err := validateVolcesVideoTaskBody(body); err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, taskPath, apiKey, body)
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
@ -112,7 +113,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
pollStartedAt := time.Now()
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey)
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
pollFinishedAt := time.Now()
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
if err != nil {
@ -159,6 +160,21 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
}
func volcesVideoTaskPath(request Request) string {
path := firstNonEmptyStringValue(
request.Candidate.PlatformConfig,
"volcesVideoTaskPath",
"videoTaskPath",
)
if path == "" {
return "/contents/generations/tasks"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return strings.TrimRight(path, "/")
}
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
@ -173,7 +189,11 @@ func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL str
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
return result, requestID, err
if err != nil {
return result, requestID, err
}
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
}
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) {
@ -188,7 +208,64 @@ func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL stri
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
return result, requestID, err
if err != nil {
return result, requestID, err
}
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
}
func normalizeVolcesCompatibleResult(result map[string]any) (map[string]any, string, error) {
requestID := firstNonEmpty(
stringFromAny(result["request_id"]),
stringFromAny(result["requestId"]),
)
if errorObject, ok := result["error"].(map[string]any); ok {
code := firstNonEmpty(
stringFromAny(errorObject["code"]),
stringFromAny(errorObject["type"]),
"volces_compatible_error",
)
message := strings.TrimSpace(stringFromAny(errorObject["message"]))
if message == "" {
message = "volces compatible request failed"
}
return result, requestID, &ClientError{
Code: code,
Message: message,
RequestID: requestID,
Retryable: false,
}
}
rawCode, hasCode := result["code"]
if !hasCode {
return result, requestID, nil
}
code, validCode := volcesIntFromAny(rawCode)
if !validCode {
return result, requestID, nil
}
if code != 0 {
message := strings.TrimSpace(stringFromAny(result["message"]))
if message == "" {
message = fmt.Sprintf("volces compatible request failed with code %d", code)
}
return result, requestID, &ClientError{
Code: fmt.Sprintf("volces_%d", code),
Message: message,
RequestID: requestID,
Retryable: false,
}
}
data, ok := result["data"].(map[string]any)
if !ok {
return result, requestID, nil
}
normalized := cloneBody(data)
if requestID != "" && requestIDFromResult(normalized) == "" {
normalized["request_id"] = requestID
}
return normalized, requestID, nil
}
func volcesImageBody(request Request) map[string]any {

View File

@ -0,0 +1,129 @@
package clients
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestVolcesClientSupportsDeyunEnvelope(t *testing.T) {
var submitted bool
var polled bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer deyun-secret" {
t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization"))
}
switch r.Method + " " + r.URL.Path {
case "POST /c39/api/v3/video/tasks":
submitted = true
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "deyun-submit-request",
"data": map[string]any{"id": "deyun-task-1"},
})
case "GET /c39/api/v3/video/tasks/deyun-task-1":
polled = true
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "deyun-poll-request",
"data": map[string]any{
"id": "deyun-task-1",
"status": "succeeded",
"created_at": 123,
"content": map[string]any{
"video_url": "https://example.com/deyun.mp4",
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "videos.generations",
ModelType: "video_generate",
Model: "deyun-seedance-2.0-canary",
Body: map[string]any{
"prompt": "A red cube rotates on a white table",
"resolution": "480p",
"ratio": "16:9",
"duration": 4,
"generate_audio": false,
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL + "/c39/api/v3",
ProviderModelName: "doubao-seedance-2-0",
Credentials: map[string]any{"apiKey": "deyun-secret"},
PlatformConfig: map[string]any{
"volcesPollIntervalMs": 100,
"volcesPollTimeoutSeconds": 1,
"volcesVideoTaskPath": "/video/tasks",
},
},
})
if err != nil {
t.Fatalf("run deyun-compatible video task: %v", err)
}
if !submitted || !polled {
t.Fatalf("expected submit and poll, submitted=%v polled=%v", submitted, polled)
}
if response.RequestID != "deyun-poll-request" {
t.Fatalf("unexpected request id: %s", response.RequestID)
}
data, _ := response.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if response.Result["upstream_task_id"] != "deyun-task-1" || item["url"] != "https://example.com/deyun.mp4" {
t.Fatalf("unexpected response: %+v", response.Result)
}
}
func TestNormalizeVolcesCompatibleResultPreservesNativeResponse(t *testing.T) {
native := map[string]any{"id": "native-task", "status": "queued"}
got, requestID, err := normalizeVolcesCompatibleResult(native)
if err != nil {
t.Fatalf("normalize native response: %v", err)
}
if got["id"] != "native-task" || requestID != "" {
t.Fatalf("native response changed unexpectedly: %+v requestID=%q", got, requestID)
}
}
func TestNormalizeVolcesCompatibleResultRejectsBusinessError(t *testing.T) {
_, requestID, err := normalizeVolcesCompatibleResult(map[string]any{
"code": 1004,
"message": "Authorization is expired",
"request_id": "deyun-error-request",
})
if err == nil {
t.Fatal("expected business error")
}
if requestID != "deyun-error-request" || ErrorCode(err) != "volces_1004" {
t.Fatalf("unexpected error metadata requestID=%q code=%q err=%v", requestID, ErrorCode(err), err)
}
if !strings.Contains(err.Error(), "Authorization is expired") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestNormalizeVolcesCompatibleResultRejectsHTTP200ErrorObject(t *testing.T) {
_, _, err := normalizeVolcesCompatibleResult(map[string]any{
"error": map[string]any{
"code": "ModelNotOpen",
"message": "model service is not activated",
"type": "Not Found",
},
})
if err == nil {
t.Fatal("expected HTTP 200 error object to fail")
}
if ErrorCode(err) != "ModelNotOpen" || !strings.Contains(err.Error(), "not activated") {
t.Fatalf("unexpected error: code=%q err=%v", ErrorCode(err), err)
}
}

View File

@ -142,7 +142,7 @@ func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.
executor := &fakeTaskExecutor{
runErr: &clients.ClientError{
Code: "invalid_parameter",
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh",
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max",
Retryable: false,
},
}
@ -157,6 +157,9 @@ func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.
if !strings.Contains(recorder.Body.String(), "invalid_parameter") {
t.Fatalf("response should include invalid_parameter code: %s", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"type":"invalid_request_error"`) || !strings.Contains(recorder.Body.String(), `"param":null`) {
t.Fatalf("response should use OpenAI error fields: %s", recorder.Body.String())
}
}
func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {

View File

@ -416,6 +416,10 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
}
model, err := s.store.CreatePlatformModel(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "base model not found")
return
@ -460,6 +464,10 @@ func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
models, err := s.store.ReplacePlatformModels(r.Context(), platformID, input.Models)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "base model not found")
return
@ -966,7 +974,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/responses [post]
// @Router /api/v1/embeddings [post]
// @Router /api/v1/reranks [post]
// @Router /api/v1/images/generations [post]
@ -976,8 +983,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Router /api/v1/music/generations [post]
// @Router /api/v1/speech/generations [post]
// @Router /api/v1/voice_clone [post]
// @Router /chat/completions [post]
// @Router /v1/chat/completions [post]
// @Router /embeddings [post]
// @Router /v1/embeddings [post]
// @Router /reranks [post]
@ -1011,6 +1016,12 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeError(w, status, err.Error(), clients.ErrorCode(err))
return
}
if kind == "chat.completions" || kind == "responses" {
if err := clients.ValidateOpenAIRequestParameters(kind, body); err != nil {
writeErrorWithDetails(w, http.StatusBadRequest, err.Error(), map[string]any{"param": clients.ErrorParam(err)}, clients.ErrorCode(err))
return
}
}
model := requestModelName(body)
if model == "" {
writeError(w, http.StatusBadRequest, "model is required")
@ -1082,7 +1093,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
// @Produce text/event-stream
// @Security BearerAuth
// @Param X-Async header bool false "该接口忽略此参数"
// @Param input body TaskRequest true "Chat Completions 请求"
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
@ -1096,6 +1107,26 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
return s.createTask("chat.completions", false)
}
// openAIChatCompletionsDoc godoc
// @Summary 创建 OpenAI Chat Completions
// @Description OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。
// @Tags chat
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Security BearerAuth
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope "invalid_parameter"
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /chat/completions [post]
// @Router /v1/chat/completions [post]
func openAIChatCompletionsDoc() {}
// openAIResponsesDoc godoc
// @Summary 创建 OpenAI Responses
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态Gateway 以本轮 input/messages 为准且不追加本地历史。
@ -1114,6 +1145,7 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
// @Router /responses [post]
// @Router /v1/responses [post]
// @Router /api/v1/responses [post]
func openAIResponsesDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
@ -1344,6 +1376,10 @@ func statusFromRunError(err error) int {
return http.StatusServiceUnavailable
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
return http.StatusBadRequest
case store.ModelCandidateErrorCode(err) == "invalid_parameter":
return http.StatusBadRequest
case store.ModelCandidateErrorCode(err) == "model_capability_configuration_error":
return http.StatusInternalServerError
case clients.ErrorCode(err) == "cloned_voice_not_found":
return http.StatusNotFound
case store.ModelCandidateErrorCode(err) == "platform_cooling_down" || store.ModelCandidateErrorCode(err) == "model_cooling_down":

View File

@ -0,0 +1,194 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestKeling30TurboSimulationFlow(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling simulation integration flow")
}
ctx := context.Background()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
serverCtx, cancelServer := context.WithCancel(ctx)
defer cancelServer()
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "test-secret",
CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
username := "kling_sim_" + suffix
password := "password123"
var registerResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
"username": username,
"email": username + "@example.com",
"password": password,
}, http.StatusCreated, &registerResponse)
var apiKeyResponse struct {
Secret string `json:"secret"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
"name": "kling simulation key",
}, http.StatusCreated, &apiKeyResponse)
if _, err := db.Pool().Exec(ctx, `
UPDATE gateway_users
SET roles = '["admin"]'::jsonb
WHERE username = $1`, username); err != nil {
t.Fatalf("promote Kling simulation user: %v", err)
}
var loginResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username,
"password": password,
}, http.StatusOK, &loginResponse)
var gatewayUserID string
if err := db.Pool().QueryRow(
ctx,
`SELECT id::text FROM gateway_users WHERE username = $1`,
username,
).Scan(&gatewayUserID); err != nil {
t.Fatalf("read Kling simulation user id: %v", err)
}
doJSON(t, server.URL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loginResponse.AccessToken, map[string]any{
"currency": "resource",
"balance": 1000,
"reason": "seed Kling simulation wallet",
}, http.StatusOK, nil)
var platform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "keling",
"platformKey": "keling-simulation-" + suffix,
"name": "Kling Simulation",
"baseUrl": "https://api-beijing.klingai.com/v1",
"authType": "AccessKey-SecretKey",
"credentials": map[string]any{
"accessKey": "legacy-ak",
"secretKey": "legacy-sk",
},
}, http.StatusCreated, &platform)
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "keling:kling-3.0-turbo",
"modelName": "kling-3.0-turbo",
"providerModelName": "kling-3.0-turbo",
"modelAlias": "可灵3.0 Turbo",
"modelType": []string{"video_generate", "image_to_video"},
"displayName": "可灵3.0 Turbo",
}, http.StatusCreated, nil)
assertKeling30TurboSimulationTask := func(
name string,
request map[string]any,
expectedModelType string,
) {
t.Helper()
t.Run(name, func(t *testing.T) {
var response struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
RunMode string `json:"runMode"`
ModelType string `json:"modelType"`
ResolvedModel string `json:"resolvedModel"`
Result map[string]any `json:"result"`
Metrics map[string]any `json:"metrics"`
BillingSummary map[string]any `json:"billingSummary"`
FinalChargeAmount float64 `json:"finalChargeAmount"`
ResponseDurationMS int64 `json:"responseDurationMs"`
} `json:"task"`
}
doJSON(
t,
server.URL,
http.MethodPost,
"/api/v1/videos/generations",
apiKeyResponse.Secret,
request,
http.StatusAccepted,
&response,
)
task := response.Task
if task.ID == "" ||
task.Status != "succeeded" ||
task.RunMode != "simulation" ||
task.ModelType != expectedModelType ||
task.ResolvedModel != "kling-3.0-turbo" {
t.Fatalf("unexpected Kling simulation task: %+v", task)
}
data, _ := task.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if item["video_url"] != "/static/simulation/video.mp4" ||
item["assetSource"] != "simulation" {
t.Fatalf("unexpected Kling simulation result: %+v", task.Result)
}
if task.FinalChargeAmount <= 0 ||
task.BillingSummary["finalCharge"] == nil ||
task.Metrics["parameterPreprocessingSummary"] == nil ||
task.ResponseDurationMS <= 0 {
t.Fatalf("Kling simulation should preserve billing, preprocessing and timing: %+v", task)
}
})
}
assertKeling30TurboSimulationTask("text-to-video", map[string]any{
"model": "可灵3.0 Turbo",
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "A cinematic city reveal",
"duration": 8,
"resolution": "1080p",
"aspect_ratio": "9:16",
"audio": true,
}, "video_generate")
assertKeling30TurboSimulationTask("image-to-video", map[string]any{
"model": "可灵3.0 Turbo",
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "The subject looks toward the camera",
"image": "https://example.com/first.png",
"duration": 5,
"resolution": "720p",
"audio": true,
}, "image_to_video")
}

View File

@ -35,6 +35,8 @@ type ErrorPayload struct {
Message string `json:"message" example:"invalid json body"`
Status int `json:"status" example:"400"`
Code string `json:"code,omitempty" example:"rate_limit"`
Type string `json:"type,omitempty" example:"invalid_request_error"`
Param any `json:"param,omitempty"`
}
type AuthResponse struct {
@ -193,16 +195,19 @@ type PricingEstimateResponse struct {
type TaskRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages,omitempty"`
Input string `json:"input,omitempty" example:"Tell me a short story"`
Input interface{} `json:"input,omitempty"`
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
TextFileID string `json:"text_file_id,omitempty" example:""`
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
Stream bool `json:"stream,omitempty" example:"false"`
Stream *bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
// MaxCompletionTokens includes visible output and reasoning tokens.
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
Size string `json:"size,omitempty" example:"1024x1024"`
Duration int `json:"duration,omitempty" example:"5"`
Resolution string `json:"resolution,omitempty" example:"720p"`
@ -223,36 +228,88 @@ type TaskRequest struct {
}
type ChatCompletionRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
Stream bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages"`
Audio map[string]interface{} `json:"audio,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" example:"0"`
FunctionCall interface{} `json:"function_call,omitempty"`
Functions []map[string]interface{} `json:"functions,omitempty"`
LogitBias map[string]interface{} `json:"logit_bias,omitempty"`
Logprobs *bool `json:"logprobs,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Modalities []string `json:"modalities,omitempty"`
Moderation interface{} `json:"moderation,omitempty"`
N *int `json:"n,omitempty" example:"1"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
Prediction interface{} `json:"prediction,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty" example:"0"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
// ReasoningEffort 推理强度OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
ResponseFormat interface{} `json:"response_format,omitempty"`
SafetyIdentifier string `json:"safety_identifier,omitempty"`
Seed *int `json:"seed,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Stop interface{} `json:"stop,omitempty"`
Store *bool `json:"store,omitempty"`
Stream *bool `json:"stream,omitempty" example:"false"`
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Tools []map[string]interface{} `json:"tools,omitempty"`
TopLogprobs *int `json:"top_logprobs,omitempty"`
TopP *float64 `json:"top_p,omitempty" example:"1"`
User string `json:"user,omitempty"`
Verbosity string `json:"verbosity,omitempty"`
WebSearchOptions map[string]interface{} `json:"web_search_options,omitempty"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
}
type ChatMessage struct {
Role string `json:"role" example:"user"`
Content string `json:"content" example:"Hello"`
Role string `json:"role" example:"user"`
Content interface{} `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls interface{} `json:"tool_calls,omitempty"`
FunctionCall interface{} `json:"function_call,omitempty"`
}
type ResponsesRequest struct {
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Input interface{} `json:"input"`
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
Tools []map[string]interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty" example:"true"`
MaxOutputTokens int `json:"max_output_tokens,omitempty" example:"512"`
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
Text map[string]interface{} `json:"text,omitempty"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
TopP float64 `json:"top_p,omitempty" example:"1"`
Store *bool `json:"store,omitempty"`
Stream bool `json:"stream,omitempty" example:"false"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Background *bool `json:"background,omitempty"`
ContextManagement []map[string]interface{} `json:"context_management,omitempty"`
Conversation interface{} `json:"conversation,omitempty"`
Include []string `json:"include,omitempty"`
Input interface{} `json:"input"`
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Moderation interface{} `json:"moderation,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" example:"true"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
Prompt interface{} `json:"prompt,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
SafetyIdentifier string `json:"safety_identifier,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store *bool `json:"store,omitempty"`
Stream *bool `json:"stream,omitempty" example:"false"`
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
Text map[string]interface{} `json:"text,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Tools []map[string]interface{} `json:"tools,omitempty"`
TopLogprobs *int `json:"top_logprobs,omitempty"`
TopP *float64 `json:"top_p,omitempty" example:"1"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
}
type ResponsesCompatibleResponse struct {

View File

@ -25,6 +25,12 @@ func writeErrorWithDetails(w http.ResponseWriter, status int, message string, de
if len(codes) > 0 {
if code := strings.TrimSpace(codes[0]); code != "" {
errorPayload["code"] = code
if code == "invalid_parameter" || code == "unsupported_response_parameter" {
errorPayload["type"] = "invalid_request_error"
if _, ok := details["param"]; !ok {
errorPayload["param"] = nil
}
}
}
}
for key, value := range details {

View File

@ -0,0 +1,232 @@
package runner
import (
"fmt"
"math"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
volcesOutputTokenThreshold = 10240
volcesOutputCapabilityErrorCode = "model_capability_configuration_error"
volcesOutputInvalidParameterCode = "invalid_parameter"
)
type outputTokenLimitProcessor struct{}
func (outputTokenLimitProcessor) Name() string { return "OutputTokenLimitProcessor" }
func (outputTokenLimitProcessor) ShouldProcess(_ map[string]any, modelType string, context *paramProcessContext) bool {
return context != nil && isOpenAITextGenerationKind(context.kind) && isTextOutputModelType(modelType) && isVolcesCandidate(context.candidate)
}
func (outputTokenLimitProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
modelMax, sourceType, capabilityValue, ok := candidateMaxOutputTokens(context.candidate, modelType)
path := capabilityPath(sourceType, "max_output_tokens")
if !ok {
return context.reject(
"OutputTokenLimitProcessor",
outputTokenParameter(context.kind),
nil,
"火山引擎文本候选未配置正整数 max_output_tokens已禁止执行该候选。",
path,
capabilityValue,
)
}
if context.kind == "chat.completions" {
if value, explicit := nonNullParameter(params, "max_tokens"); explicit {
if parsed, valid := positiveInteger(value); !valid || parsed > modelMax {
return context.reject("OutputTokenLimitProcessor", "max_tokens", value, fmt.Sprintf("max_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue)
}
return true
}
if _, explicit := nonNullParameter(params, "max_completion_tokens"); explicit {
return true
}
value := defaultVolcesOutputTokens(modelMax)
params["max_tokens"] = value
context.recordChange(
"OutputTokenLimitProcessor", "set", "max_tokens", nil, value,
fmt.Sprintf("火山候选未显式设置输出上限floor(%d/3)=%d阈值=%d注入候选级默认值。", modelMax, modelMax/3, volcesOutputTokenThreshold),
path, capabilityValue,
)
return true
}
if value, explicit := nonNullParameter(params, "max_output_tokens"); explicit {
if parsed, valid := positiveInteger(value); !valid || parsed > modelMax {
return context.reject("OutputTokenLimitProcessor", "max_output_tokens", value, fmt.Sprintf("max_output_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue)
}
return true
}
value := defaultVolcesOutputTokens(modelMax)
params["max_output_tokens"] = value
context.recordChange(
"OutputTokenLimitProcessor", "set", "max_output_tokens", nil, value,
fmt.Sprintf("火山候选未显式设置输出上限floor(%d/3)=%d阈值=%d注入候选级默认值。", modelMax, modelMax/3, volcesOutputTokenThreshold),
path, capabilityValue,
)
return true
}
func filterRuntimeCandidatesByOutputTokens(kind string, requestedModel string, modelType string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]store.RuntimeModelCandidate, map[string]any, error) {
if !isOpenAITextGenerationKind(kind) || !isTextOutputModelType(modelType) || len(candidates) == 0 {
return candidates, nil, nil
}
filtered := make([]store.RuntimeModelCandidate, 0, len(candidates))
rejected := make([]map[string]any, 0)
invalidExplicit := false
for _, candidate := range candidates {
if !isVolcesCandidate(candidate) {
filtered = append(filtered, candidate)
continue
}
modelMax, sourceType, raw, configured := candidateMaxOutputTokens(candidate, modelType)
detail := map[string]any{
"platformId": candidate.PlatformID, "platformKey": candidate.PlatformKey, "provider": candidate.Provider,
"platformModelId": candidate.PlatformModelID, "providerModelName": candidate.ProviderModelName,
"modelType": modelType, "capabilityPath": capabilityPath(sourceType, "max_output_tokens"), "capabilityValue": raw,
}
if !configured {
detail["reason"] = "max_output_tokens_missing"
rejected = append(rejected, detail)
continue
}
if parameter, value, explicit := explicitOutputTokenParameter(kind, body); explicit {
parsed, valid := positiveInteger(value)
if !valid || (parameter != "max_completion_tokens" && parsed > modelMax) {
detail["reason"] = "requested_output_tokens_exceed_capability"
detail["parameter"] = parameter
detail["requestedValue"] = value
invalidExplicit = true
rejected = append(rejected, detail)
continue
}
}
filtered = append(filtered, candidate)
}
if len(rejected) == 0 {
return filtered, nil, nil
}
summary := map[string]any{
"filter": "volces_output_token_limit", "kind": kind, "requestedModel": requestedModel, "modelType": modelType,
"candidateCount": len(candidates), "supportedCandidateCount": len(filtered), "filteredCandidateCount": len(rejected),
"threshold": volcesOutputTokenThreshold, "formula": "third=floor(modelMaxOutputTokens/3); default=third>=10240?third:modelMaxOutputTokens",
"rejectedCandidates": rejected,
}
if len(filtered) > 0 {
return filtered, summary, nil
}
code := volcesOutputCapabilityErrorCode
message := "所有火山引擎文本候选都缺少有效的 max_output_tokens 能力配置"
if invalidExplicit {
code = volcesOutputInvalidParameterCode
message = "请求的输出 token 上限超过所有可用火山引擎候选的模型能力"
}
summary["code"] = code
return nil, summary, &store.ModelCandidateUnavailableError{Code: code, Message: message, Details: summary}
}
func defaultVolcesOutputTokens(modelMax int) int {
third := modelMax / 3
if third >= volcesOutputTokenThreshold {
return third
}
return modelMax
}
func isVolcesCandidate(candidate store.RuntimeModelCandidate) bool {
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
baseURL := strings.ToLower(strings.TrimSpace(candidate.BaseURL))
return provider == "volces-openai" || strings.Contains(baseURL, "volces.com") || strings.Contains(baseURL, "byteplus.com")
}
func candidateMaxOutputTokens(candidate store.RuntimeModelCandidate, modelType string) (int, string, any, bool) {
capabilities := effectiveModelCapability(candidate)
seen := map[string]struct{}{}
for _, candidateType := range []string{candidate.ModelType, modelType, "text_generate"} {
candidateType = strings.TrimSpace(candidateType)
if candidateType == "" {
continue
}
if _, ok := seen[candidateType]; ok {
continue
}
seen[candidateType] = struct{}{}
capability := capabilityForType(capabilities, candidateType)
if capability == nil {
continue
}
raw, exists := capability["max_output_tokens"]
if !exists {
continue
}
value, ok := positiveInteger(raw)
return value, candidateType, raw, ok
}
return 0, firstNonEmptyString(candidate.ModelType, modelType, "text_generate"), nil, false
}
func positiveInteger(value any) (int, bool) {
number := floatFromAny(value)
if number <= 0 || math.Trunc(number) != number || number > float64(math.MaxInt) {
return 0, false
}
return int(number), true
}
func nonNullParameter(body map[string]any, key string) (any, bool) {
value, ok := body[key]
return value, ok && value != nil
}
func explicitOutputTokenParameter(kind string, body map[string]any) (string, any, bool) {
if kind == "responses" {
value, ok := nonNullParameter(body, "max_output_tokens")
return "max_output_tokens", value, ok
}
if value, ok := nonNullParameter(body, "max_tokens"); ok {
return "max_tokens", value, true
}
value, ok := nonNullParameter(body, "max_completion_tokens")
return "max_completion_tokens", value, ok
}
func outputTokenParameter(kind string) string {
if kind == "responses" {
return "max_output_tokens"
}
return "max_tokens"
}
func isOpenAITextGenerationKind(kind string) bool {
return kind == "chat.completions" || kind == "responses"
}
func isTextOutputModelType(modelType string) bool {
switch strings.TrimSpace(modelType) {
case "", "text_generate", "chat", "responses", "text":
return true
default:
return false
}
}
func mergeCandidateFilterSummaries(summaries ...map[string]any) map[string]any {
nonEmpty := make([]any, 0, len(summaries))
for _, summary := range summaries {
if len(summary) > 0 {
nonEmpty = append(nonEmpty, summary)
}
}
if len(nonEmpty) == 0 {
return nil
}
if len(nonEmpty) == 1 {
return nonEmpty[0].(map[string]any)
}
return map[string]any{"filters": nonEmpty}
}

View File

@ -0,0 +1,113 @@
package runner
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func volcTextCandidate(maxOutput any) store.RuntimeModelCandidate {
capability := map[string]any{}
if maxOutput != nil {
capability["max_output_tokens"] = maxOutput
}
return store.RuntimeModelCandidate{
Provider: "volces-openai", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
ModelType: "text_generate", ProviderModelName: "demo",
Capabilities: map[string]any{"text_generate": capability},
}
}
func TestDefaultVolcesOutputTokens(t *testing.T) {
tests := []struct {
name string
max int
want int
}{
{name: "128K", max: 131072, want: 43690},
{name: "32K", max: 32768, want: 10922},
{name: "24K below threshold", max: 24576, want: 24576},
{name: "threshold exact", max: 30720, want: 10240},
{name: "threshold minus one", max: 30719, want: 30719},
{name: "floor", max: 131071, want: 43690},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := defaultVolcesOutputTokens(test.max); got != test.want {
t.Fatalf("defaultVolcesOutputTokens(%d)=%d, want %d", test.max, got, test.want)
}
})
}
}
func TestVolcesOutputProcessorInjectsCandidateSpecificDefaults(t *testing.T) {
chat := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}, "max_tokens": nil}, volcTextCandidate(131072))
if chat.Err != nil || chat.Body["max_tokens"] != 43690 {
t.Fatalf("unexpected Chat preprocessing: body=%+v err=%v", chat.Body, chat.Err)
}
if len(chat.Log.Changes) != 1 || chat.Log.Changes[0].CapabilityPath != "capabilities.text_generate.max_output_tokens" {
t.Fatalf("expected auditable capability source, got %+v", chat.Log.Changes)
}
responses := preprocessRequestWithLog("responses", map[string]any{"input": "hello", "max_output_tokens": nil}, volcTextCandidate(24576))
if responses.Err != nil || responses.Body["max_output_tokens"] != 24576 {
t.Fatalf("unexpected Responses preprocessing: body=%+v err=%v", responses.Body, responses.Err)
}
}
func TestVolcesOutputProcessorPreservesExplicitLimits(t *testing.T) {
for _, body := range []map[string]any{
{"messages": []any{}, "max_tokens": 1234},
{"messages": []any{}, "max_completion_tokens": 2345},
{"messages": []any{}, "max_tokens": 1234, "max_completion_tokens": 2345},
} {
result := preprocessRequestWithLog("chat.completions", body, volcTextCandidate(32768))
if result.Err != nil || len(result.Log.Changes) != 0 {
t.Fatalf("explicit Chat limit should remain unchanged: body=%+v err=%v changes=%+v", result.Body, result.Err, result.Log.Changes)
}
}
result := preprocessRequestWithLog("responses", map[string]any{"input": "hello", "max_output_tokens": 3456}, volcTextCandidate(32768))
if result.Err != nil || result.Body["max_output_tokens"] != 3456 || len(result.Log.Changes) != 0 {
t.Fatalf("explicit Responses limit should remain unchanged: %+v", result)
}
}
func TestVolcesOutputCandidateFilterSupportsFailover(t *testing.T) {
missing := volcTextCandidate(nil)
missing.PlatformID = "missing"
valid := volcTextCandidate(32768)
valid.PlatformID = "valid"
filtered, summary, err := filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}}, []store.RuntimeModelCandidate{missing, valid})
if err != nil || len(filtered) != 1 || filtered[0].PlatformID != "valid" {
t.Fatalf("expected missing capability candidate to be skipped: filtered=%+v summary=%+v err=%v", filtered, summary, err)
}
result := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, filtered[0])
if result.Body["max_tokens"] != 10922 {
t.Fatalf("failover candidate must recalculate its own default, got %+v", result.Body)
}
}
func TestVolcesOutputCandidateFilterRejectsMissingAndExceededCapabilities(t *testing.T) {
_, _, err := filterRuntimeCandidatesByOutputTokens("responses", "demo", "text_generate", map[string]any{"input": "hello"}, []store.RuntimeModelCandidate{volcTextCandidate(nil)})
if store.ModelCandidateErrorCode(err) != volcesOutputCapabilityErrorCode {
t.Fatalf("expected capability configuration error, got %v", err)
}
_, _, err = filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}, "max_tokens": 32769}, []store.RuntimeModelCandidate{volcTextCandidate(32768)})
if store.ModelCandidateErrorCode(err) != volcesOutputInvalidParameterCode {
t.Fatalf("expected invalid_parameter for explicit limit, got %v", err)
}
}
func TestNonVolcesOutputProcessorDoesNotInject(t *testing.T) {
candidate := volcTextCandidate(131072)
candidate.Provider = "openai"
candidate.BaseURL = "https://api.openai.com/v1"
chat := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, candidate)
if _, ok := chat.Body["max_tokens"]; ok {
t.Fatalf("non-Volcengine Chat candidate must remain unchanged: %+v", chat.Body)
}
responses := preprocessRequestWithLog("responses", map[string]any{"input": "hello"}, candidate)
if _, ok := responses.Body["max_output_tokens"]; ok {
t.Fatalf("non-Volcengine Responses candidate must remain unchanged: %+v", responses.Body)
}
}

View File

@ -55,6 +55,7 @@ type parameterPreprocessChange struct {
func NewParamProcessorChain() ParamProcessorChain {
return ParamProcessorChain{
processors: []paramProcessor{
outputTokenLimitProcessor{},
resolutionNormalizeProcessor{},
aspectRatioProcessor{},
imageSizeProcessor{},

View File

@ -28,6 +28,10 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body
if err != nil {
return EstimateResult{}, err
}
candidates, _, err = filterRuntimeCandidatesByOutputTokens(kind, model, modelType, body, candidates)
if err != nil {
return EstimateResult{}, err
}
candidate := candidates[0]
body = preprocessRequest(kind, body, candidate)
items := s.estimatedBillings(ctx, user, kind, body, candidate)

View File

@ -88,7 +88,10 @@ func (s *Service) startRiverQueue(ctx context.Context) error {
Queues: map[string]river.QueueConfig{
asyncTaskQueueName: {MaxWorkers: 32},
},
RescueStuckJobsAfter: 30 * time.Second,
// Provider-backed media jobs commonly poll for 10-20 minutes. River may
// execute a still-running job again once this window elapses, so keep the
// rescue horizon above the longest configured provider poll timeout.
RescueStuckJobsAfter: time.Hour,
TestOnly: s.cfg.AppEnv == "test",
Workers: workers,
})

View File

@ -230,6 +230,22 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
}
return Result{Task: failed, Output: failed.Result}, err
}
var outputTokenFilterSummary map[string]any
candidates, outputTokenFilterSummary, err = filterRuntimeCandidatesByOutputTokens(task.Kind, task.Model, modelType, body, candidates)
candidateFilterSummary = mergeCandidateFilterSummaries(candidateFilterSummary, outputTokenFilterSummary)
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_output_token_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
}
if task.Kind == "responses" {
candidates, err = prepareResponseCandidates(candidates, responseExecution)
if err != nil {
@ -839,7 +855,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
}
func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID string, attemptID string, attemptNo int, candidate store.RuntimeModelCandidate, log parameterPreprocessingLog) error {
if skipTaskParameterPreprocessingLog(log.ModelType) {
if skipTaskParameterPreprocessingLog(log.ModelType) && !log.Changed {
return nil
}
_, err := s.store.CreateTaskParamPreprocessingLog(ctx, store.CreateTaskParamPreprocessingLogInput{
@ -1348,10 +1364,22 @@ func validateRequest(kind string, body map[string]any) error {
if err := clients.ValidateOpenAIReasoningEffort(body["reasoning_effort"]); err != nil {
return err
}
for _, key := range []string{"max_tokens", "max_completion_tokens"} {
if value, explicit := nonNullParameter(body, key); explicit {
if _, ok := positiveInteger(value); !ok {
return &clients.ClientError{Code: "invalid_parameter", Message: key + " must be a positive integer", Param: key, StatusCode: 400, Retryable: false}
}
}
}
case "responses":
if body["input"] == nil && body["messages"] == nil {
return errors.New("input or messages is required")
}
if value, explicit := nonNullParameter(body, "max_output_tokens"); explicit {
if _, ok := positiveInteger(value); !ok {
return &clients.ClientError{Code: "invalid_parameter", Message: "max_output_tokens must be a positive integer", Param: "max_output_tokens", StatusCode: 400, Retryable: false}
}
}
case "embeddings":
if body["input"] == nil {
return errors.New("input is required")

View File

@ -16,7 +16,7 @@ func (namedClient) Run(context.Context, clients.Request) (clients.Response, erro
}
func TestValidateRequestAcceptsOpenAIReasoningEffort(t *testing.T) {
for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh"} {
for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} {
t.Run(effort, func(t *testing.T) {
err := validateRequest("chat.completions", map[string]any{
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
@ -30,7 +30,7 @@ func TestValidateRequestAcceptsOpenAIReasoningEffort(t *testing.T) {
}
func TestValidateRequestRejectsNonOpenAIReasoningEffort(t *testing.T) {
for _, effort := range []string{"max", "auto"} {
for _, effort := range []string{"auto"} {
t.Run(effort, func(t *testing.T) {
err := validateRequest("chat.completions", map[string]any{
"messages": []any{map[string]any{"role": "user", "content": "ping"}},

View File

@ -3,6 +3,8 @@ package store
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"github.com/jackc/pgx/v5"
@ -116,6 +118,9 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
if len(capabilities) == 0 {
capabilities = EffectivePlatformModelCapabilities(base.Capabilities, input.CapabilityOverride)
}
if err := validateEnabledVolcesTextModelCapabilities(ctx, q, input, capabilities); err != nil {
return PlatformModel{}, err
}
billingConfig := input.BillingConfig
if len(billingConfig) == 0 {
billingConfig = mergeObjects(base.BaseBillingConfig, input.BillingConfigOverride)
@ -262,6 +267,76 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
return model, nil
}
func validateEnabledVolcesTextModelCapabilities(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput, capabilities map[string]any) error {
// createPlatformModel enables/upserts models unconditionally, so every text
// model that reaches this path must already satisfy the Volcengine invariant.
if !containsTextOutputModelType(input.ModelType) {
return nil
}
var provider string
var baseURL string
if err := q.QueryRow(ctx, `SELECT provider, COALESCE(base_url, '') FROM integration_platforms WHERE id = $1::uuid`, input.PlatformID).Scan(&provider, &baseURL); err != nil {
return err
}
provider = strings.ToLower(strings.TrimSpace(provider))
baseURL = strings.ToLower(strings.TrimSpace(baseURL))
if provider != "volces-openai" && !strings.Contains(baseURL, "volces.com") && !strings.Contains(baseURL, "byteplus.com") {
return nil
}
seen := map[string]struct{}{}
for _, modelType := range append(append(StringList{}, input.ModelType...), "text_generate") {
modelType = strings.TrimSpace(modelType)
if modelType == "" {
continue
}
if _, ok := seen[modelType]; ok {
continue
}
seen[modelType] = struct{}{}
capability, _ := capabilities[modelType].(map[string]any)
if value, ok := positiveWholeNumber(capability["max_output_tokens"]); ok && value > 0 {
return nil
}
}
return fmt.Errorf("%w: enabled Volcengine text model %q requires a positive integer max_output_tokens capability for its text model type or text_generate fallback", ErrInvalidPlatformModelConfiguration, input.ProviderModelName)
}
func containsTextOutputModelType(values StringList) bool {
for _, value := range values {
switch strings.ToLower(strings.TrimSpace(value)) {
case "text_generate", "chat", "responses", "text":
return true
}
}
return false
}
func positiveWholeNumber(value any) (int64, bool) {
var number float64
switch typed := value.(type) {
case int:
number = float64(typed)
case int32:
number = float64(typed)
case int64:
number = float64(typed)
case float64:
number = typed
case json.Number:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
number = parsed
default:
return 0, false
}
if number <= 0 || math.Trunc(number) != number || number > math.MaxInt64 {
return 0, false
}
return int64(number), true
}
func (s *Store) DeletePlatformModel(ctx context.Context, id string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {

View File

@ -7,8 +7,9 @@ import (
)
var (
ErrNoModelCandidate = errors.New("no enabled platform model matches request")
ErrRateLimited = errors.New("rate limit exceeded")
ErrNoModelCandidate = errors.New("no enabled platform model matches request")
ErrRateLimited = errors.New("rate limit exceeded")
ErrInvalidPlatformModelConfiguration = errors.New("invalid platform model configuration")
)
type ModelCandidateUnavailableError struct {

View File

@ -0,0 +1,216 @@
UPDATE model_catalog_providers
SET default_auth_type = 'APIKey',
updated_at = now()
WHERE provider_key = 'keling'
OR provider_code = 'keling';
UPDATE integration_platforms
SET auth_type = CASE
WHEN COALESCE(NULLIF(trim(credentials->>'accessKey'), ''), NULLIF(trim(credentials->>'secretKey'), '')) IS NULL
THEN 'APIKey'
ELSE auth_type
END,
credentials = CASE
WHEN credentials ? 'apiKey' THEN credentials
ELSE credentials || '{"apiKey": ""}'::jsonb
END,
updated_at = now()
WHERE provider = 'keling'
AND deleted_at IS NULL;
WITH keling_30_turbo AS (
SELECT
'keling:kling-3.0-turbo' AS canonical_model_key,
'kling-3.0-turbo' AS provider_model_name,
'可灵3.0 Turbo' AS display_name,
'["video_generate","image_to_video"]'::jsonb AS model_type,
'{
"video_generate": {
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
"output_resolutions": ["720p", "1080p"],
"duration_range": [3, 15],
"output_audio": true,
"prompt_length_limit": {
"max": 3072,
"label": "可灵3.0 Turbo"
}
},
"image_to_video": {
"output_resolutions": ["720p", "1080p"],
"duration_range": [3, 15],
"input_first_frame": true,
"input_last_frame": false,
"input_first_last_frame": false,
"aspect_ratio_allowed": [],
"output_audio": true,
"input_reference_generate_single": false,
"input_reference_generate_multiple": false,
"support_video_effect_template": false,
"prompt_length_limit": {
"max": 2500,
"label": "可灵3.0 Turbo"
}
},
"originalTypes": ["video_generate", "image_to_video"]
}'::jsonb AS capabilities,
COALESCE(
(
SELECT base_billing_config
FROM base_model_catalog
WHERE canonical_model_key = 'keling:kling-v3'
LIMIT 1
),
'{
"video": {
"basePrice": 100,
"baseWeight": 1,
"dynamicWeight": {
"720p": 1,
"1080p": 1.5,
"audio-true": 2,
"audio-false": 1
}
},
"currency": "resource"
}'::jsonb
) AS base_billing_config,
COALESCE(
(
SELECT default_rate_limit_policy
FROM base_model_catalog
WHERE canonical_model_key = 'keling:kling-v3'
LIMIT 1
),
'{"platformLimits":{"max_concurrent_requests":5}}'::jsonb
) AS default_rate_limit_policy,
'{
"source": "server-main.integration-platform",
"sourceProviderCode": "keling",
"sourceProviderName": "可灵AI",
"sourceSpecType": "keling",
"originalTypes": ["video_generate", "image_to_video"],
"alias": "可灵3.0 Turbo",
"description": "可灵新任务 API 的 3.0 Turbo 文生视频与首帧图生视频模型",
"iconPath": "https://static.51easyai.com/kling-color.webp",
"billingType": "external-api",
"billingMode": "",
"referenceModel": "",
"modelWeight": null,
"selectable": true,
"rawModel": {
"name": "kling-3.0-turbo",
"types": ["video_generate", "image_to_video"],
"alias": "可灵3.0 Turbo",
"icon_path": "https://static.51easyai.com/kling-color.webp"
}
}'::jsonb AS metadata
)
INSERT INTO base_model_catalog (
provider_id,
provider_key,
canonical_model_key,
provider_model_name,
model_type,
display_name,
capabilities,
base_billing_config,
default_rate_limit_policy,
metadata,
catalog_type,
default_snapshot,
status
)
SELECT
(
SELECT id
FROM model_catalog_providers
WHERE provider_key = 'keling' OR provider_code = 'keling'
LIMIT 1
),
'keling',
model.canonical_model_key,
model.provider_model_name,
model.model_type,
model.display_name,
model.capabilities,
model.base_billing_config,
model.default_rate_limit_policy,
jsonb_set(model.metadata, '{rawModel,capabilities}', model.capabilities, true),
'system',
jsonb_build_object(
'providerKey', 'keling',
'canonicalModelKey', model.canonical_model_key,
'providerModelName', model.provider_model_name,
'modelType', model.model_type,
'modelAlias', model.display_name,
'displayName', model.display_name,
'capabilities', model.capabilities,
'baseBillingConfig', model.base_billing_config,
'defaultRateLimitPolicy', model.default_rate_limit_policy,
'metadata', jsonb_set(model.metadata, '{rawModel,capabilities}', model.capabilities, true),
'status', 'active'
),
'active'
FROM keling_30_turbo model
ON CONFLICT (canonical_model_key) DO UPDATE
SET provider_id = EXCLUDED.provider_id,
provider_key = EXCLUDED.provider_key,
provider_model_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name ELSE base_model_catalog.provider_model_name END,
model_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.model_type ELSE base_model_catalog.model_type END,
display_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name ELSE base_model_catalog.display_name END,
capabilities = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.capabilities ELSE base_model_catalog.capabilities END,
base_billing_config = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.base_billing_config ELSE base_model_catalog.base_billing_config END,
default_rate_limit_policy = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_rate_limit_policy ELSE base_model_catalog.default_rate_limit_policy END,
metadata = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.metadata ELSE base_model_catalog.metadata END,
catalog_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'system' ELSE base_model_catalog.catalog_type END,
default_snapshot = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_snapshot ELSE base_model_catalog.default_snapshot END,
status = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'active' ELSE base_model_catalog.status END,
updated_at = now();
INSERT INTO platform_models (
platform_id,
base_model_id,
model_name,
provider_model_name,
model_alias,
model_type,
display_name,
capabilities,
pricing_mode,
billing_config,
retry_policy,
rate_limit_policy,
enabled
)
SELECT
platform.id,
base_model.id,
base_model.provider_model_name,
base_model.provider_model_name,
base_model.display_name,
base_model.model_type,
base_model.display_name,
base_model.capabilities,
'inherit_discount',
base_model.base_billing_config,
'{"enabled":true,"maxAttempts":1}'::jsonb,
base_model.default_rate_limit_policy,
true
FROM integration_platforms platform
JOIN base_model_catalog base_model
ON base_model.canonical_model_key = 'keling:kling-3.0-turbo'
WHERE platform.provider = 'keling'
AND platform.deleted_at IS NULL
ON CONFLICT (platform_id, model_name) DO UPDATE
SET base_model_id = EXCLUDED.base_model_id,
provider_model_name = EXCLUDED.provider_model_name,
model_alias = EXCLUDED.model_alias,
display_name = EXCLUDED.display_name,
model_type = EXCLUDED.model_type,
capabilities = EXCLUDED.capabilities,
pricing_mode = EXCLUDED.pricing_mode,
billing_config = EXCLUDED.billing_config,
retry_policy = EXCLUDED.retry_policy,
rate_limit_policy = EXCLUDED.rate_limit_policy,
enabled = EXCLUDED.enabled,
updated_at = now();

452
scripts/deyun-video-e2e.mjs Executable file
View File

@ -0,0 +1,452 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, '');
const model = process.env.DEYUN_VIDEO_MODEL || 'deyun-seedance-2.0-canary';
const expectedPlatform = process.env.DEYUN_VIDEO_PLATFORM || '漫路(火山兼容)';
const expectedProviderModel = process.env.DEYUN_VIDEO_PROVIDER_MODEL || 'doubao-seedance-2-0';
const pollIntervalMs = positiveInteger(process.env.DEYUN_VIDEO_POLL_INTERVAL_MS, 5000);
const taskTimeoutMs = positiveInteger(process.env.DEYUN_VIDEO_TASK_TIMEOUT_MS, 20 * 60 * 1000);
const outputPath =
process.env.DEYUN_VIDEO_E2E_OUTPUT ||
`artifacts/deyun-video-e2e-${new Date().toISOString().replaceAll(/[:.]/g, '-')}.json`;
const database = {
container: process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres',
name: process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway',
user: process.env.GATEWAY_E2E_DB_USER || 'easyai',
};
const validationCases = [
{
name: '480p-4s-audio-off',
request: {
model,
prompt: 'A glossy red cube rotates slowly on a clean white studio table, locked camera, soft daylight.',
resolution: '480p',
ratio: '16:9',
duration: 4,
generate_audio: false,
seed: 41001,
watermark: false,
runMode: 'real',
},
expected: {
height: 480,
duration: 4,
audio: false,
ratio: 16 / 9,
},
},
{
name: '720p-6s-audio-on',
request: {
model,
prompt: 'A calm ocean wave rolls toward a sandy beach at sunrise, locked camera, natural ambient sound.',
resolution: '720p',
ratio: '16:9',
duration: 6,
generate_audio: true,
seed: 42002,
watermark: false,
runMode: 'real',
},
expected: {
height: 720,
duration: 6,
audio: true,
ratio: 16 / 9,
},
},
];
const requestedCases = new Set(
String(process.env.DEYUN_VIDEO_CASES || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
);
if (requestedCases.size > 0) {
const selectedCases = validationCases.filter((validationCase) => requestedCases.has(validationCase.name));
if (selectedCases.length !== requestedCases.size) {
const knownCases = validationCases.map((validationCase) => validationCase.name).join(', ');
throw new Error(`Unknown DEYUN_VIDEO_CASES value; known cases: ${knownCases}`);
}
validationCases.splice(0, validationCases.length, ...selectedCases);
}
const submittedTaskIds = [];
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function positiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value || ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function nearlyEqual(left, right, tolerance = 1e-6) {
return Math.abs(Number(left) - Number(right)) <= tolerance;
}
function runPSQL(query) {
return execFileSync(
'docker',
['exec', database.container, 'psql', '-U', database.user, '-d', database.name, '-At', '-F', '\t', '-c', query],
{ encoding: 'utf8' },
).trim();
}
function resolveGatewayAPIKey() {
if (process.env.GATEWAY_E2E_API_KEY) {
assert(process.env.GATEWAY_E2E_API_KEY_ID, 'Set GATEWAY_E2E_API_KEY_ID when GATEWAY_E2E_API_KEY is provided');
const metadata = apiKeyMetadata(process.env.GATEWAY_E2E_API_KEY_ID);
return {
...metadata,
secret: process.env.GATEWAY_E2E_API_KEY,
source: 'environment',
};
}
const query = `
SELECT key.id::text,
key.key_secret,
key.gateway_user_id::text,
wallet.balance::float8,
wallet.frozen_balance::float8
FROM gateway_api_keys key
JOIN gateway_wallet_accounts wallet
ON wallet.gateway_user_id = key.gateway_user_id
AND wallet.currency = 'resource'
AND wallet.status = 'active'
WHERE key.deleted_at IS NULL
AND key.status = 'active'
AND COALESCE(key.key_secret, '') <> ''
AND (key.expires_at IS NULL OR key.expires_at > now())
AND (key.scopes ? 'video' OR key.scopes ? '*' OR key.scopes ? 'all')
AND (wallet.balance - wallet.frozen_balance) > 0
ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC
LIMIT 1`;
const fields = runPSQL(query).split('\t');
assert(fields.length === 5 && fields[0] && fields[1], 'No recoverable video-scoped Gateway API Key with positive balance was found');
return {
keyId: fields[0],
secret: fields[1],
userId: fields[2],
balance: Number(fields[3]),
frozenBalance: Number(fields[4]),
source: 'database',
};
}
function apiKeyMetadata(keyId) {
const escapedKeyId = String(keyId).replaceAll("'", "''");
const output = runPSQL(`
SELECT key.id::text,
key.gateway_user_id::text,
wallet.balance::float8,
wallet.frozen_balance::float8
FROM gateway_api_keys key
JOIN gateway_wallet_accounts wallet
ON wallet.gateway_user_id = key.gateway_user_id
AND wallet.currency = 'resource'
WHERE key.id = '${escapedKeyId}'::uuid
LIMIT 1`);
const fields = output.split('\t');
assert(fields.length === 4 && fields[0], `Gateway API Key ${keyId} was not found`);
return {
keyId: fields[0],
userId: fields[1],
balance: Number(fields[2]),
frozenBalance: Number(fields[3]),
};
}
function walletState(userId) {
const escapedUserId = String(userId).replaceAll("'", "''");
const fields = runPSQL(`
SELECT balance::float8, frozen_balance::float8
FROM gateway_wallet_accounts
WHERE gateway_user_id = '${escapedUserId}'::uuid
AND currency = 'resource'
LIMIT 1`).split('\t');
assert(fields.length === 2, `Wallet for Gateway user ${userId} was not found`);
return {
balance: Number(fields[0]),
frozenBalance: Number(fields[1]),
};
}
async function request(path, init = {}) {
const response = await fetch(`${baseURL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${authentication.secret}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(init.headers || {}),
},
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${init.method || 'GET'} ${path} failed ${response.status}: ${text}`);
}
return text ? JSON.parse(text) : {};
}
async function createVideoTask(body) {
const accepted = await request('/api/v1/videos/generations', {
method: 'POST',
headers: { 'X-Async': 'true' },
body: JSON.stringify(body),
});
const taskId = accepted.taskId || accepted.task?.id;
assert(taskId, `Video task acceptance is missing taskId: ${JSON.stringify(accepted)}`);
submittedTaskIds.push(taskId);
return taskId;
}
async function pollTask(taskId) {
const startedAt = Date.now();
while (Date.now() - startedAt < taskTimeoutMs) {
const task = await request(`/api/v1/tasks/${taskId}`);
if (task.status === 'succeeded') return task;
if (task.status === 'failed' || task.status === 'cancelled') {
throw new Error(`Task ${taskId} ended as ${task.status}: ${task.errorMessage || task.error || JSON.stringify(task.result)}`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Timed out after ${taskTimeoutMs}ms waiting for task ${taskId}`);
}
async function taskEvents(taskId) {
const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}/events`, {
headers: { Authorization: `Bearer ${authentication.secret}` },
});
const text = await response.text();
if (!response.ok) throw new Error(`GET task events failed ${response.status}: ${text}`);
return text
.split(/\r?\n\r?\n/)
.flatMap((block) => {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
.join('\n');
return data ? [JSON.parse(data)] : [];
});
}
async function taskPreprocessingLogs(taskId) {
return request(`/api/v1/tasks/${taskId}/param-preprocessing`);
}
function videoURLFromTask(task) {
const data = Array.isArray(task.result?.data) ? task.result.data : [];
return data.find((item) => item?.type === 'video')?.url || data.find((item) => item?.url)?.url || '';
}
async function downloadVideo(url, filePath) {
const response = await fetch(url);
assert(response.ok, `Download ${url} failed ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
assert(bytes.length > 0, `Downloaded video ${url} is empty`);
await writeFile(filePath, bytes);
return bytes.length;
}
function probeVideo(filePath) {
const output = execFileSync(
'ffprobe',
['-v', 'error', '-show_streams', '-show_format', '-of', 'json', filePath],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
);
return JSON.parse(output);
}
function validateRequestSnapshot(task, expectedRequest) {
const snapshot = task.request || {};
for (const key of ['model', 'resolution', 'ratio', 'duration', 'generate_audio', 'seed']) {
assert(
snapshot[key] === expectedRequest[key],
`Task ${task.id} request.${key}=${JSON.stringify(snapshot[key])}, expected ${JSON.stringify(expectedRequest[key])}`,
);
}
}
function validateTaskAudit(task, events) {
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
assert(attempts.length === 1, `Task ${task.id} has ${attempts.length} attempts, expected exactly one`);
const attempt = attempts[0];
assert(attempt.platformName === expectedPlatform, `Task ${task.id} used platform ${attempt.platformName}, expected ${expectedPlatform}`);
assert(attempt.provider === 'volces', `Task ${task.id} used provider ${attempt.provider}, expected volces`);
assert(
attempt.providerModelName === expectedProviderModel,
`Task ${task.id} used provider model ${attempt.providerModelName}, expected ${expectedProviderModel}`,
);
assert(task.remoteTaskId, `Task ${task.id} is missing remoteTaskId`);
assert(attempt.requestId, `Task ${task.id} attempt is missing requestId`);
const startedEvents = events.filter((event) => event.eventType === 'task.attempt.started');
assert(startedEvents.length === 1, `Task ${task.id} emitted ${startedEvents.length} task.attempt.started events`);
assert(Number(task.finalChargeAmount) > 0, `Task ${task.id} finalChargeAmount must be positive`);
return attempt;
}
function validateMedia(probe, expected, taskId) {
const streams = Array.isArray(probe.streams) ? probe.streams : [];
const video = streams.find((stream) => stream.codec_type === 'video');
const audioStreams = streams.filter((stream) => stream.codec_type === 'audio');
assert(video, `Task ${taskId} output has no video stream`);
const width = Number(video.width);
const height = Number(video.height);
const duration = Number(video.duration || probe.format?.duration);
assert(height === expected.height, `Task ${taskId} output height=${height}, expected ${expected.height}`);
const ratioError = Math.abs(width / height - expected.ratio) / expected.ratio;
assert(ratioError <= 0.02, `Task ${taskId} aspect ratio ${width}:${height} differs from 16:9 by ${(ratioError * 100).toFixed(2)}%`);
assert(Number.isFinite(duration), `Task ${taskId} output duration is unavailable`);
assert(Math.abs(duration - expected.duration) <= 1, `Task ${taskId} duration=${duration}s, expected ${expected.duration}s ±1s`);
if (expected.audio) {
assert(audioStreams.length > 0, `Task ${taskId} requested audio but output has no audio stream`);
const audioDuration = Number(audioStreams[0].duration || probe.format?.duration);
if (Number.isFinite(audioDuration)) {
assert(audioDuration >= duration - 1, `Task ${taskId} audio duration=${audioDuration}s is too short for video duration=${duration}s`);
}
} else {
assert(audioStreams.length === 0, `Task ${taskId} disabled audio but output has ${audioStreams.length} audio stream(s)`);
}
return {
width,
height,
duration,
ratio: width / height,
videoCodec: video.codec_name || null,
audioStreams: audioStreams.map((stream) => ({
codec: stream.codec_name || null,
channels: Number(stream.channels || 0),
sampleRate: Number(stream.sample_rate || 0),
duration: Number(stream.duration || probe.format?.duration || 0),
})),
};
}
const authentication = resolveGatewayAPIKey();
function sanitizedFailureMessage(error) {
return String(error instanceof Error ? error.message : error)
.replace(/Request id:\s*[A-Za-z0-9_-]+/gi, 'Request id: [redacted]')
.replace(/\b\d{8,}\b/g, '[redacted-id]');
}
async function writeFailureReport(error) {
const report = {
ok: false,
generatedAt: new Date().toISOString(),
baseURL,
model,
expectedPlatform,
expectedProviderModel,
authentication: {
type: 'gateway_api_key',
source: authentication.source,
keyId: authentication.keyId,
},
submittedTaskIds,
error: sanitizedFailureMessage(error),
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
return report;
}
async function main() {
const tempDirectory = await mkdtemp(join(tmpdir(), 'deyun-video-e2e-'));
const walletBefore = walletState(authentication.userId);
const results = [];
try {
for (const validationCase of validationCases) {
const taskId = await createVideoTask(validationCase.request);
const task = await pollTask(taskId);
const [events, preprocessing] = await Promise.all([
taskEvents(taskId),
taskPreprocessingLogs(taskId),
]);
validateRequestSnapshot(task, validationCase.request);
const attempt = validateTaskAudit(task, events);
assert(
Array.isArray(preprocessing.items) && preprocessing.items.length > 0,
`Task ${taskId} has no parameter preprocessing audit record`,
);
const videoURL = videoURLFromTask(task);
assert(videoURL, `Task ${taskId} result is missing a video URL`);
const videoPath = join(tempDirectory, `${validationCase.name}.mp4`);
const byteSize = await downloadVideo(videoURL, videoPath);
const probe = probeVideo(videoPath);
const observed = validateMedia(probe, validationCase.expected, taskId);
results.push({
name: validationCase.name,
taskId,
remoteTaskId: task.remoteTaskId,
requestId: attempt.requestId,
platform: attempt.platformName,
providerModelName: attempt.providerModelName,
requested: {
resolution: validationCase.request.resolution,
ratio: validationCase.request.ratio,
duration: validationCase.request.duration,
generateAudio: validationCase.request.generate_audio,
seed: validationCase.request.seed,
},
observed,
byteSize,
finalChargeAmount: Number(task.finalChargeAmount),
billingSummary: task.billingSummary || {},
eventTypes: events.map((event) => event.eventType),
preprocessingChangeCount: preprocessing.items.reduce((sum, item) => sum + Number(item.changeCount || 0), 0),
});
}
} finally {
await rm(tempDirectory, { recursive: true, force: true });
}
const walletAfter = walletState(authentication.userId);
const totalCharge = results.reduce((sum, result) => sum + result.finalChargeAmount, 0);
const walletDebit = walletBefore.balance - walletAfter.balance;
assert(nearlyEqual(walletDebit, totalCharge, 1e-6), `Wallet debit=${walletDebit}, expected total charge=${totalCharge}`);
assert(
nearlyEqual(walletAfter.frozenBalance, walletBefore.frozenBalance, 1e-6),
`Wallet frozen balance changed from ${walletBefore.frozenBalance} to ${walletAfter.frozenBalance}`,
);
const report = {
ok: true,
generatedAt: new Date().toISOString(),
baseURL,
model,
expectedPlatform,
expectedProviderModel,
authentication: {
type: 'gateway_api_key',
source: authentication.source,
keyId: authentication.keyId,
},
wallet: {
balanceBefore: walletBefore.balance,
balanceAfter: walletAfter.balance,
frozenBalanceBefore: walletBefore.frozenBalance,
frozenBalanceAfter: walletAfter.frozenBalance,
debit: walletDebit,
totalCharge,
},
results,
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
console.log(JSON.stringify({ ...report, outputPath }, null, 2));
}
main().catch(async (error) => {
const report = await writeFailureReport(error);
console.error(report.error);
console.error(`Failure report: ${outputPath}`);
process.exitCode = 1;
});

View File

@ -0,0 +1,272 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, '');
const authentication = resolveGatewayAPIKey();
const timeoutMs = Number(process.env.GATEWAY_E2E_TIMEOUT_MS || 180_000);
const scenarios = [
{
name: '火山 128K Chat 未传上限',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', value: 43690, injected: true },
},
{
name: '火山 32K Chat 未传上限',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_32K_MODEL || 'volces-openai:doubao-seed-1-8-251228',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', value: 10922, injected: true },
},
{
name: '火山 Chat max_tokens=null',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody({ max_tokens: null }),
expected: { parameter: 'max_tokens', value: 43690, injected: true },
},
{
name: '火山 Chat 显式 max_tokens',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody({ max_tokens: 64 }),
expected: { parameter: 'max_tokens', value: 64, injected: false },
},
{
name: '火山 Chat 显式 max_completion_tokens',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/chat/completions',
body: chatBody({ max_completion_tokens: 64 }),
expected: { parameter: 'max_tokens', absent: true, injected: false, preserved: { max_completion_tokens: 64 } },
},
{
name: '火山 Responses 未传上限',
platform: '火山引擎',
model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215',
path: '/v1/responses',
body: responsesBody(),
expected: { parameter: 'max_output_tokens', value: 43690, injected: true },
},
{
name: '阿里百炼 Qwen Chat 未传上限',
platform: '阿里云百炼',
model: process.env.GATEWAY_E2E_QWEN_MODEL || 'aliyun-bailian-openai:qwen3.7-plus',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: '阿里百炼 Qwen Responses 未传上限',
platform: '阿里云百炼',
model: process.env.GATEWAY_E2E_QWEN_MODEL || 'aliyun-bailian-openai:qwen3.7-plus',
path: '/v1/responses',
body: responsesBody(),
expected: { parameter: 'max_output_tokens', absent: true, injected: false },
},
{
name: 'DeepSeek 官网 Chat 未传上限',
platform: 'DeepSeek 官网',
model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: 'DeepSeek 官网 Responses 回退未传上限',
platform: 'DeepSeek 官网',
model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro',
path: '/v1/responses',
body: responsesBody(),
expected: { parameter: 'max_output_tokens', absent: true, injected: false },
},
{
name: '智谱 GLM-5.2 Chat 未传上限',
platform: '智谱AI',
model: process.env.GATEWAY_E2E_ZHIPU_MODEL || 'zhipu-openai:glm-5.2',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: 'OpenAI GPT-4o Chat 未传上限',
platform: 'OpenAI',
model: process.env.GATEWAY_E2E_OPENAI_MODEL || 'openai:gpt-4o',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
{
name: 'Google Gemini Chat 未传上限',
platform: 'Google Gemini',
model: process.env.GATEWAY_E2E_GEMINI_MODEL || 'gemini:gemini-2.5-pro',
path: '/v1/chat/completions',
body: chatBody(),
expected: { parameter: 'max_tokens', absent: true, injected: false },
},
];
function chatBody(extra = {}) {
return {
messages: [{ role: 'user', content: '这是输出上限链路验证。只回复 OK。' }],
...extra,
};
}
function responsesBody(extra = {}) {
return { input: '这是输出上限链路验证。只回复 OK。', ...extra };
}
function resolveGatewayAPIKey() {
if (process.env.GATEWAY_E2E_API_KEY) {
return { value: process.env.GATEWAY_E2E_API_KEY, source: 'environment', keyId: null };
}
const container = process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres';
const database = process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway';
const user = process.env.GATEWAY_E2E_DB_USER || 'easyai';
const query = `SELECT key.id::text || E'\\t' || key.key_secret FROM gateway_api_keys key JOIN gateway_wallet_accounts wallet ON wallet.gateway_user_id = key.gateway_user_id AND wallet.status = 'active' WHERE key.deleted_at IS NULL AND key.status = 'active' AND key.key_secret IS NOT NULL AND key.key_secret <> '' AND key.scopes ? 'chat' AND (key.expires_at IS NULL OR key.expires_at > now()) AND (wallet.balance - wallet.frozen_balance) > 0 ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC LIMIT 1`;
let output = '';
try {
output = execFileSync('docker', ['exec', container, 'psql', '-U', user, '-d', database, '-At', '-c', query], { encoding: 'utf8' }).trim();
} catch {
throw new Error('Unable to load an active chat-scoped Gateway API Key; set GATEWAY_E2E_API_KEY explicitly');
}
const separator = output.indexOf('\t');
if (separator <= 0 || separator === output.length - 1) {
throw new Error('No active chat-scoped Gateway API Key with a positive balance was found');
}
return { keyId: output.slice(0, separator), value: output.slice(separator + 1), source: 'database' };
}
async function fetchText(path, options = {}) {
const response = await fetch(`${baseURL}${path}`, {
...options,
signal: AbortSignal.timeout(timeoutMs),
headers: { Authorization: `Bearer ${authentication.value}`, ...(options.headers || {}) },
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text.slice(0, 2_000) };
}
return { response, body };
}
async function getTask(taskId) {
const { response, body } = await fetchText(`/api/v1/tasks/${taskId}`);
if (!response.ok) throw new Error(`GET task ${taskId} failed ${response.status}: ${JSON.stringify(body)}`);
return body;
}
async function getPreprocessing(taskId) {
const { response, body } = await fetchText(`/api/v1/tasks/${taskId}/param-preprocessing`);
if (!response.ok) throw new Error(`GET preprocessing ${taskId} failed ${response.status}: ${JSON.stringify(body)}`);
return body?.items || [];
}
function successfulAttempt(task) {
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
return attempts.findLast((attempt) => attempt.status === 'succeeded') || attempts.at(-1);
}
function hasOwn(value, key) {
return value != null && Object.prototype.hasOwnProperty.call(value, key);
}
function validateAudit(scenario, task, preprocessing) {
const attempt = successfulAttempt(task);
if (!attempt) throw new Error('task does not contain an upstream attempt');
if (task.simulated || attempt.simulated) throw new Error('request was served by a simulation candidate');
const platformName = String(attempt.metrics?.platformName || task.metrics?.platformName || '');
if (!platformName.includes(scenario.platform)) {
throw new Error(`unexpected platform ${platformName}; expected ${scenario.platform}`);
}
const snapshot = attempt.requestSnapshot || {};
const expected = scenario.expected;
if (expected.absent) {
if (hasOwn(snapshot, expected.parameter)) {
throw new Error(`${expected.parameter} was unexpectedly sent upstream as ${JSON.stringify(snapshot[expected.parameter])}`);
}
} else if (snapshot[expected.parameter] !== expected.value) {
throw new Error(`${expected.parameter}=${JSON.stringify(snapshot[expected.parameter])}; expected ${expected.value}`);
}
for (const [key, value] of Object.entries(expected.preserved || {})) {
if (snapshot[key] !== value) throw new Error(`${key} was not preserved in the upstream snapshot`);
}
const changes = preprocessing.flatMap((item) => item.changes || []);
const outputLimitChanges = changes.filter((change) => change.processor === 'OutputTokenLimitProcessor');
if (expected.injected) {
const matched = outputLimitChanges.some((change) => change.action === 'set' && change.path === expected.parameter && change.after === expected.value);
if (!matched) throw new Error(`missing OutputTokenLimitProcessor audit for ${expected.parameter}=${expected.value}`);
} else if (outputLimitChanges.length > 0) {
throw new Error(`unexpected OutputTokenLimitProcessor audit: ${JSON.stringify(outputLimitChanges)}`);
}
return {
taskId: task.id,
attemptId: attempt.id,
platform: platformName,
provider: attempt.metrics?.provider || task.metrics?.provider || null,
platformModelId: attempt.metrics?.platformModelId || task.metrics?.platformModelId || null,
upstreamProtocol: attempt.metrics?.upstreamProtocol || task.metrics?.upstreamProtocol || null,
upstreamEndpoint: attempt.metrics?.upstreamEndpoint || task.metrics?.upstreamEndpoint || null,
simulated: Boolean(task.simulated || attempt.simulated),
requestParameters: {
max_tokens: hasOwn(snapshot, 'max_tokens') ? snapshot.max_tokens : '(absent)',
max_completion_tokens: hasOwn(snapshot, 'max_completion_tokens') ? snapshot.max_completion_tokens : '(absent)',
max_output_tokens: hasOwn(snapshot, 'max_output_tokens') ? snapshot.max_output_tokens : '(absent)',
},
outputLimitAudit: outputLimitChanges,
usage: task.usage || attempt.usage || null,
};
}
const results = [];
for (const scenario of scenarios) {
const startedAt = Date.now();
let taskId = null;
try {
const requestBody = { model: scenario.model, ...scenario.body };
const { response, body } = await fetchText(scenario.path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
taskId = response.headers.get('x-gateway-task-id');
if (!response.ok) throw new Error(`POST ${scenario.path} failed ${response.status}: ${JSON.stringify(body)}`);
if (!taskId) throw new Error('response is missing X-Gateway-Task-Id');
const [task, preprocessing] = await Promise.all([getTask(taskId), getPreprocessing(taskId)]);
const audit = validateAudit(scenario, task, preprocessing);
results.push({ name: scenario.name, model: scenario.model, path: scenario.path, passed: true, durationMs: Date.now() - startedAt, audit });
process.stderr.write(`PASS ${scenario.name} (${Date.now() - startedAt} ms)\n`);
} catch (error) {
results.push({ name: scenario.name, model: scenario.model, path: scenario.path, passed: false, durationMs: Date.now() - startedAt, taskId, error: error instanceof Error ? error.message : String(error) });
process.stderr.write(`FAIL ${scenario.name}: ${error instanceof Error ? error.message : String(error)}\n`);
}
}
const report = {
ok: results.every((result) => result.passed),
generatedAt: new Date().toISOString(),
baseURL,
authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId },
summary: { total: results.length, passed: results.filter((result) => result.passed).length, failed: results.filter((result) => !result.passed).length },
results,
};
const outputPath = process.env.GATEWAY_E2E_OUTPUT;
if (outputPath) {
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
}
console.log(JSON.stringify(report, null, 2));
if (!report.ok) process.exitCode = 1;