Merge remote-tracking branch 'origin/main' into codex/production-cicd

# Conflicts:
#	pnpm-lock.yaml
This commit is contained in:
chengcheng 2026-07-17 16:21:49 +08:00
commit 74c20b1976
58 changed files with 6284 additions and 334 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ node_modules/
*.log
apps/api/bin/
apps/api/gateway
apps/api/tmp/
apps/api/data/

9
apps/api/docs/embed.go Normal file
View File

@ -0,0 +1,9 @@
package docs
import _ "embed"
//go:embed swagger.json
var SwaggerJSON []byte
//go:embed swagger.yaml
var SwaggerYAML []byte

View File

@ -12,6 +12,47 @@
},
"basePath": "/",
"paths": {
"/api-docs-json": {
"get": {
"description": "返回当前构建内嵌的完整机器可读 Swagger JSON供 Agent 在 SKILL references 未覆盖接口时查询。",
"produces": [
"application/json"
],
"tags": [
"agent-resources"
],
"summary": "获取 AI Gateway Swagger JSON",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api-docs-yaml": {
"get": {
"description": "返回当前构建内嵌的完整机器可读 Swagger YAML。",
"produces": [
"application/yaml"
],
"tags": [
"agent-resources"
],
"summary": "获取 AI Gateway Swagger YAML",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/api/admin/access-rules": {
"get": {
"security": [
@ -4480,7 +4521,7 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/httpapi.TaskRequest"
"$ref": "#/definitions/httpapi.ChatCompletionRequest"
}
}
],
@ -5320,6 +5361,58 @@
}
}
},
"/api/v1/public/skills/ai-gateway-ops-management/download": {
"get": {
"description": "下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。",
"produces": [
"application/zip"
],
"tags": [
"agent-resources"
],
"summary": "下载 AI Gateway 运维管理 SKILL",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/v1/public/skills/ai-gateway-ops-management/metadata": {
"get": {
"description": "返回公开运维管理 SKILL 的名称、版本、模块、下载文件名和机器可读接口文档路径。",
"produces": [
"application/json"
],
"tags": [
"agent-resources"
],
"summary": "获取 AI Gateway 运维管理 SKILL 元数据",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.SkillBundleMetadataResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
}
}
}
},
"/api/v1/reranks": {
"post": {
"security": [
@ -5420,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"
}
}
],
@ -5452,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"
}
@ -5485,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"
}
@ -6845,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"
}
}
],
@ -6877,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"
}
@ -6910,12 +6975,6 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
@ -8127,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"
}
}
],
@ -8159,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"
}
@ -8192,12 +8240,6 @@
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
@ -9659,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": {
@ -9694,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": {
@ -9760,9 +9959,14 @@
"type": "string",
"example": "invalid json body"
},
"param": {},
"status": {
"type": "integer",
"example": 400
},
"type": {
"type": "string",
"example": "invalid_request_error"
}
}
},
@ -10304,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",
@ -10313,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
@ -10325,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"
},
@ -10336,6 +10586,10 @@
"type": "boolean",
"example": false
},
"stream_options": {
"type": "object",
"additionalProperties": true
},
"temperature": {
"type": "number",
"example": 0.7
@ -10352,9 +10606,18 @@
"additionalProperties": true
}
},
"top_logprobs": {
"type": "integer"
},
"top_p": {
"type": "number",
"example": 1
},
"truncation": {
"type": "string"
},
"user": {
"type": "string"
}
}
},
@ -10369,6 +10632,48 @@
}
}
},
"httpapi.SkillBundleMetadataResponse": {
"type": "object",
"properties": {
"apiDocsJsonPath": {
"type": "string",
"example": "/api-docs-json"
},
"apiDocsYamlPath": {
"type": "string",
"example": "/api-docs-yaml"
},
"displayName": {
"type": "string",
"example": "AI Gateway 运维管理"
},
"downloadPath": {
"type": "string",
"example": "/api/v1/public/skills/ai-gateway-ops-management/download"
},
"fileName": {
"type": "string",
"example": "ai-gateway-ops-management-v1.0.2.zip"
},
"modules": {
"type": "array",
"items": {
"type": "string"
},
"example": [
"model-runtime"
]
},
"name": {
"type": "string",
"example": "ai-gateway-ops-management"
},
"version": {
"type": "string",
"example": "1.0.2"
}
}
},
"httpapi.TaskAcceptedResponse": {
"type": "object",
"properties": {
@ -10475,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
@ -10510,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:
@ -614,6 +776,36 @@ definitions:
$ref: '#/definitions/store.RuntimePolicySet'
type: array
type: object
httpapi.SkillBundleMetadataResponse:
properties:
apiDocsJsonPath:
example: /api-docs-json
type: string
apiDocsYamlPath:
example: /api-docs-yaml
type: string
displayName:
example: AI Gateway 运维管理
type: string
downloadPath:
example: /api/v1/public/skills/ai-gateway-ops-management/download
type: string
fileName:
example: ai-gateway-ops-management-v1.0.2.zip
type: string
modules:
example:
- model-runtime
items:
type: string
type: array
name:
example: ai-gateway-ops-management
type: string
version:
example: 1.0.2
type: string
type: object
httpapi.TaskAcceptedResponse:
properties:
next:
@ -688,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
@ -714,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:
@ -2545,6 +2750,33 @@ info:
title: EasyAI AI Gateway API
version: 0.1.0
paths:
/api-docs-json:
get:
description: 返回当前构建内嵌的完整机器可读 Swagger JSON供 Agent 在 SKILL references 未覆盖接口时查询。
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
summary: 获取 AI Gateway Swagger JSON
tags:
- agent-resources
/api-docs-yaml:
get:
description: 返回当前构建内嵌的完整机器可读 Swagger YAML。
produces:
- application/yaml
responses:
"200":
description: OK
schema:
type: string
summary: 获取 AI Gateway Swagger YAML
tags:
- agent-resources
/api/admin/access-rules:
get:
description: 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。
@ -5403,7 +5635,7 @@ paths:
name: input
required: true
schema:
$ref: '#/definitions/httpapi.TaskRequest'
$ref: '#/definitions/httpapi.ChatCompletionRequest'
produces:
- application/json
- text/event-stream
@ -5951,6 +6183,40 @@ paths:
summary: 获取公开客户端自定义设置
tags:
- system
/api/v1/public/skills/ai-gateway-ops-management/download:
get:
description: 下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。
produces:
- application/zip
responses:
"200":
description: OK
schema:
type: file
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
summary: 下载 AI Gateway 运维管理 SKILL
tags:
- agent-resources
/api/v1/public/skills/ai-gateway-ops-management/metadata:
get:
description: 返回公开运维管理 SKILL 的名称、版本、模块、下载文件名和机器可读接口文档路径。
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/httpapi.SkillBundleMetadataResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
summary: 获取 AI Gateway 运维管理 SKILL 元数据
tags:
- agent-resources
/api/v1/reranks:
post:
consumes:
@ -6016,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":
@ -6056,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:
@ -6936,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":
@ -6976,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:
@ -6990,9 +7236,9 @@ paths:
$ref: '#/definitions/httpapi.ErrorEnvelope'
security:
- BearerAuth: []
summary: 创建或执行 AI 任务
summary: 创建 OpenAI Chat Completions
tags:
- tasks
- chat
/embeddings:
post:
consumes:
@ -7775,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":
@ -7815,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:
@ -7829,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

@ -0,0 +1,98 @@
package httpapi
import (
"net/http"
gatewaydocs "github.com/easyai/easyai-ai-gateway/apps/api/docs"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/skillbundle"
)
const (
opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download"
apiDocsJSONPath = "/api-docs-json"
apiDocsYAMLPath = "/api-docs-yaml"
)
// getOpsManagementSkillMetadata godoc
// @Summary 获取 AI Gateway 运维管理 SKILL 元数据
// @Description 返回公开运维管理 SKILL 的名称、版本、模块、下载文件名和机器可读接口文档路径。
// @Tags agent-resources
// @Produce json
// @Success 200 {object} SkillBundleMetadataResponse
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/public/skills/ai-gateway-ops-management/metadata [get]
func (s *Server) getOpsManagementSkillMetadata(w http.ResponseWriter, _ *http.Request) {
metadata, err := skillbundle.LoadMetadata()
if err != nil {
s.logger.Error("load operations skill metadata failed", "error", err)
writeError(w, http.StatusInternalServerError, "operations skill metadata unavailable")
return
}
writeJSON(w, http.StatusOK, opsManagementSkillMetadataResponse(metadata))
}
// downloadOpsManagementSkill godoc
// @Summary 下载 AI Gateway 运维管理 SKILL
// @Description 下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。
// @Tags agent-resources
// @Produce application/zip
// @Success 200 {file} binary
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/public/skills/ai-gateway-ops-management/download [get]
func (s *Server) downloadOpsManagementSkill(w http.ResponseWriter, _ *http.Request) {
metadata, err := skillbundle.LoadMetadata()
if err != nil {
s.logger.Error("load operations skill metadata failed", "error", err)
writeError(w, http.StatusInternalServerError, "operations skill metadata unavailable")
return
}
archive, err := skillbundle.BuildArchive()
if err != nil {
s.logger.Error("build operations skill archive failed", "error", err)
writeError(w, http.StatusInternalServerError, "operations skill download unavailable")
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+skillbundle.FileName(metadata)+`"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(archive)
}
// apiDocsJSON godoc
// @Summary 获取 AI Gateway Swagger JSON
// @Description 返回当前构建内嵌的完整机器可读 Swagger JSON供 Agent 在 SKILL references 未覆盖接口时查询。
// @Tags agent-resources
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api-docs-json [get]
func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(gatewaydocs.SwaggerJSON)
}
// apiDocsYAML godoc
// @Summary 获取 AI Gateway Swagger YAML
// @Description 返回当前构建内嵌的完整机器可读 Swagger YAML。
// @Tags agent-resources
// @Produce application/yaml
// @Success 200 {string} string
// @Router /api-docs-yaml [get]
func (s *Server) apiDocsYAML(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(gatewaydocs.SwaggerYAML)
}
func opsManagementSkillMetadataResponse(metadata skillbundle.Metadata) SkillBundleMetadataResponse {
return SkillBundleMetadataResponse{
Name: metadata.Name,
Version: metadata.Version,
DisplayName: skillbundle.DisplayName,
Modules: metadata.Modules,
FileName: skillbundle.FileName(metadata),
DownloadPath: opsManagementSkillDownloadPath,
APIDocsJSONPath: apiDocsJSONPath,
APIDocsYAMLPath: apiDocsYAMLPath,
}
}

View File

@ -0,0 +1,115 @@
package httpapi
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetOpsManagementSkillMetadata(t *testing.T) {
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/skills/ai-gateway-ops-management/metadata", nil)
response := httptest.NewRecorder()
server.getOpsManagementSkillMetadata(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected metadata status 200, got %d: %s", response.Code, response.Body.String())
}
var metadata SkillBundleMetadataResponse
if err := json.Unmarshal(response.Body.Bytes(), &metadata); err != nil {
t.Fatalf("decode metadata: %v", err)
}
if metadata.Name != "ai-gateway-ops-management" || metadata.Version != "1.0.2" {
t.Fatalf("unexpected metadata: %+v", metadata)
}
if len(metadata.Modules) != 1 || metadata.Modules[0] != "model-runtime" {
t.Fatalf("unexpected metadata modules: %+v", metadata.Modules)
}
if metadata.APIDocsJSONPath != "/api-docs-json" || metadata.APIDocsYAMLPath != "/api-docs-yaml" {
t.Fatalf("unexpected API docs paths: %+v", metadata)
}
}
func TestDownloadOpsManagementSkill(t *testing.T) {
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/skills/ai-gateway-ops-management/download", nil)
response := httptest.NewRecorder()
server.downloadOpsManagementSkill(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected download status 200, got %d: %s", response.Code, response.Body.String())
}
if response.Header().Get("Content-Type") != "application/zip" {
t.Fatalf("unexpected content type: %q", response.Header().Get("Content-Type"))
}
if disposition := response.Header().Get("Content-Disposition"); !strings.Contains(disposition, "ai-gateway-ops-management-v1.0.2.zip") {
t.Fatalf("unexpected content disposition: %q", disposition)
}
raw := response.Body.Bytes()
archive, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatalf("open downloaded archive: %v", err)
}
foundSkill := false
for _, file := range archive.File {
if file.Name == "SKILL.md" {
foundSkill = true
break
}
}
if !foundSkill {
t.Fatalf("downloaded archive does not contain SKILL.md")
}
}
func TestEmbeddedAPIDocs(t *testing.T) {
server := &Server{}
jsonResponse := httptest.NewRecorder()
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api-docs-json", nil))
if jsonResponse.Code != http.StatusOK {
t.Fatalf("expected JSON docs status 200, got %d", jsonResponse.Code)
}
if jsonResponse.Header().Get("Content-Type") != "application/json; charset=utf-8" {
t.Fatalf("unexpected JSON docs content type: %q", jsonResponse.Header().Get("Content-Type"))
}
var document struct {
Paths map[string]json.RawMessage `json:"paths"`
}
if err := json.Unmarshal(jsonResponse.Body.Bytes(), &document); err != nil {
t.Fatalf("decode embedded Swagger JSON: %v", err)
}
for _, path := range []string{
"/api-docs-json",
"/api/v1/public/skills/ai-gateway-ops-management/download",
"/api/admin/catalog/providers",
"/api/admin/catalog/base-models",
"/api/admin/platforms",
"/api/admin/runtime/policy-sets",
"/api/admin/pricing/rule-sets",
} {
if document.Paths[path] == nil {
t.Fatalf("embedded Swagger JSON missing %q", path)
}
}
yamlResponse := httptest.NewRecorder()
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api-docs-yaml", nil))
if yamlResponse.Code != http.StatusOK {
t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code)
}
if yamlResponse.Header().Get("Content-Type") != "application/yaml; charset=utf-8" {
t.Fatalf("unexpected YAML docs content type: %q", yamlResponse.Header().Get("Content-Type"))
}
if !strings.Contains(yamlResponse.Body.String(), "/api/v1/public/skills/ai-gateway-ops-management/metadata") {
t.Fatalf("embedded Swagger YAML missing operations skill metadata path")
}
}

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

@ -16,6 +16,17 @@ type ReadyResponse struct {
OK bool `json:"ok" example:"true"`
}
type SkillBundleMetadataResponse struct {
Name string `json:"name" example:"ai-gateway-ops-management"`
Version string `json:"version" example:"1.0.2"`
DisplayName string `json:"displayName" example:"AI Gateway 运维管理"`
Modules []string `json:"modules" example:"model-runtime"`
FileName string `json:"fileName" example:"ai-gateway-ops-management-v1.0.2.zip"`
DownloadPath string `json:"downloadPath" example:"/api/v1/public/skills/ai-gateway-ops-management/download"`
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api-docs-json"`
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api-docs-yaml"`
}
type ErrorEnvelope struct {
Error ErrorPayload `json:"error"`
}
@ -24,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 {
@ -182,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"`
@ -212,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

@ -121,6 +121,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
mux.HandleFunc("GET /api-docs-json", server.apiDocsJSON)
mux.HandleFunc("GET /api-docs-yaml", server.apiDocsYAML)
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/metadata", server.getOpsManagementSkillMetadata)
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/download", server.downloadOpsManagementSkill)
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))

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

@ -0,0 +1,122 @@
package skillbundle
import (
"archive/zip"
"bytes"
"embed"
"encoding/json"
"fmt"
"io/fs"
"path"
"regexp"
"sort"
"strings"
"time"
)
const (
Name = "ai-gateway-ops-management"
DisplayName = "AI Gateway 运维管理"
)
const bundleRoot = "content/skills/" + Name
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
//go:embed content/skills/ai-gateway-ops-management
var bundleFS embed.FS
type Metadata struct {
Name string `json:"name"`
Version string `json:"version"`
Modules []string `json:"modules"`
}
func LoadMetadata() (Metadata, error) {
raw, err := bundleFS.ReadFile(bundleRoot + "/skill.json")
if err != nil {
return Metadata{}, fmt.Errorf("read bundled skill metadata: %w", err)
}
var metadata Metadata
if err := json.Unmarshal(raw, &metadata); err != nil {
return Metadata{}, fmt.Errorf("parse bundled skill metadata: %w", err)
}
if metadata.Name != Name {
return Metadata{}, fmt.Errorf("bundled skill metadata name must be %q", Name)
}
if !semverPattern.MatchString(metadata.Version) {
return Metadata{}, fmt.Errorf("bundled skill metadata version %q is not semver", metadata.Version)
}
if len(metadata.Modules) == 0 {
return Metadata{}, fmt.Errorf("bundled skill metadata modules must not be empty")
}
seen := make(map[string]struct{}, len(metadata.Modules))
for _, module := range metadata.Modules {
module = strings.TrimSpace(module)
if module == "" {
return Metadata{}, fmt.Errorf("bundled skill metadata module must not be empty")
}
if _, exists := seen[module]; exists {
return Metadata{}, fmt.Errorf("bundled skill metadata module %q is duplicated", module)
}
seen[module] = struct{}{}
}
if _, err := bundleFS.ReadFile(bundleRoot + "/SKILL.md"); err != nil {
return Metadata{}, fmt.Errorf("read bundled SKILL.md: %w", err)
}
return metadata, nil
}
func FileName(metadata Metadata) string {
return fmt.Sprintf("%s-v%s.zip", metadata.Name, metadata.Version)
}
func BuildArchive() ([]byte, error) {
if _, err := LoadMetadata(); err != nil {
return nil, err
}
files := make([]string, 0)
if err := fs.WalkDir(bundleFS, bundleRoot, func(filePath string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
relativePath := strings.TrimPrefix(filePath, bundleRoot+"/")
if relativePath == "" || path.IsAbs(relativePath) || path.Clean(relativePath) != relativePath || strings.HasPrefix(relativePath, "../") {
return fmt.Errorf("unsafe bundled skill path %q", relativePath)
}
files = append(files, relativePath)
return nil
}); err != nil {
return nil, fmt.Errorf("walk bundled skill: %w", err)
}
sort.Strings(files)
var output bytes.Buffer
archive := zip.NewWriter(&output)
fixedTime := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
for _, relativePath := range files {
raw, err := bundleFS.ReadFile(bundleRoot + "/" + relativePath)
if err != nil {
_ = archive.Close()
return nil, fmt.Errorf("read bundled skill file %q: %w", relativePath, err)
}
header := &zip.FileHeader{Name: relativePath, Method: zip.Deflate}
header.SetModTime(fixedTime)
writer, err := archive.CreateHeader(header)
if err != nil {
_ = archive.Close()
return nil, fmt.Errorf("create bundled skill archive entry %q: %w", relativePath, err)
}
if _, err := writer.Write(raw); err != nil {
_ = archive.Close()
return nil, fmt.Errorf("write bundled skill archive entry %q: %w", relativePath, err)
}
}
if err := archive.Close(); err != nil {
return nil, fmt.Errorf("close bundled skill archive: %w", err)
}
return output.Bytes(), nil
}

View File

@ -0,0 +1,95 @@
package skillbundle
import (
"archive/zip"
"bytes"
"io"
"slices"
"strings"
"testing"
)
func TestLoadMetadata(t *testing.T) {
metadata, err := LoadMetadata()
if err != nil {
t.Fatalf("load metadata: %v", err)
}
if metadata.Name != Name || metadata.Version != "1.0.2" {
t.Fatalf("unexpected metadata: %+v", metadata)
}
if !slices.Equal(metadata.Modules, []string{"model-runtime"}) {
t.Fatalf("unexpected modules: %+v", metadata.Modules)
}
if FileName(metadata) != "ai-gateway-ops-management-v1.0.2.zip" {
t.Fatalf("unexpected file name: %s", FileName(metadata))
}
}
func TestBuildArchiveContainsOperationsSkill(t *testing.T) {
raw, err := BuildArchive()
if err != nil {
t.Fatalf("build archive: %v", err)
}
archive, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatalf("open archive: %v", err)
}
files := make(map[string]*zip.File, len(archive.File))
for _, file := range archive.File {
files[file.Name] = file
if strings.HasPrefix(file.Name, "/") || strings.Contains(file.Name, "..") {
t.Fatalf("unsafe archive path: %q", file.Name)
}
}
expected := []string{
"SKILL.md",
"agents/openai.yaml",
"skill.json",
"references/api-discovery-and-safety.md",
"references/model-providers-and-base-models.md",
"references/model-pricing-and-policies.md",
"references/model-billing-configuration-and-estimation.md",
"references/model-platforms-and-bindings.md",
"references/model-universal-platforms.md",
"references/model-acceptance-runbook.md",
}
for _, name := range expected {
if files[name] == nil {
t.Fatalf("archive missing %q; files=%v", name, archiveFileNames(archive.File))
}
}
skillFile, err := files["SKILL.md"].Open()
if err != nil {
t.Fatalf("open SKILL.md: %v", err)
}
defer skillFile.Close()
skillContent, err := io.ReadAll(skillFile)
if err != nil {
t.Fatalf("read SKILL.md: %v", err)
}
if !strings.Contains(string(skillContent), "name: "+Name) {
t.Fatalf("SKILL.md frontmatter has unexpected name")
}
billingFile, err := files["references/model-billing-configuration-and-estimation.md"].Open()
if err != nil {
t.Fatalf("open billing reference: %v", err)
}
defer billingFile.Close()
billingContent, err := io.ReadAll(billingFile)
if err != nil {
t.Fatalf("read billing reference: %v", err)
}
for _, required := range []string{"/api/v1/pricing/estimate", "finalChargeAmount", "transactionType=task_billing"} {
if !strings.Contains(string(billingContent), required) {
t.Fatalf("billing reference missing %q", required)
}
}
}
func archiveFileNames(files []*zip.File) []string {
names := make([]string, 0, len(files))
for _, file := range files {
names = append(names, file.Name)
}
return names
}

View File

@ -0,0 +1,55 @@
---
name: ai-gateway-ops-management
description: Operate and verify EasyAI AI Gateway administration capabilities. Use when Codex or an Agent needs to inspect, create, update, disable, restore, or troubleshoot AI Gateway providers, base models, pricing and billing configuration, price estimates and wallet deduction reconciliation, runtime policies, runner policies, integration platforms, platform-model bindings, or universal custom-script platforms; also use this skill as the extensible entry point for future AI Gateway operations modules.
---
# AI Gateway Operations Management
Use this skill to operate AI Gateway administration APIs through documented, evidence-first workflows.
## Operating Rules
- Obtain the Gateway API base URL and an administrator JWT before calling management APIs. Admin APIs reject `sk-*` API keys.
- Read `references/api-discovery-and-safety.md` before any write operation.
- Select only the references for the requested module. Do not load unrelated future operations modules.
- Read current state before changing it. Treat PATCH bodies as complete resource configurations unless the reference explicitly says otherwise.
- Never write credentials, tokens, script `authValues`, raw upstream responses, or other secrets into Skill files, logs, commands shown to users, or final summaries.
- Execute ordinary creates and updates only when the user requested the change. Before DELETE, full replacement, bulk reset, platform disablement, or credential clearing, show the current snapshot and impact and obtain explicit confirmation.
- Reuse existing pricing rules, runtime policy sets, providers, protocol clients, base models, and platforms whenever their effective behavior satisfies the target. Do not create a near-duplicate resource merely because the upstream account, base URL, or provider-side model name differs.
- Prefer a supported standard client before using `universal` scripts. Use custom scripts only when the upstream contract cannot be represented by the existing OpenAI, Gemini, or provider-specific clients.
- Do not invent platform config fields or assume an arbitrary config key is enforced. For `universal`, use only the recognized keys documented in `references/model-universal-platforms.md`; treat any extra key as script-owned data available through `context.env`.
- Use the module references as the primary API source. Only when the required API is absent, inspect `<gateway-api-base-url>/api-docs-json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous.
## Module Routing
### Model Runtime
Use these references for the current v1 module:
- `references/model-providers-and-base-models.md`: provider catalog and base-model lifecycle.
- `references/model-pricing-and-policies.md`: pricing rule sets, runtime policy sets, runner policy, priority, and recovery.
- `references/model-billing-configuration-and-estimation.md`: effective billing configuration, price-estimate calls, simulation/real charge comparison, and wallet reconciliation.
- `references/model-platforms-and-bindings.md`: integration platforms, credentials, platform-model upsert, replacement, and deletion.
- `references/model-universal-platforms.md`: `universal` custom platform triage, configuration, script contracts, and examples.
- `references/model-acceptance-runbook.md`: read-back, catalog, simulation, runtime, billing, and rollback verification.
## Standard Workflow
1. Read `references/api-discovery-and-safety.md` and confirm the API base URL, identity mode, administrator JWT, target documentation, and required credentials.
2. Read the current provider, base-model, pricing, policy, platform, and platform-model records relevant to the request.
3. Reuse an existing pricing rule when its base prices and calculators, combined with the effective platform or platform-model discount, produce the required price. Create a pricing rule only when that combination cannot represent the target.
4. Reuse an existing runtime policy set when its limits, scopes, retry, auto-disable, and degradation behavior match the requirement. Create a policy only for a real semantic difference.
5. Classify the upstream protocol against existing platforms and clients. If compatible, keep the existing `specType` and change only instance configuration such as `baseUrl`, credentials, and bindings. Use `universal` only for an unsupported protocol.
6. Reuse the base model across platforms. Put a platform-specific upstream invocation name in the platform-model `providerModelName`; do not duplicate the base model just because providers use different names.
7. Apply the minimum changes in dependency order: provider, base model, pricing/policy, platform, platform-model binding.
8. Read every changed resource back and verify the effective model catalog.
9. For billing work, read `references/model-billing-configuration-and-estimation.md`, call `/api/v1/pricing/estimate` with the same user identity and request parameters intended for execution, and reconcile the returned candidate, line items, discounts, quantities, and total.
10. Run a simulation or approved real request only after the estimate is accepted. Inspect task billing and wallet transactions; do not assume simulation is free or side-effect-free.
11. Report reused and created resource IDs, effective pricing evidence, protocol-fit evidence, verification results, unresolved risks, and whether the Swagger fallback was used, without exposing secrets.
## Extending This Skill
- Add future modules as one-level files under `references/` with stable prefixes such as `storage-`, `runtime-`, `identity-`, or `network-`.
- Add the module to this routing section and to `skill.json`.
- Increment the `skill.json` version whenever downloadable contents change.
- Keep the same skill name and public download route.

View File

@ -0,0 +1,4 @@
interface:
display_name: "AI Gateway 运维管理"
short_description: "管理 AI Gateway 模型、平台、定价与运行策略"
default_prompt: "Use $ai-gateway-ops-management to inspect and safely operate the AI Gateway administration APIs."

View File

@ -0,0 +1,73 @@
# API Discovery and Safety
## Required Inputs
- Gateway API base URL. When using the bundled Web deployment this commonly includes `/gateway-api`; direct API access commonly uses port `8088`.
- Administrator JWT with the `manager` or `admin` role.
- Target provider documentation and authorization material.
- Clear requested outcome and whether real upstream calls are allowed.
Do not place credentials in files or reusable commands. Use shell environment variables:
```bash
export GATEWAY_BASE_URL='https://gateway.example.com/gateway-api'
export GATEWAY_ADMIN_TOKEN='<administrator-jwt>'
```
## Authentication
Management endpoints under `/api/admin/*` accept administrator user credentials only. A local or server-main `sk-*` API key is rejected even if it has broad model scopes.
For standalone or hybrid deployments, local login can return a JWT:
```bash
curl --fail-with-body \
-H 'Content-Type: application/json' \
-d '{"account":"<admin-account>","password":"<admin-password>"}' \
"$GATEWAY_BASE_URL/api/v1/auth/login"
```
Do not use local login when the deployment requires OIDC or server-main identity. Obtain the deployment's administrator access token instead.
Verify identity and role before writes:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/v1/me"
```
## Request Pattern
Use a temporary request file or a carefully quoted inline body without printing secrets:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-X POST \
-d '<json-body>' \
"$GATEWAY_BASE_URL/api/admin/<resource>"
```
Always read current state before PATCH, DELETE, reset, disable, or full replacement. PATCH handlers for providers, base models, pricing rule sets, runtime policy sets, runner policy, and platforms write complete resource shapes rather than merging every omitted field.
## High-impact Operations
Obtain explicit confirmation after showing the current snapshot and impact before:
- Any DELETE request.
- `POST /api/admin/catalog/base-models/reset-all`.
- `PUT /api/admin/platforms/{platformID}/models`.
- Changing a platform status to `disabled`.
- Sending an empty `credentials` object to clear stored credentials.
- Replacing pricing rules or policy contents in a way that removes existing entries.
## Full API Fallback
The live machine-readable documents are:
- `<gateway-api-base-url>/api-docs-json`
- `<gateway-api-base-url>/api-docs-yaml`
Use them only when this Skill does not document the required operation. Before acting, confirm the exact path, method, body, authentication, permission, response, and side effect. Do not infer a write operation from a similarly named endpoint.

View File

@ -0,0 +1,85 @@
# Model Runtime Acceptance Runbook
## Before Changes
- Record the target deployment and identity mode.
- Confirm administrator identity with `/api/v1/me`.
- Save current provider, base model, pricing rule set, policy set, platform, and platform-model JSON without secrets.
- Confirm whether real upstream calls are allowed; otherwise use simulation.
- Confirm upstream endpoint, auth, model name, capabilities, limits, pricing, and sync/async behavior from documentation.
- Record which existing pricing rule and runtime policy were reused, or the exact semantic mismatch that required a new one.
- For billing changes, save the current effective rule bindings, discounts, user-group policy, wallet balance, and one representative estimate response as described in `model-billing-configuration-and-estimation.md`.
- Record the protocol compatibility decision and why the selected existing `specType` is sufficient, or the concrete gap that requires `universal`.
- When one base model has different upstream names across platforms, confirm each binding resolves the expected `providerModelName`.
## After Configuration
Read back:
- `/api/admin/catalog/providers`
- `/api/admin/catalog/base-models`
- `/api/admin/pricing/rule-sets`
- `/api/admin/runtime/policy-sets`
- `/api/admin/runtime/runner-policy`
- `/api/admin/platforms`
- `/api/admin/models`
Verify that IDs and bindings resolve as intended and no unrelated record was removed or reset.
## Catalog Verification
Use an authorized user JWT:
```bash
curl --fail-with-body \
-H "Authorization: Bearer <user-jwt>" \
"$GATEWAY_BASE_URL/api/v1/model-catalog"
```
Confirm model alias, model types, provider source, effective capabilities, pricing summary, rate limits, permissions, and enabled state.
For pricing, verify the effective rule source and discount source rather than checking IDs only. Confirm whether the platform-model discount overrides the platform default, and compare the estimated or simulated amount with the intended price.
Use `/api/v1/pricing/estimate` for the first read-only calculation. A simulation request is a task execution path and may reserve and settle wallet billing; do not treat it as a read-only replacement for the estimate endpoint.
## Execution Verification
Start with simulation:
```bash
curl --fail-with-body \
-H "Authorization: Bearer <user-jwt-or-approved-api-key>" \
-H 'Content-Type: application/json' \
-d '{
"model": "<model-alias>",
"messages": [{"role":"user","content":"Reply with OK"}],
"runMode": "simulation",
"simulation": true,
"stream": false
}' \
"$GATEWAY_BASE_URL/v1/chat/completions"
```
Simulation verifies Gateway routing, permissions, parameter normalization, pricing, and task behavior, but it does not execute the real universal submit or poll scripts. Validate universal scripts separately against a local mock or approved provider test environment before enabling production traffic.
For media or universal scripts, inspect:
- `/api/workspace/tasks/{taskID}`
- `/api/workspace/tasks/{taskID}/events`
- `/api/workspace/tasks/{taskID}/param-preprocessing`
- `/api/admin/runtime/model-rate-limits`
- `/api/admin/runtime/rate-limit-windows`
With explicit approval, run one real minimal request and verify upstream request ID, normalized output, task completion, `billings`, `billingSummary.totalAmount`, `finalChargeAmount`, and the wallet `task_billing` transaction. Explain any expected estimate difference caused by actual token usage, cached input, generated media duration/audio, preprocessing, or failover to another platform.
## Rollback
- Restore the prior complete resource body with PATCH.
- Restore an individual platform-model binding through POST.
- Use full platform-model PUT only when the saved list is complete and replacement is intentional.
- Remove newly created resources in reverse dependency order only after explicit confirmation.
- Use runtime restore only after the upstream problem is resolved.
## Final Report
Report resource IDs, before/after behavior, requests used for verification, simulation or real mode, billing evidence, remaining risks, rollback readiness, and whether `/api-docs-json` was used. Never include credentials or raw secret-bearing payloads.

View File

@ -0,0 +1,249 @@
# Model Billing Configuration and Estimate Reconciliation
## Contents
- Safety and Identity
- Effective Billing Layers
- Billing Configuration Workflow
- Price Estimate Requests
- Estimate Reconciliation
- Task and Wallet Charge Verification
- Expected Differences and Troubleshooting
## Safety and Identity
- Use an administrator JWT with `manager` permission for pricing, base-model, platform, platform-model, or user-group writes. Do not use an `sk-*` API key for management APIs.
- Call `POST /api/v1/pricing/estimate` with the same user JWT or API key that will execute the real request. The subject controls access rules, candidate visibility, API-key scopes, and user-group billing discount.
- The estimate endpoint is read-only for tasks and wallets: it selects and preprocesses a candidate and returns simulated billing lines, but it does not create a task, freeze balance, or debit the wallet.
- A request with `runMode: "simulation"` is different: it enters the task execution and billing path and may create task records, reserve wallet balance, and settle a charge. Use the estimate endpoint first and obtain approval before any task request intended only for billing verification.
- Never expose API keys, administrator JWTs, wallet identifiers, or credentials in reports.
## Effective Billing Layers
Read every applicable layer before changing one:
1. Base model: `pricingRuleSetId` and `baseBillingConfig`.
2. Platform: `pricingRuleSetId`, `defaultPricingMode`, and `defaultDiscountFactor`.
3. Platform model: `pricingRuleSetId`, `billingConfigOverride`, `pricingMode`, and `discountFactor`.
4. User group: `billingDiscountPolicy.discountFactor`.
The runtime resolves an effective billing configuration, then applies discounts. An explicit platform-model rule and `billingConfigOverride` can replace or merge inherited values. Do not infer the final price from one ID; confirm it with an estimate.
The current discount behavior is:
```text
candidate discount = positive platform-model discountFactor
else platform defaultDiscountFactor
effective discount = candidate discount × user-group billingDiscountPolicy.discountFactor
```
The platform and platform-model discount factors do not multiply together. The user-group factor, when positive, multiplies the selected candidate discount.
Pricing rule resource mappings include:
| `resourceType` | Effective use |
| --- | --- |
| `text_input` | uncached input token price per 1,000 tokens |
| `text_cached_input` | cached input token price per 1,000 tokens |
| `text_output` | output token price per 1,000 tokens |
| `text_total` | text prices from `basePrice` and optional `formulaConfig` |
| `image` | image generation base price and dynamic weights |
| `image_edit` | image-edit price; image pricing is a fallback when absent |
| `video` | price per five-second unit plus dynamic weights |
| other types | generic resource base price and weights, such as music or audio |
Common `dynamicWeight` dimensions are `qualityWeights`, `sizeWeights`, `resolutionWeights`, `audioWeights`, `referenceVideoWeights`, and `voiceSpecifiedWeights`. Configure only dimensions used by the runtime and proven by the target product pricing.
## Billing Configuration Workflow
### 1. Capture the current state
```bash
curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/pricing/rule-sets"
curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/catalog/base-models"
curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/platforms"
curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/models"
curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/user-groups"
```
Use the runtime subject credential to read its effective group policy and wallet snapshot:
```bash
curl --fail-with-body -H "Authorization: Bearer $RUNTIME_TOKEN" \
"$GATEWAY_BASE_URL/api/workspace/user-groups"
curl --fail-with-body -H "Authorization: Bearer $RUNTIME_TOKEN" \
"$GATEWAY_BASE_URL/api/workspace/wallet"
```
Save IDs and non-secret fields. Record the wallet balance and frozen balance only when a later task charge will be verified.
### 2. Reuse or create the pricing rule
Compare active rules by resource type, unit, base price, `dynamicWeight`, `formulaConfig`, calculator type, currency, and status. Reuse an existing rule when its effective amount can be adjusted with platform or platform-model discounts. Read `model-pricing-and-policies.md` for the rule-set request shape.
Create a new rule only when no existing rule can express the required unit, formula, dimensions, or undiscounted price. `PATCH /api/admin/pricing/rule-sets/{ruleSetID}` replaces all rules in the set, so preserve every required row.
### 3. Bind the minimum required layer
- Put a reusable default rule on the base model with `pricingRuleSetId`.
- Put account-wide pricing on the platform with `pricingRuleSetId` and `defaultDiscountFactor`.
- Put a real per-model exception on the platform model with `pricingRuleSetId`, `discountFactor`, or `billingConfigOverride`.
- Use a user-group `billingDiscountPolicy.discountFactor` only for a subject-wide discount. Read the complete user-group record before PATCH and preserve unrelated rate-limit, quota, recharge, metadata, and status fields.
Avoid copying the same prices into multiple layers. Read back every changed record before estimating.
## Price Estimate Requests
`POST /api/v1/pricing/estimate` accepts a normal request-shaped JSON object plus `kind`. `kind` defaults to `chat.completions`. An API key must have the scope required by the selected kind.
### Chat estimate
```bash
curl --fail-with-body \
-H "Authorization: Bearer $RUNTIME_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"kind": "chat.completions",
"model": "<model-alias>",
"messages": [{"role": "user", "content": "Reply with OK"}],
"max_tokens": 256
}' \
"$GATEWAY_BASE_URL/api/v1/pricing/estimate"
```
Text input tokens are estimated from the request. Output tokens use `max_tokens`; when it is absent or zero, the estimate currently uses 64 output tokens. Use the intended output limit for a meaningful upper-bound comparison.
### Image estimate
```bash
curl --fail-with-body \
-H "Authorization: Bearer $RUNTIME_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"kind": "images.generations",
"model": "<model-alias>",
"prompt": "A small orange cat",
"n": 2,
"size": "1024x1024",
"quality": "high"
}' \
"$GATEWAY_BASE_URL/api/v1/pricing/estimate"
```
### Video estimate
```bash
curl --fail-with-body \
-H "Authorization: Bearer $RUNTIME_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"kind": "videos.generations",
"model": "<model-alias>",
"prompt": "A slow camera move over a lake",
"duration": 12,
"resolution": "1080p",
"audio": true,
"n": 1
}' \
"$GATEWAY_BASE_URL/api/v1/pricing/estimate"
```
The response has this shape:
```json
{
"items": [
{
"model": "example-model",
"modelAlias": "example-model",
"provider": "example",
"platformId": "<platform-id>",
"platformModelId": "<platform-model-id>",
"resourceType": "video",
"unit": "5s_video",
"quantity": 3,
"amount": 12.5,
"currency": "resource",
"discountFactor": 0.8,
"simulated": true,
"durationSeconds": 12,
"durationUnitCount": 3
}
],
"resolver": "effective-pricing-v1",
"totalAmount": 12.5,
"currency": "resource"
}
```
## Estimate Reconciliation
For every estimate, verify:
1. `platformId` and `platformModelId` identify the intended first eligible candidate after permissions, capabilities, output-token limits, priority, and runtime availability are applied.
2. `resourceType`, `unit`, `quantity`, and dimension details match the normalized request.
3. `discountFactor` equals the selected platform/platform-model factor multiplied by the effective user-group factor.
4. Each `amount` matches the configured base price, weights, quantity, and discount.
5. `totalAmount` equals the rounded sum of `items[].amount`, and `currency` is expected.
Useful calculation checks:
```text
text input = input tokens / 1000 × input price × discount
text output = output tokens / 1000 × output price × discount
image = count × base price × quality/size/resolution weights × discount
video = count × ceil(duration seconds / 5) × base price × applicable weights × discount
speech = Unicode character count × audio price × discount
```
Cached input is normally known only after execution. When cached input exists but has no configured price, the runtime currently falls back to one tenth of the normal input price.
If the estimate selects an unexpected platform, do not edit prices to hide the routing problem. Inspect access rules, enabled state, model type, capabilities, priority, cooldown/load, and output-token limits first.
## Task and Wallet Charge Verification
After the read-only estimate is accepted, obtain explicit approval before a simulation or real task if wallet mutation is possible.
1. Record `GET /api/workspace/wallet` before the request.
2. Submit the exact same `kind`, model, and billing-relevant parameters through the corresponding runtime API.
3. Read `GET /api/workspace/tasks/{taskID}` and capture:
- `billings`;
- `billingSummary.totalAmount` and currency;
- `finalChargeAmount`;
- resolved model and candidate evidence from metrics.
4. Query the final wallet debit:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $RUNTIME_TOKEN" \
"$GATEWAY_BASE_URL/api/workspace/wallet/transactions?q=$TASK_ID&transactionType=task_billing&pageSize=20"
```
5. Read the wallet again and compare:
```text
task finalChargeAmount == billingSummary.totalAmount
task finalChargeAmount == task_billing transaction amount
wallet balance before - wallet balance after == task_billing transaction amount
```
The task may also create `reserve` and `release` transactions. Reservation changes frozen balance, not the spend balance; `task_billing` is the final debit to reconcile. Use the task ID as the reference and never sum `reserve`, `release`, and `task_billing` as three charges.
## Expected Differences and Troubleshooting
An estimate and final charge may legitimately differ when:
- actual text input/output or cached-input usage differs from the estimate;
- the request omitted `max_tokens`, so the estimate used the 64-token default;
- generated video duration or audio presence overrides the requested/preprocessed value;
- preprocessing normalizes duration, resolution, count, or model-specific fields;
- the first candidate fails and execution settles on another platform with different pricing;
- the estimate and task used different users, API keys, scopes, groups, or access rules;
- pricing or discount configuration changed between estimate and execution.
When the difference is unexplained, compare the estimate line, task `billings`, task preprocessing log, candidate metrics, effective user group, wallet transaction metadata, and configuration timestamps. Do not change wallet balances to make a mismatch disappear.

View File

@ -0,0 +1,123 @@
# Platforms and Platform-model Bindings
## Read Current State
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/platforms"
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/models"
```
Platform responses expose masked `credentialsPreview`, not stored secrets.
## Reuse-compatible Platforms First
Before creating a platform or selecting `universal`:
1. Compare the upstream authentication, endpoint paths, content type, request schema, response schema, and sync/async lifecycle with the clients already represented by existing platform `config.specType` values.
2. If the upstream is compatible with an existing OpenAI, Gemini, or provider-specific client, keep that client type. For another compatible endpoint or account, the protocol layer normally needs only a different `baseUrl`; credentials and account-specific settings remain ordinary platform instance configuration.
3. Reuse the existing platform record only when it represents the same logical account/endpoint and changing it will not redirect unrelated models. Otherwise create another standard platform instance with the same compatible `specType`.
4. Use `universal` only when a documented request, authentication, task lifecycle, or response-mapping requirement cannot be expressed by a standard client.
Do not convert a compatible platform to `universal` merely because the host name, account, or provider-side model name differs.
## Create a Standard Platform
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"provider": "example-openai",
"platformKey": "example-openai-primary",
"name": "Example OpenAI Primary",
"internalName": "example-openai-primary",
"baseUrl": "https://api.example.com/v1",
"authType": "bearer",
"credentials": {"apiKey": "<provider-api-key>"},
"config": {"specType": "openai"},
"retryPolicy": {},
"rateLimitPolicy": {},
"defaultPricingMode": "inherit_discount",
"defaultDiscountFactor": 1,
"pricingRuleSetId": "",
"priority": 100,
"status": "enabled"
}' \
"$GATEWAY_BASE_URL/api/admin/platforms"
```
Use `PATCH /api/admin/platforms/{platformID}` with the complete platform body. Omit `credentials` to preserve existing secrets. A non-empty credentials object is merged into stored credentials. An empty object clears credentials and requires explicit confirmation.
## Upsert One Platform Model
`POST /api/admin/platforms/{platformID}/models` inserts or updates the `(platformID, modelName)` binding:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"baseModelId": "<base-model-id>",
"canonicalModelKey": "example-openai:example-chat",
"modelName": "example-chat",
"providerModelName": "example-chat",
"modelAlias": "example-chat",
"modelType": ["text_generate"],
"displayName": "Example Chat",
"capabilityOverride": {},
"pricingMode": "inherit_discount",
"discountFactor": 1,
"pricingRuleSetId": "",
"billingConfigOverride": {},
"permissionConfig": {},
"retryPolicy": {},
"rateLimitPolicy": {},
"runtimePolicySetId": "",
"runtimePolicyOverride": {},
"enabled": true
}' \
"$GATEWAY_BASE_URL/api/admin/platforms/<platform-id>/models"
```
When fields are omitted, defaults may be derived from the base model. For predictable updates, read the current binding and send the intended complete configuration.
### Override the real upstream invocation name
Use the base model as the stable system identity and `providerModelName` as the platform-specific name sent to the upstream API:
- `modelName` is the platform binding key and participates in the `(platformId, modelName)` upsert identity.
- `providerModelName` is the real model/deployment name used by the selected runtime client.
- Bind the same `baseModelId` on multiple platforms and set a different `providerModelName` on each binding when providers expose different invocation names.
- Do not create duplicate base models solely for aliases such as a vendor deployment ID, endpoint ID, dated model ID, or regional model name.
Example:
```json
[
{
"platform": "platform-a",
"baseModelId": "base-example-chat",
"modelName": "example-chat",
"providerModelName": "example-chat-2026-07"
},
{
"platform": "platform-b",
"baseModelId": "base-example-chat",
"modelName": "example-chat",
"providerModelName": "deployment-prod-42"
}
]
```
The current create/upsert implementation stores the platform-model binding as enabled even when the request contains `"enabled": false`. Do not rely on that input field for canary isolation. Use an isolated test deployment or keep the whole platform disabled until configuration review is complete; enable the platform only for an approved protocol test.
## Full Replacement and Deletion
`PUT /api/admin/platforms/{platformID}/models` reconciles the platform to exactly the supplied `models` list. Omitted bindings are deleted together with their platform-model access rules. Never use it for a single-model update.
Delete one binding with `DELETE /api/admin/platform-models/{modelID}` after confirmation. Delete a platform with `DELETE /api/admin/platforms/{platformID}` only after reviewing all bindings and access-rule impact.

View File

@ -0,0 +1,139 @@
# Model Pricing and Policies
## Contents
- Pricing Rule Sets
- Billing Configuration and Estimate Reconciliation
- Runtime Policy Sets
- Runner Policy and Runtime Recovery
## Pricing Rule Sets
Read rule sets and effective rule rows:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/pricing/rule-sets"
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/pricing/rules"
```
### Reuse-first pricing decision
Do not create a pricing rule until all existing active rule sets have been compared against the target resource types, units, calculator types, base prices, weights, and conditions.
Evaluate the price that the runtime will actually use:
1. Identify the inherited pricing source from the base model and platform, then check whether the platform-model has an explicit `pricingRuleSetId` or billing override.
2. Reuse the existing rule when it already describes the required raw unit prices and calculators.
3. Apply the effective discount:
- a positive platform-model `discountFactor` takes precedence;
- otherwise `pricingMode: "inherit_discount"` uses the platform `defaultDiscountFactor`;
- AI Gateway does not multiply the platform and platform-model discount factors together.
4. Verify the resulting amount with `/api/v1/pricing/estimate`, simulation billing lines, or an approved test request.
5. Create a new rule set only when no existing rule plus the available platform/platform-model discount can produce the required price or express the required unit/calculator structure.
Do not clone an otherwise identical pricing rule merely to represent a provider or account discount. Keep the reusable base price in the rule and express the instance-specific adjustment with `defaultDiscountFactor` or `discountFactor`.
For the complete binding, estimate, task-charge, and wallet-reconciliation workflow, read `model-billing-configuration-and-estimation.md` before changing billing settings.
Create an example text pricing rule set:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"ruleSetKey": "example-chat-pricing-v1",
"name": "Example Chat Pricing",
"description": "Input and output token pricing",
"category": "model",
"currency": "resource",
"status": "active",
"metadata": {},
"rules": [
{
"ruleKey": "input",
"displayName": "Input tokens",
"resourceType": "text_input",
"unit": "1k_tokens",
"basePrice": 0.001,
"calculatorType": "token_usage",
"priority": 10,
"status": "active"
},
{
"ruleKey": "output",
"displayName": "Output tokens",
"resourceType": "text_output",
"unit": "1k_tokens",
"basePrice": 0.002,
"calculatorType": "token_usage",
"priority": 20,
"status": "active"
}
]
}' \
"$GATEWAY_BASE_URL/api/admin/pricing/rule-sets"
```
`PATCH /api/admin/pricing/rule-sets/{ruleSetID}` replaces the stored rules with the supplied list. Preserve every rule that should remain. Delete only non-default rule sets after checking base-model, platform, and platform-model bindings.
## Runtime Policy Sets
Read and create policies:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/runtime/policy-sets"
```
Compare the existing policy sets before creating one. Reuse a policy when the effective rate-limit metrics, limits, windows, concurrency lease TTL, retry count, auto-disable behavior, degradation behavior, and scope semantics match the target. A different platform name or model name alone is not a reason to duplicate a policy.
Create a new policy only when at least one required behavior cannot be represented by an existing policy plus the supported platform-model override fields. Record the mismatch that justified the new policy.
```json
{
"policyKey": "example-balanced-v1",
"name": "Example Balanced",
"description": "Retry and concurrency policy",
"rateLimitPolicy": {
"rules": [
{"metric": "rpm", "limit": 60, "windowSeconds": 60},
{"metric": "concurrent", "limit": 5, "leaseTtlSeconds": 120}
]
},
"retryPolicy": {"maxRetries": 2},
"autoDisablePolicy": {},
"degradePolicy": {},
"metadata": {},
"status": "active"
}
```
POST the body to `/api/admin/runtime/policy-sets`. Use PATCH with a complete body. Default policy sets cannot be deleted.
## Runner Policy and Runtime Recovery
Read the current global runner policy before changing it:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/runtime/runner-policy"
```
PATCH the same endpoint with the complete current fields plus approved changes to `failoverPolicy`, `hardStopPolicy`, `singleSourcePolicy`, or `cacheAffinityPolicy`.
Operational endpoints:
- `PATCH /api/admin/platforms/{platformID}/dynamic-priority`
- `GET /api/admin/runtime/rate-limit-windows`
- `GET /api/admin/runtime/model-rate-limits`
- `POST /api/admin/runtime/model-rate-limits/{platformModelID}/restore`
Use restore only after identifying whether the model, platform cooldown, or platform disablement was automatic and whether the upstream condition has recovered.

View File

@ -0,0 +1,87 @@
# Model Providers and Base Models
## Read Current State
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/catalog/providers"
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/admin/catalog/base-models"
```
Public read-only catalog endpoints also exist at `/api/v1/public/catalog/providers` and `/api/v1/public/catalog/base-models`.
## Provider Catalog
Create a provider:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"providerKey": "example-openai",
"code": "example-openai",
"displayName": "Example OpenAI Compatible",
"providerType": "openai",
"defaultBaseUrl": "https://api.example.com/v1",
"defaultAuthType": "APIKey",
"source": "gateway",
"capabilitySchema": {},
"defaultRateLimitPolicy": {},
"metadata": {"protocol": "openai-compatible"},
"status": "active"
}' \
"$GATEWAY_BASE_URL/api/admin/catalog/providers"
```
Use `PATCH /api/admin/catalog/providers/{providerID}` with the complete current provider body to update. `providerType` selects the runtime client through the platform candidate. Common supported values include `openai`, `gemini`, `volces`, `keling`, `minimax`, and `universal`. Unknown types may fall back to the OpenAI client, so never leave a custom integration type ambiguous.
Delete with `DELETE /api/admin/catalog/providers/{providerID}` only after checking platform references and obtaining confirmation.
## Base Model
Create a base model after its provider exists:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"providerKey": "example-openai",
"canonicalModelKey": "example-openai:example-chat",
"providerModelName": "example-chat",
"modelType": ["text_generate"],
"modelAlias": "example-chat",
"displayName": "Example Chat",
"capabilities": {
"text_generate": {
"supportedApiProtocols": ["openai_chat_completions"]
}
},
"baseBillingConfig": {
"textInputPer1k": 0.001,
"textOutputPer1k": 0.002
},
"defaultRateLimitPolicy": {},
"runtimePolicyOverride": {},
"metadata": {"description": "Example OpenAI-compatible chat model"},
"catalogType": "custom",
"defaultSnapshot": {},
"pricingVersion": 1,
"status": "active"
}' \
"$GATEWAY_BASE_URL/api/admin/catalog/base-models"
```
Use `PATCH /api/admin/catalog/base-models/{baseModelID}` with a complete body to update. Do not omit capabilities, billing, policy bindings, metadata, or status unintentionally.
Reset endpoints are destructive:
- `POST /api/admin/catalog/base-models/{baseModelID}/reset`
- `POST /api/admin/catalog/base-models/reset-all`
Only system-seeded models with default snapshots can be reset. Delete with `DELETE /api/admin/catalog/base-models/{baseModelID}` only after checking platform-model bindings.

View File

@ -0,0 +1,92 @@
# Universal Custom Platforms
## Selection Rule
Use `universal` only when documented upstream requirements cannot be represented by an existing standard client. Prefer:
1. `openai` for OpenAI-compatible chat, Responses, embeddings, reranks, image generation, or image editing.
2. `gemini` for Gemini `generateContent` contracts.
3. A provider-specific client such as `volces`, `keling`, or `minimax` when implemented.
4. `universal` for non-standard auth, submit/poll lifecycles, payload shaping, or response mapping.
Set the provider catalog `providerType` or platform `config.specType` explicitly to `universal`. An unknown type may fall back to OpenAI behavior.
Do not assume a new platform-model binding can be staged with `enabled: false`; the current upsert path enables it. Isolate tests with the platform status or a dedicated non-production deployment.
## Platform Configuration
Gateway directly recognizes these universal settings in the platform `config` object:
- `specType`
- `submitPath`
- `getTaskURL`
- `pollIntervalMs`
- `pollTimeoutMs` or `timeoutMs`
- `skipParamNormalization`
- `customPreprocessScript`
- `customGetParamsScript`
- `customSubmitScript`
- `customPollScript`
Do not invent additional enforcement fields. Other config keys are only exposed as data through `context.env` unless a custom script explicitly reads and enforces them.
```json
{
"specType": "universal",
"submitPath": "/video/generations",
"getTaskURL": "https://provider.example/tasks/{upstream_task_id}",
"pollIntervalMs": 2000,
"pollTimeoutMs": 600000,
"skipParamNormalization": false,
"customGetParamsScript": {
"video_generate": "function getGenerateParams(params, context) { return { model: context.options.providerModelName, prompt: params.prompt }; }"
},
"customSubmitScript": {
"video_generate": "async function submitTask(payload, context) { const response = await got.post(context.createRequestURL('/video/generations'), { json: payload, headers: { Authorization: 'Bearer ' + context.authValues.apiKey } }).json(); const taskId = String(response.id || ''); if (!/^[A-Za-z0-9._:-]{1,200}$/.test(taskId)) return { status: 'failed', code: 'invalid_response', message: 'invalid upstream task id' }; return { status: 'submitted', task_id: taskId }; }"
},
"customPollScript": {
"video_generate": "async function pollTask(taskId, context) { if (!/^[A-Za-z0-9._:-]{1,200}$/.test(taskId)) return { status: 'failed', code: 'invalid_response', message: 'invalid upstream task id' }; const response = await got.get(context.resolveGetTaskURL(taskId), { headers: { Authorization: 'Bearer ' + context.authValues.apiKey } }).json(); if (response.status === 'done') { const resultUrl = String(response.url || ''); if (!/^https:\\/\\/cdn\\.video\\.example(?:\\/|$)/.test(resultUrl)) return { status: 'failed', code: 'invalid_response', message: 'untrusted result URL' }; return { status: 'succeeded', data: [{ url: resultUrl }] }; } if (response.status === 'failed') return { status: 'failed', code: 'provider_failed', message: String(response.message || 'provider task failed').slice(0, 300) }; if (response.status === 'queued' || response.status === 'processing' || response.status === 'running') return { status: 'processing' }; return { status: 'failed', code: 'invalid_response', message: 'unknown upstream status' }; }"
}
}
```
Script values may be a string applied to all model types or an object keyed by model type with optional `common`.
## Script Contracts
- Preprocess: `(params, type, context)`; return an object merged into normalized parameters. Preferred names include `preprocessParams`, `preprocess`, `main`, and `handler`.
- Get params: `(params, context)`; return the upstream payload object. Preferred names include `getGenerateParams`, `getParams`, `main`, and `handler`.
- Submit: `(payload, context)`; return final success data or an asynchronous task ID. Preferred names include `submitTask`, `submitParams`, `submit`, `main`, and `handler`.
- Poll: `(upstreamTaskID, context)`; return processing, success, or failure state. Preferred names include `pollTask`, `poll`, `main`, and `handler`.
The context includes `baseURL`, `getTaskURL`, `authValues`, `headers`, `payload`, `type`, `options`, `env`, `candidate`, `createRequestURL`, and `resolveGetTaskURL`. The runtime also exposes `fetch`, `got`, and `FormData`.
If a result URL must be restricted, implement the HTTPS and host allowlist check inside the submit or poll script before returning `data`. Merely adding an `allowedResultUrlPrefixes`-style config field has no built-in effect. Do not convert HTTP failures into `processing` unless the upstream documentation proves that retrying the poll is safe.
Do not assume Node.js or browser globals exist. The runtime does not install `URL`, `Buffer`, `process`, `require`, or Node modules. Use the provided context helpers, `fetch`, `got`, `FormData`, and standard ECMAScript string or regular-expression checks.
## Result Shapes
Synchronous success:
```json
{"status":"succeeded","data":[{"url":"https://provider.example/result.png"}]}
```
Asynchronous submit:
```json
{"status":"submitted","task_id":"remote-task-id"}
```
Poll success or failure:
```json
{"status":"succeeded","data":[{"url":"https://provider.example/result.mp4"}]}
```
```json
{"status":"failed","code":"provider_failed","message":"upstream rejected request"}
```
Never log, return, or retain `authValues`, full raw responses, base64 media, Buffers, HTTP response objects, or entire request snapshots. Return only the fields required for routing and normalized output.

View File

@ -0,0 +1,7 @@
{
"name": "ai-gateway-ops-management",
"version": "1.0.2",
"modules": [
"model-runtime"
]
}

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

View File

@ -1,9 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createResponse,
deleteOIDCBrowserSession,
GatewayApiError,
gatewayErrorMessage,
getAPITask,
getCurrentUser,
getOpsManagementSkillMetadata,
OIDC_BROWSER_SESSION_CREDENTIAL,
} from './api';
@ -65,3 +68,69 @@ describe('OIDC browser session transport', () => {
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
});
describe('Public Agent resources', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('loads operations skill metadata without authorization', async () => {
const metadata = {
name: 'ai-gateway-ops-management',
version: '1.0.2',
displayName: 'AI Gateway 运维管理',
modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management-v1.0.2.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api-docs-json',
apiDocsYamlPath: '/api-docs-yaml',
};
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await expect(getOpsManagementSkillMetadata()).resolves.toEqual(metadata);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/v1/public/skills/ai-gateway-ops-management/metadata');
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
});
describe('API documentation runner transports', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('runs Responses against the public OpenAI-compatible endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'resp-test', object: 'response' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await createResponse('sk-test', { model: 'gpt-test', input: 'hello', store: true, stream: false });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/v1/responses');
expect(init.method).toBe('POST');
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer sk-test');
expect(JSON.parse(String(init.body))).toMatchObject({ model: 'gpt-test', input: 'hello', store: true, stream: false });
});
it('retrieves a task from the documented API path', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'task-123', status: 'running' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await getAPITask('sk-test', 'task-123');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/v1/tasks/task-123');
expect(init.method).toBe('GET');
});
});

View File

@ -24,6 +24,7 @@ import type {
GatewayTenantUpsertRequest,
GatewayNetworkProxyConfig,
GatewayPricingEstimate,
GatewaySkillBundleMetadata,
GatewayTask,
GatewayTaskParamPreprocessingLog,
GatewayUser,
@ -90,6 +91,10 @@ export async function getHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/healthz', { auth: false });
}
export async function getOpsManagementSkillMetadata(): Promise<GatewaySkillBundleMetadata> {
return request<GatewaySkillBundleMetadata>('/api/v1/public/skills/ai-gateway-ops-management/metadata', { auth: false });
}
export async function registerLocalAccount(input: {
username: string;
email?: string;
@ -600,6 +605,26 @@ export async function createCompatibleChatCompletion(
});
}
export async function createResponse(
token: string,
input: {
model: string;
input: unknown;
instructions?: string;
previous_response_id?: string;
runMode?: string;
simulation?: boolean;
store?: boolean;
stream?: boolean;
},
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>('/v1/responses', {
body: input,
method: 'POST',
token,
});
}
export async function createEmbedding(
token: string,
input: { model: string; input: string | string[]; dimensions?: number; runMode?: string; simulation?: boolean },
@ -813,6 +838,8 @@ export interface VideoGenerationParams {
mode?: 'std' | 'pro';
negative_prompt?: string;
cfg_scale?: number;
runMode?: string;
simulation?: boolean;
}
export async function createVideoGenerationTask(
@ -876,6 +903,10 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
return request<GatewayTask>(`/api/workspace/tasks/${taskId}`, { token });
}
export async function getAPITask(token: string, taskId: string): Promise<GatewayTask> {
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
}
export async function listTaskParamPreprocessing(
token: string,
taskId: string,

View File

@ -1,5 +1,14 @@
import type { GatewayTask } from '@easyai-ai-gateway/contracts';
import { createCompatibleChatCompletion, createEmbedding, createImageEditTask, createImageGenerationTask, createRerank } from '../api';
import {
createCompatibleChatCompletion,
createEmbedding,
createImageEditTask,
createImageGenerationTask,
createRerank,
createResponse,
createVideoGenerationTask,
getAPITask,
} from '../api';
import type { TaskForm } from '../types';
export interface RunTaskResponse {
@ -19,6 +28,19 @@ export async function runTask(token: string, task: TaskForm): Promise<RunTaskRes
});
return { localOnly: true, task: compatibleTask(task, result) };
}
if (task.kind === 'responses') {
const result = await createResponse(token, {
model: task.model,
input: task.prompt,
instructions: task.instructions,
previous_response_id: task.previousResponseId,
runMode: 'simulation',
simulation: true,
store: true,
stream: false,
});
return { localOnly: true, task: compatibleTask(task, result) };
}
if (task.kind === 'embeddings') {
const result = await createEmbedding(token, {
model: task.model,
@ -60,6 +82,24 @@ export async function runTask(token: string, task: TaskForm): Promise<RunTaskRes
simulation: true,
});
}
if (task.kind === 'videos.generations') {
return createVideoGenerationTask(token, {
model: task.model,
content: [{ type: 'text', text: task.prompt }],
aspect_ratio: task.aspectRatio ?? '16:9',
resolution: task.resolution ?? '720p',
duration: task.duration ?? 5,
audio: task.outputAudio ?? true,
runMode: 'simulation',
simulation: true,
});
}
if (task.kind === 'tasks.retrieve') {
const taskId = task.taskId?.trim();
if (!taskId) throw new Error('请输入要取回的 Task ID');
const result = await getAPITask(token, taskId);
return { localOnly: true, task: compatibleTask(task, result as unknown as Record<string, unknown>) };
}
throw new Error(`Unsupported task kind: ${task.kind}`);
}
@ -83,6 +123,18 @@ function compatibleTask(task: TaskForm, result: Record<string, unknown>): Gatewa
}
function requestSnapshot(task: TaskForm): Record<string, unknown> {
if (task.kind === 'responses') {
return {
model: task.model,
input: task.prompt,
instructions: task.instructions,
previous_response_id: task.previousResponseId,
runMode: 'simulation',
simulation: true,
store: true,
stream: false,
};
}
if (task.kind === 'embeddings') {
return {
model: task.model,
@ -102,6 +154,19 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
simulation: true,
};
}
if (task.kind === 'videos.generations') {
return {
model: task.model,
content: [{ type: 'text', text: task.prompt }],
aspect_ratio: task.aspectRatio ?? '16:9',
resolution: task.resolution ?? '720p',
duration: task.duration ?? 5,
audio: task.outputAudio ?? true,
runMode: 'simulation',
simulation: true,
};
}
if (task.kind === 'tasks.retrieve') return { taskId: task.taskId };
return {
model: task.model,
messages: [{ role: 'user', content: task.prompt }],
@ -133,5 +198,7 @@ function modelTypeForKind(kind: TaskForm['kind']) {
if (kind === 'reranks') return 'text_rerank';
if (kind === 'images.generations') return 'image_generate';
if (kind === 'images.edits') return 'image_edit';
if (kind === 'videos.generations') return 'video_generate';
if (kind === 'tasks.retrieve') return 'task';
return 'text_generate';
}

View File

@ -0,0 +1,107 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import type { ApiDocSection, TaskForm } from '../types';
import { ApiDocsPage } from './ApiDocsPage';
describe('ApiDocsPage extended task documentation', () => {
it.each([
['guideBaseUrl', '前往创建 API Key'],
['guideWebhook', 'TASK_PROGRESS_CALLBACK_ENABLED'],
['guideErrors', '400 invalid_parameter'],
['guideTesting', 'simulated=true'],
] as const)('renders the clickable guide page %s', (section, expectedContent) => {
const html = renderDocs(section, { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('data-active="true"');
expect(html).toContain(expectedContent);
expect(html).toContain('指南导航');
expect(html).not.toContain('/v1/chat/completions');
});
it('documents the OpenAI-compatible Responses request and continuity fields', () => {
const html = renderDocs('responses', { kind: 'responses', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('/v1/responses');
expect(html).toContain('previous_response_id');
expect(html).toContain('X-Async');
expect(html).toContain('连续对话与状态归属');
});
it('documents video generation parameters and async retrieval guidance', () => {
const html = renderDocs('videoGeneration', { kind: 'videos.generations', model: '豆包Seedance-2.0', prompt: '雪山日出' });
expect(html).toContain('/api/v1/videos/generations');
expect(html).toContain('aspect_ratio');
expect(html).toContain('reference_image');
expect(html).toContain('任务取回接口');
});
it('documents async mode as a body-independent capability', () => {
const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('X-Async');
expect(html).toContain('原请求 Header');
expect(html).toContain('同步与异步的区别');
expect(html).toContain('异步受理响应');
expect(html).not.toContain('/api/v1/videos/generations');
expect(html).not.toContain('Body 参数');
expect(html).not.toContain('视频模型 ID');
});
it.each(['chat', 'responses', 'embeddings', 'reranks', 'imageGeneration', 'imageEdit', 'videoGeneration'] as const)(
'shows the shared X-Async switch on the %s task API',
(section) => {
const html = renderDocs(section, { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
expect(html).toContain('X-Async');
},
);
it('renders nested objects and object arrays recursively', () => {
const html = renderDocs('videoGeneration', { kind: 'videos.generations', model: '豆包Seedance-2.0', prompt: '雪山日出' });
expect(html).toContain('array&lt;object&gt;');
expect(html).toContain('data-depth="1"');
expect(html).toContain('data-depth="4"');
expect(html).toContain('inline_element');
expect(html).toContain('refer_images');
expect(html).toContain('slot_key');
});
it('renders task retrieval as GET with a path parameter instead of a request body', () => {
const html = renderDocs('taskRetrieve', { kind: 'tasks.retrieve', model: 'task', prompt: '', taskId: 'task-123' });
expect(html).toContain('GET');
expect(html).toContain('/api/v1/tasks/task-123');
expect(html).toContain('Path 参数');
expect(html).toContain('响应 Body');
expect(html).toContain('result');
expect(html).toContain('billingSummary');
expect(html).toContain('attempts');
expect(html).toContain('成功响应示例');
expect(html).toContain('Task ID');
expect(html).not.toContain('请求 Body');
});
});
function renderDocs(activeDocSection: ApiDocSection, taskForm: TaskForm) {
return renderToStaticMarkup(
<ApiDocsPage
activeDocSection={activeDocSection}
apiKeySecretsById={{}}
apiKeys={[]}
canRun
coreMessage=""
coreState="idle"
selectedApiKeyId=""
taskForm={taskForm}
taskResult={null}
onApiKeyChange={vi.fn()}
onCreateApiKey={vi.fn()}
onDocSectionChange={vi.fn()}
onLogin={vi.fn()}
onSubmitTask={vi.fn()}
onTaskFormChange={vi.fn()}
/>,
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { parseAppRoute, pathForApiDocSection } from './routing';
describe('API documentation routes', () => {
it.each([
['guideBaseUrl', '/docs/auth'],
['guideWebhook', '/docs/webhook'],
['guideErrors', '/docs/errors'],
['guideTesting', '/docs/testing'],
['responses', '/docs/responses'],
['videoGeneration', '/docs/videos/generations'],
['asyncMode', '/docs/async'],
['taskRetrieve', '/docs/tasks/retrieve'],
] as const)('maps %s to a stable documentation URL', (section, path) => {
expect(pathForApiDocSection(section)).toBe(path);
expect(parseAppRoute(path).apiDocSection).toBe(section);
});
it.each([
['/docs/playground', 'guideTesting'],
['/docs/api/responses', 'responses'],
['/docs/api/videos', 'videoGeneration'],
['/docs/api/tasks', 'taskRetrieve'],
] as const)('keeps the legacy documentation URL %s working', (path, section) => {
expect(parseAppRoute(path).apiDocSection).toBe(section);
});
});

View File

@ -43,11 +43,19 @@ const adminPaths: Record<AdminSection, string> = {
};
const docsPaths: Record<ApiDocSection, string> = {
guideBaseUrl: '/docs/auth',
guideWebhook: '/docs/webhook',
guideErrors: '/docs/errors',
guideTesting: '/docs/testing',
chat: '/docs/chat',
responses: '/docs/responses',
embeddings: '/docs/embeddings',
reranks: '/docs/reranks',
imageGeneration: '/docs/images/generations',
imageEdit: '/docs/images/edits',
videoGeneration: '/docs/videos/generations',
asyncMode: '/docs/async',
taskRetrieve: '/docs/tasks/retrieve',
pricing: '/docs/pricing',
files: '/docs/files',
};
@ -63,8 +71,9 @@ const adminSections = reverseMap(adminPaths);
const docsSections = reverseMap(docsPaths);
const playgroundSections = reverseMap(playgroundPaths);
export function parseAppRoute(input = `${window.location.pathname}${window.location.search}`): AppRouteState {
const url = new URL(input, window.location.origin);
export function parseAppRoute(input = currentLocationPath()): AppRouteState {
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
const url = new URL(input, origin);
const path = normalizePath(url.pathname);
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
if (path === '/') return { ...defaultRouteState };
@ -84,6 +93,11 @@ export function parseAppRoute(input = `${window.location.pathname}${window.locat
return { ...defaultRouteState };
}
function currentLocationPath() {
if (typeof window === 'undefined') return '/';
return `${window.location.pathname}${window.location.search}`;
}
export function pathForPage(page: PageKey, route: AppRouteState): string {
if (page === 'playground') return pathForPlaygroundMode(route.playgroundMode);
if (page === 'models') return '/models';
@ -135,11 +149,14 @@ function parseAdminSection(path: string): AdminSection {
function parseDocSection(path: string): ApiDocSection {
if (path === '/docs') return 'chat';
if (path === '/docs/playground') return 'guideTesting';
if (path === '/docs/api/chat') return 'chat';
if (path === '/docs/api/responses') return 'responses';
if (path === '/docs/api/embeddings') return 'embeddings';
if (path === '/docs/api/reranks') return 'reranks';
if (path === '/docs/api/media') return 'imageGeneration';
if (path === '/docs/playground') return 'chat';
if (path === '/docs/api/videos') return 'videoGeneration';
if (path === '/docs/api/tasks') return 'taskRetrieve';
return docsSections[path] ?? 'chat';
}

View File

@ -120,6 +120,83 @@
line-height: 1.7;
}
.agentResourceCard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 16px;
margin: 0 0 24px;
padding: 18px;
border: 1px solid #bfdbfe;
border-radius: 12px;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 70%);
}
.agentResourceIcon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 10px;
background: #dbeafe;
color: #1d4ed8;
}
.agentResourceContent {
display: grid;
gap: 12px;
min-width: 0;
}
.agentResourceContent p {
margin: 0;
color: var(--text-soft);
line-height: 1.6;
}
.agentResourceTitle,
.agentResourceActions,
.agentResourceModules {
display: flex;
align-items: center;
gap: 10px;
}
.agentResourceTitle {
justify-content: space-between;
}
.agentResourceTitle h2 {
margin: 2px 0 0;
font-size: 1.0625rem;
}
.agentResourceTitle .eyebrow {
color: #2563eb;
}
.agentResourceModules {
color: var(--text-soft);
font-size: 0.8125rem;
}
.agentResourceModules strong {
color: var(--text-strong);
}
.agentResourceActions {
flex-wrap: wrap;
}
.agentResourceMetadataLink {
color: #2563eb;
font-size: 0.8125rem;
text-decoration: none;
}
.agentResourceMetadataLink:hover {
text-decoration: underline;
}
.paramCard {
margin-top: 18px;
overflow: hidden;
@ -141,6 +218,7 @@
.paramRow {
display: grid;
grid-template-columns: 150px 110px minmax(0, 1fr) 50px;
align-items: start;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
@ -148,11 +226,91 @@
font-size: 0.8125rem;
}
.paramRow[data-nested='true'] {
background: #fbfcfe;
}
.paramName {
position: relative;
min-width: 0;
}
.paramName code {
overflow-wrap: anywhere;
}
.paramTreeMarker {
position: absolute;
top: -13px;
width: 12px;
height: 23px;
border-bottom: 1px solid #cfd7e3;
border-left: 1px solid #cfd7e3;
border-radius: 0 0 0 4px;
}
.paramRow em {
color: #a16207;
font-style: normal;
}
.docsDetailContent {
display: grid;
gap: 12px;
padding: 16px;
color: var(--text-soft);
font-size: 0.875rem;
line-height: 1.7;
}
.docsDetailContent p {
margin: 0;
}
.docsDetailContent pre {
overflow: auto;
margin: 0;
padding: 14px;
border-radius: 8px;
background: #f7f8fa;
color: var(--text-normal);
font-size: 0.75rem;
line-height: 1.6;
}
.docsSteps {
display: grid;
gap: 8px;
margin: 0;
padding-left: 20px;
}
.docsGuideActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.docsStatusGrid {
display: grid;
grid-template-columns: minmax(190px, auto) minmax(0, 1fr);
gap: 0;
}
.docsStatusGrid > * {
margin: 0;
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.6;
}
.docsStatusGrid > code {
color: var(--text-normal);
}
.docsRunner {
border-left: 1px solid var(--border);
background: #fff;
@ -164,6 +322,40 @@
padding: 0 16px 16px;
}
.docsRunnerUnavailable {
padding: 14px;
border: 1px dashed var(--border);
border-radius: 8px;
background: var(--surface-muted);
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.6;
}
.docsGuideAside header {
display: flex;
align-items: center;
min-height: 58px;
}
.docsGuideAside > div {
display: grid;
gap: 10px;
padding: 18px 16px;
color: var(--text-soft);
font-size: 0.8125rem;
line-height: 1.6;
}
.docsGuideAside span,
.docsGuideAside p {
margin: 0;
}
.docsGuideAside strong {
color: var(--text-normal);
}
.runnerResult {
display: grid;
gap: 12px;
@ -201,4 +393,16 @@
.paramRow {
grid-template-columns: 1fr;
}
.docsStatusGrid {
grid-template-columns: 1fr;
}
.agentResourceCard {
grid-template-columns: 1fr;
}
.agentResourceTitle {
align-items: flex-start;
}
}

View File

@ -1,10 +1,33 @@
export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export type AuthMode = 'login' | 'register' | 'external';
export type TaskKind = 'chat.completions' | 'embeddings' | 'reranks' | 'images.generations' | 'images.edits';
export type TaskKind =
| 'chat.completions'
| 'responses'
| 'embeddings'
| 'reranks'
| 'images.generations'
| 'images.edits'
| 'videos.generations'
| 'tasks.retrieve';
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
export type PlaygroundMode = 'chat' | 'image' | 'video';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
export type ApiDocSection = 'chat' | 'embeddings' | 'reranks' | 'imageGeneration' | 'imageEdit' | 'pricing' | 'files';
export type ApiDocSection =
| 'guideBaseUrl'
| 'guideWebhook'
| 'guideErrors'
| 'guideTesting'
| 'chat'
| 'responses'
| 'embeddings'
| 'reranks'
| 'imageGeneration'
| 'imageEdit'
| 'videoGeneration'
| 'asyncMode'
| 'taskRetrieve'
| 'pricing'
| 'files';
export type AdminSection =
| 'overview'
| 'globalModels'
@ -37,10 +60,17 @@ export interface TaskForm {
kind: TaskKind;
model: string;
prompt: string;
aspectRatio?: string;
dimensions?: number;
documents?: string;
duration?: number;
image?: string;
instructions?: string;
mask?: string;
outputAudio?: boolean;
previousResponseId?: string;
resolution?: string;
taskId?: string;
topN?: number;
}

View File

@ -303,6 +303,17 @@ export interface GatewayRunnerPolicyUpsertRequest {
status?: 'active' | 'disabled' | string;
}
export interface GatewaySkillBundleMetadata {
name: string;
version: string;
displayName: string;
modules: string[];
fileName: string;
downloadPath: string;
apiDocsJsonPath: string;
apiDocsYamlPath: string;
}
export interface GatewayUser {
id: string;
userKey: string;

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;