diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index ede6dfd..81cd7ae 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -4521,7 +4521,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ChatCompletionRequest" } } ], @@ -5513,31 +5513,26 @@ "BearerAuth": [] } ], - "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "description": "公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。", "consumes": [ "application/json" ], "produces": [ - "application/json" + "application/json", + "text/event-stream" ], "tags": [ - "tasks" + "responses" ], - "summary": "创建或执行 AI 任务", + "summary": "创建 OpenAI Responses", "parameters": [ { - "type": "boolean", - "description": "true 时异步创建任务并返回 202", - "name": "X-Async", - "in": "header" - }, - { - "description": "AI 任务请求,字段随任务类型变化", + "description": "Responses 请求;Chat 回退只支持自定义 function tools", "name": "input", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ResponsesRequest" } } ], @@ -5545,17 +5540,17 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpapi.CompatibleResponse" - } - }, - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + "$ref": "#/definitions/httpapi.ResponsesCompatibleResponse" + }, + "headers": { + "X-Gateway-Task-Id": { + "type": "string", + "description": "网关审计任务 ID" + } } }, "400": { - "description": "Bad Request", + "description": "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -5578,20 +5573,8 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, - "502": { - "description": "Bad Gateway", + "503": { + "description": "response_chain_unavailable", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -6938,31 +6921,26 @@ "BearerAuth": [] } ], - "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "description": "OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。", "consumes": [ "application/json" ], "produces": [ - "application/json" + "application/json", + "text/event-stream" ], "tags": [ - "tasks" + "chat" ], - "summary": "创建或执行 AI 任务", + "summary": "创建 OpenAI Chat Completions", "parameters": [ { - "type": "boolean", - "description": "true 时异步创建任务并返回 202", - "name": "X-Async", - "in": "header" - }, - { - "description": "AI 任务请求,字段随任务类型变化", + "description": "Chat Completions 请求", "name": "input", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ChatCompletionRequest" } } ], @@ -6970,17 +6948,11 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpapi.CompatibleResponse" - } - }, - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + "$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse" } }, "400": { - "description": "Bad Request", + "description": "invalid_parameter", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -7003,12 +6975,6 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, "429": { "description": "Too Many Requests", "schema": { @@ -8220,31 +8186,26 @@ "BearerAuth": [] } ], - "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "description": "OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。", "consumes": [ "application/json" ], "produces": [ - "application/json" + "application/json", + "text/event-stream" ], "tags": [ - "tasks" + "chat" ], - "summary": "创建或执行 AI 任务", + "summary": "创建 OpenAI Chat Completions", "parameters": [ { - "type": "boolean", - "description": "true 时异步创建任务并返回 202", - "name": "X-Async", - "in": "header" - }, - { - "description": "AI 任务请求,字段随任务类型变化", + "description": "Chat Completions 请求", "name": "input", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ChatCompletionRequest" } } ], @@ -8252,17 +8213,11 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpapi.CompatibleResponse" - } - }, - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + "$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse" } }, "400": { - "description": "Bad Request", + "description": "invalid_parameter", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -8285,12 +8240,6 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, "429": { "description": "Too Many Requests", "schema": { @@ -9752,6 +9701,158 @@ } } }, + "httpapi.ChatCompletionRequest": { + "type": "object", + "properties": { + "audio": { + "type": "object", + "additionalProperties": true + }, + "frequency_penalty": { + "type": "number", + "example": 0 + }, + "function_call": {}, + "functions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "logit_bias": { + "type": "object", + "additionalProperties": true + }, + "logprobs": { + "type": "boolean" + }, + "max_completion_tokens": { + "type": "integer", + "example": 512 + }, + "max_tokens": { + "type": "integer", + "example": 512 + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.ChatMessage" + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "modalities": { + "type": "array", + "items": { + "type": "string" + } + }, + "model": { + "type": "string", + "example": "gpt-4o-mini" + }, + "moderation": {}, + "n": { + "type": "integer", + "example": 1 + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "prediction": {}, + "presence_penalty": { + "type": "number", + "example": 0 + }, + "prompt_cache_key": { + "type": "string" + }, + "prompt_cache_options": { + "type": "object", + "additionalProperties": true + }, + "prompt_cache_retention": { + "type": "string", + "enum": [ + "in_memory", + "24h" + ] + }, + "reasoning_effort": { + "description": "ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。", + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "example": "medium" + }, + "response_format": {}, + "runMode": { + "type": "string", + "example": "simulation" + }, + "safety_identifier": { + "type": "string" + }, + "seed": { + "type": "integer" + }, + "service_tier": { + "type": "string" + }, + "stop": {}, + "store": { + "type": "boolean" + }, + "stream": { + "type": "boolean", + "example": false + }, + "stream_options": { + "type": "object", + "additionalProperties": true + }, + "temperature": { + "type": "number", + "example": 0.7 + }, + "tool_choice": {}, + "tools": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "top_logprobs": { + "type": "integer" + }, + "top_p": { + "type": "number", + "example": 1 + }, + "user": { + "type": "string" + }, + "verbosity": { + "type": "string" + }, + "web_search_options": { + "type": "object", + "additionalProperties": true + } + } + }, "httpapi.ChatCompletionUsage": { "type": "object", "properties": { @@ -9787,14 +9888,19 @@ "httpapi.ChatMessage": { "type": "object", "properties": { - "content": { - "type": "string", - "example": "Hello" + "content": {}, + "function_call": {}, + "name": { + "type": "string" }, "role": { "type": "string", "example": "user" - } + }, + "tool_call_id": { + "type": "string" + }, + "tool_calls": {} } }, "httpapi.ChatPromptTokensDetails": { @@ -9853,9 +9959,14 @@ "type": "string", "example": "invalid json body" }, + "param": {}, "status": { "type": "integer", "example": 400 + }, + "type": { + "type": "string", + "example": "invalid_request_error" } } }, @@ -10397,6 +10508,23 @@ "httpapi.ResponsesRequest": { "type": "object", "properties": { + "background": { + "type": "boolean" + }, + "context_management": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "conversation": {}, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, "input": {}, "instructions": { "type": "string", @@ -10406,10 +10534,18 @@ "type": "integer", "example": 512 }, + "max_tool_calls": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, "model": { "type": "string", "example": "Doubao Seed 2.0 Pro" }, + "moderation": {}, "parallel_tool_calls": { "type": "boolean", "example": true @@ -10418,10 +10554,31 @@ "type": "string", "example": "resp_0123456789abcdef0123456789abcdef" }, + "prompt": {}, + "prompt_cache_key": { + "type": "string" + }, + "prompt_cache_options": { + "type": "object", + "additionalProperties": true + }, + "prompt_cache_retention": { + "type": "string", + "enum": [ + "in_memory", + "24h" + ] + }, "reasoning": { "type": "object", "additionalProperties": true }, + "safety_identifier": { + "type": "string" + }, + "service_tier": { + "type": "string" + }, "store": { "type": "boolean" }, @@ -10429,6 +10586,10 @@ "type": "boolean", "example": false }, + "stream_options": { + "type": "object", + "additionalProperties": true + }, "temperature": { "type": "number", "example": 0.7 @@ -10445,9 +10606,18 @@ "additionalProperties": true } }, + "top_logprobs": { + "type": "integer" + }, "top_p": { "type": "number", "example": 1 + }, + "truncation": { + "type": "string" + }, + "user": { + "type": "string" } } }, @@ -10610,14 +10780,20 @@ "type": "string", "example": "happy" }, - "input": { - "type": "string", - "example": "Tell me a short story" - }, + "input": {}, "makeInstrumental": { "type": "boolean", "example": false }, + "max_completion_tokens": { + "description": "MaxCompletionTokens includes visible output and reasoning tokens.", + "type": "integer", + "example": 512 + }, + "max_output_tokens": { + "type": "integer", + "example": 512 + }, "max_tokens": { "type": "integer", "example": 512 @@ -10645,8 +10821,17 @@ "example": "A watercolor robot reading a book" }, "reasoning_effort": { - "description": "ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。", + "description": "ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。", "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], "example": "medium" }, "resolution": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index bd4092a..21c5218 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -133,6 +133,118 @@ definitions: usage: $ref: '#/definitions/httpapi.ChatCompletionUsage' type: object + httpapi.ChatCompletionRequest: + properties: + audio: + additionalProperties: true + type: object + frequency_penalty: + example: 0 + type: number + function_call: {} + functions: + items: + additionalProperties: true + type: object + type: array + logit_bias: + additionalProperties: true + type: object + logprobs: + type: boolean + max_completion_tokens: + example: 512 + type: integer + max_tokens: + example: 512 + type: integer + messages: + items: + $ref: '#/definitions/httpapi.ChatMessage' + type: array + metadata: + additionalProperties: true + type: object + modalities: + items: + type: string + type: array + model: + example: gpt-4o-mini + type: string + moderation: {} + "n": + example: 1 + type: integer + parallel_tool_calls: + type: boolean + prediction: {} + presence_penalty: + example: 0 + type: number + prompt_cache_key: + type: string + prompt_cache_options: + additionalProperties: true + type: object + prompt_cache_retention: + enum: + - in_memory + - 24h + type: string + reasoning_effort: + description: ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。 + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - max + example: medium + type: string + response_format: {} + runMode: + example: simulation + type: string + safety_identifier: + type: string + seed: + type: integer + service_tier: + type: string + stop: {} + store: + type: boolean + stream: + example: false + type: boolean + stream_options: + additionalProperties: true + type: object + temperature: + example: 0.7 + type: number + tool_choice: {} + tools: + items: + additionalProperties: true + type: object + type: array + top_logprobs: + type: integer + top_p: + example: 1 + type: number + user: + type: string + verbosity: + type: string + web_search_options: + additionalProperties: true + type: object + type: object httpapi.ChatCompletionUsage: properties: completion_tokens: @@ -157,12 +269,16 @@ definitions: type: object httpapi.ChatMessage: properties: - content: - example: Hello + content: {} + function_call: {} + name: type: string role: example: user type: string + tool_call_id: + type: string + tool_calls: {} type: object httpapi.ChatPromptTokensDetails: properties: @@ -203,9 +319,13 @@ definitions: message: example: invalid json body type: string + param: {} status: example: 400 type: integer + type: + example: invalid_request_error + type: string type: object httpapi.FileStorageChannelListResponse: properties: @@ -567,6 +687,18 @@ definitions: type: object httpapi.ResponsesRequest: properties: + background: + type: boolean + context_management: + items: + additionalProperties: true + type: object + type: array + conversation: {} + include: + items: + type: string + type: array input: {} instructions: example: Answer concisely @@ -574,23 +706,47 @@ definitions: max_output_tokens: example: 512 type: integer + max_tool_calls: + type: integer + metadata: + additionalProperties: true + type: object model: example: Doubao Seed 2.0 Pro type: string + moderation: {} parallel_tool_calls: example: true type: boolean previous_response_id: example: resp_0123456789abcdef0123456789abcdef type: string + prompt: {} + prompt_cache_key: + type: string + prompt_cache_options: + additionalProperties: true + type: object + prompt_cache_retention: + enum: + - in_memory + - 24h + type: string reasoning: additionalProperties: true type: object + safety_identifier: + type: string + service_tier: + type: string store: type: boolean stream: example: false type: boolean + stream_options: + additionalProperties: true + type: object temperature: example: 0.7 type: number @@ -603,9 +759,15 @@ definitions: additionalProperties: true type: object type: array + top_logprobs: + type: integer top_p: example: 1 type: number + truncation: + type: string + user: + type: string type: object httpapi.RuntimePolicySetListResponse: properties: @@ -718,12 +880,17 @@ definitions: emotion: example: happy type: string - input: - example: Tell me a short story - type: string + input: {} makeInstrumental: example: false type: boolean + max_completion_tokens: + description: MaxCompletionTokens includes visible output and reasoning tokens. + example: 512 + type: integer + max_output_tokens: + example: 512 + type: integer max_tokens: example: 512 type: integer @@ -744,7 +911,15 @@ definitions: example: A watercolor robot reading a book type: string reasoning_effort: - description: ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。 + description: ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。 + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - max example: medium type: string resolution: @@ -5460,7 +5635,7 @@ paths: name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ChatCompletionRequest' produces: - application/json - text/event-stream @@ -6107,32 +6282,31 @@ paths: post: consumes: - application/json - description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible - 路径同步返回兼容响应或 SSE 流。 + description: 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 + Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 + previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。 parameters: - - description: true 时异步创建任务并返回 202 - in: header - name: X-Async - type: boolean - - description: AI 任务请求,字段随任务类型变化 + - description: Responses 请求;Chat 回退只支持自定义 function tools in: body name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ResponsesRequest' produces: - application/json + - text/event-stream responses: "200": description: OK + headers: + X-Gateway-Task-Id: + description: 网关审计任务 ID + type: string schema: - $ref: '#/definitions/httpapi.CompatibleResponse' - "202": - description: Accepted - schema: - $ref: '#/definitions/httpapi.TaskAcceptedResponse' + $ref: '#/definitions/httpapi.ResponsesCompatibleResponse' "400": - description: Bad Request + description: invalid_previous_response_id / unsupported_response_tool / + unsupported_response_parameter schema: $ref: '#/definitions/httpapi.ErrorEnvelope' "401": @@ -6147,23 +6321,15 @@ paths: description: Forbidden schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - "404": - description: Not Found - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' - "429": - description: Too Many Requests - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' - "502": - description: Bad Gateway + "503": + description: response_chain_unavailable schema: $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 创建或执行 AI 任务 + summary: 创建 OpenAI Responses tags: - - tasks + - responses /api/v1/song/generations: post: consumes: @@ -7027,32 +7193,25 @@ paths: post: consumes: - application/json - description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible - 路径同步返回兼容响应或 SSE 流。 + description: OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 + 400 invalid_parameter。 parameters: - - description: true 时异步创建任务并返回 202 - in: header - name: X-Async - type: boolean - - description: AI 任务请求,字段随任务类型变化 + - description: Chat Completions 请求 in: body name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ChatCompletionRequest' produces: - application/json + - text/event-stream responses: "200": description: OK schema: - $ref: '#/definitions/httpapi.CompatibleResponse' - "202": - description: Accepted - schema: - $ref: '#/definitions/httpapi.TaskAcceptedResponse' + $ref: '#/definitions/httpapi.ChatCompletionCompatibleResponse' "400": - description: Bad Request + description: invalid_parameter schema: $ref: '#/definitions/httpapi.ErrorEnvelope' "401": @@ -7067,10 +7226,6 @@ paths: description: Forbidden schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - "404": - description: Not Found - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' "429": description: Too Many Requests schema: @@ -7081,9 +7236,9 @@ paths: $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 创建或执行 AI 任务 + summary: 创建 OpenAI Chat Completions tags: - - tasks + - chat /embeddings: post: consumes: @@ -7866,32 +8021,25 @@ paths: post: consumes: - application/json - description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible - 路径同步返回兼容响应或 SSE 流。 + description: OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 + 400 invalid_parameter。 parameters: - - description: true 时异步创建任务并返回 202 - in: header - name: X-Async - type: boolean - - description: AI 任务请求,字段随任务类型变化 + - description: Chat Completions 请求 in: body name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ChatCompletionRequest' produces: - application/json + - text/event-stream responses: "200": description: OK schema: - $ref: '#/definitions/httpapi.CompatibleResponse' - "202": - description: Accepted - schema: - $ref: '#/definitions/httpapi.TaskAcceptedResponse' + $ref: '#/definitions/httpapi.ChatCompletionCompatibleResponse' "400": - description: Bad Request + description: invalid_parameter schema: $ref: '#/definitions/httpapi.ErrorEnvelope' "401": @@ -7906,10 +8054,6 @@ paths: description: Forbidden schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - "404": - description: Not Found - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' "429": description: Too Many Requests schema: @@ -7920,9 +8064,9 @@ paths: $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 创建或执行 AI 任务 + summary: 创建 OpenAI Chat Completions tags: - - tasks + - chat /v1/embeddings: post: consumes: diff --git a/apps/api/internal/clients/chat_reasoning.go b/apps/api/internal/clients/chat_reasoning.go index abb2b53..5bb21bc 100644 --- a/apps/api/internal/clients/chat_reasoning.go +++ b/apps/api/internal/clients/chat_reasoning.go @@ -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 { diff --git a/apps/api/internal/clients/chat_reasoning_max_test.go b/apps/api/internal/clients/chat_reasoning_max_test.go new file mode 100644 index 0000000..efd569d --- /dev/null +++ b/apps/api/internal/clients/chat_reasoning_max_test.go @@ -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"]) + } + }) + } +} diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index dbd0d9f..0a4421e 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -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", diff --git a/apps/api/internal/clients/keling.go b/apps/api/internal/clients/keling.go index dfe1a09..310595b 100644 --- a/apps/api/internal/clients/keling.go +++ b/apps/api/internal/clients/keling.go @@ -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{ diff --git a/apps/api/internal/clients/openai.go b/apps/api/internal/clients/openai.go index be5c73b..8ee27f5 100644 --- a/apps/api/internal/clients/openai.go +++ b/apps/api/internal/clients/openai.go @@ -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 diff --git a/apps/api/internal/clients/openai_request_params.go b/apps/api/internal/clients/openai_request_params.go new file mode 100644 index 0000000..388a8cb --- /dev/null +++ b/apps/api/internal/clients/openai_request_params.go @@ -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 +} diff --git a/apps/api/internal/clients/openai_request_params_test.go b/apps/api/internal/clients/openai_request_params_test.go new file mode 100644 index 0000000..df4de80 --- /dev/null +++ b/apps/api/internal/clients/openai_request_params_test.go @@ -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) + } + } +} diff --git a/apps/api/internal/clients/responses_compat.go b/apps/api/internal/clients/responses_compat.go index f0cd984..c361890 100644 --- a/apps/api/internal/clients/responses_compat.go +++ b/apps/api/internal/clients/responses_compat.go @@ -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") } } diff --git a/apps/api/internal/clients/responses_compat_test.go b/apps/api/internal/clients/responses_compat_test.go index 8c6b4c4..0adb741 100644 --- a/apps/api/internal/clients/responses_compat_test.go +++ b/apps/api/internal/clients/responses_compat_test.go @@ -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", diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 89c1ca4..5462f05 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -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 diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index dd01b78..9eefa56 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -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 { diff --git a/apps/api/internal/clients/volces_deyun_test.go b/apps/api/internal/clients/volces_deyun_test.go new file mode 100644 index 0000000..04485a1 --- /dev/null +++ b/apps/api/internal/clients/volces_deyun_test.go @@ -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) + } +} diff --git a/apps/api/internal/httpapi/chat_completions_mode_test.go b/apps/api/internal/httpapi/chat_completions_mode_test.go index d28c873..df744fe 100644 --- a/apps/api/internal/httpapi/chat_completions_mode_test.go +++ b/apps/api/internal/httpapi/chat_completions_mode_test.go @@ -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) { diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 7c0ee71..b5c9767 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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": diff --git a/apps/api/internal/httpapi/keling_simulation_integration_test.go b/apps/api/internal/httpapi/keling_simulation_integration_test.go new file mode 100644 index 0000000..421ce32 --- /dev/null +++ b/apps/api/internal/httpapi/keling_simulation_integration_test.go @@ -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, ®isterResponse) + + 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") +} diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 2d2363e..279b5d4 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -35,6 +35,8 @@ type ErrorPayload struct { Message string `json:"message" example:"invalid json body"` Status int `json:"status" example:"400"` Code string `json:"code,omitempty" example:"rate_limit"` + Type string `json:"type,omitempty" example:"invalid_request_error"` + Param any `json:"param,omitempty"` } type AuthResponse struct { @@ -193,16 +195,19 @@ type PricingEstimateResponse struct { type TaskRequest struct { Model string `json:"model" example:"gpt-4o-mini"` Messages []ChatMessage `json:"messages,omitempty"` - Input string `json:"input,omitempty" example:"Tell me a short story"` + Input interface{} `json:"input,omitempty"` Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"` Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."` TextFileID string `json:"text_file_id,omitempty" example:""` VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"` - Stream bool `json:"stream,omitempty" example:"false"` + Stream *bool `json:"stream,omitempty" example:"false"` RunMode string `json:"runMode,omitempty" example:"simulation"` - MaxTokens int `json:"max_tokens,omitempty" example:"512"` - // ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。 - ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"` + MaxTokens *int `json:"max_tokens,omitempty" example:"512"` + // MaxCompletionTokens includes visible output and reasoning tokens. + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"` + // ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。 + ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"` Size string `json:"size,omitempty" example:"1024x1024"` Duration int `json:"duration,omitempty" example:"5"` Resolution string `json:"resolution,omitempty" example:"720p"` @@ -223,36 +228,88 @@ type TaskRequest struct { } type ChatCompletionRequest struct { - Model string `json:"model" example:"gpt-4o-mini"` - Messages []ChatMessage `json:"messages"` - Temperature float64 `json:"temperature,omitempty" example:"0.7"` - MaxTokens int `json:"max_tokens,omitempty" example:"512"` - // ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。 - ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"` - Stream bool `json:"stream,omitempty" example:"false"` - RunMode string `json:"runMode,omitempty" example:"simulation"` + Model string `json:"model" example:"gpt-4o-mini"` + Messages []ChatMessage `json:"messages"` + Audio map[string]interface{} `json:"audio,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" example:"0"` + FunctionCall interface{} `json:"function_call,omitempty"` + Functions []map[string]interface{} `json:"functions,omitempty"` + LogitBias map[string]interface{} `json:"logit_bias,omitempty"` + Logprobs *bool `json:"logprobs,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"` + MaxTokens *int `json:"max_tokens,omitempty" example:"512"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Modalities []string `json:"modalities,omitempty"` + Moderation interface{} `json:"moderation,omitempty"` + N *int `json:"n,omitempty" example:"1"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + Prediction interface{} `json:"prediction,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty" example:"0"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"` + // ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。 + ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"` + ResponseFormat interface{} `json:"response_format,omitempty"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + Seed *int `json:"seed,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Stop interface{} `json:"stop,omitempty"` + Store *bool `json:"store,omitempty"` + Stream *bool `json:"stream,omitempty" example:"false"` + StreamOptions map[string]interface{} `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty" example:"0.7"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + Tools []map[string]interface{} `json:"tools,omitempty"` + TopLogprobs *int `json:"top_logprobs,omitempty"` + TopP *float64 `json:"top_p,omitempty" example:"1"` + User string `json:"user,omitempty"` + Verbosity string `json:"verbosity,omitempty"` + WebSearchOptions map[string]interface{} `json:"web_search_options,omitempty"` + RunMode string `json:"runMode,omitempty" example:"simulation"` } type ChatMessage struct { - Role string `json:"role" example:"user"` - Content string `json:"content" example:"Hello"` + Role string `json:"role" example:"user"` + Content interface{} `json:"content,omitempty"` + Name string `json:"name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls interface{} `json:"tool_calls,omitempty"` + FunctionCall interface{} `json:"function_call,omitempty"` } type ResponsesRequest struct { - Model string `json:"model" example:"Doubao Seed 2.0 Pro"` - Input interface{} `json:"input"` - Instructions string `json:"instructions,omitempty" example:"Answer concisely"` - PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"` - Tools []map[string]interface{} `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` - ParallelToolCalls bool `json:"parallel_tool_calls,omitempty" example:"true"` - MaxOutputTokens int `json:"max_output_tokens,omitempty" example:"512"` - Reasoning map[string]interface{} `json:"reasoning,omitempty"` - Text map[string]interface{} `json:"text,omitempty"` - Temperature float64 `json:"temperature,omitempty" example:"0.7"` - TopP float64 `json:"top_p,omitempty" example:"1"` - Store *bool `json:"store,omitempty"` - Stream bool `json:"stream,omitempty" example:"false"` + Model string `json:"model" example:"Doubao Seed 2.0 Pro"` + Background *bool `json:"background,omitempty"` + ContextManagement []map[string]interface{} `json:"context_management,omitempty"` + Conversation interface{} `json:"conversation,omitempty"` + Include []string `json:"include,omitempty"` + Input interface{} `json:"input"` + Instructions string `json:"instructions,omitempty" example:"Answer concisely"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"` + MaxToolCalls *int `json:"max_tool_calls,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + Moderation interface{} `json:"moderation,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" example:"true"` + PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"` + Prompt interface{} `json:"prompt,omitempty"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"` + Reasoning map[string]interface{} `json:"reasoning,omitempty"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Store *bool `json:"store,omitempty"` + Stream *bool `json:"stream,omitempty" example:"false"` + StreamOptions map[string]interface{} `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty" example:"0.7"` + Text map[string]interface{} `json:"text,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + Tools []map[string]interface{} `json:"tools,omitempty"` + TopLogprobs *int `json:"top_logprobs,omitempty"` + TopP *float64 `json:"top_p,omitempty" example:"1"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` } type ResponsesCompatibleResponse struct { diff --git a/apps/api/internal/httpapi/response.go b/apps/api/internal/httpapi/response.go index 27a144c..504cf57 100644 --- a/apps/api/internal/httpapi/response.go +++ b/apps/api/internal/httpapi/response.go @@ -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 { diff --git a/apps/api/internal/runner/output_token_limit.go b/apps/api/internal/runner/output_token_limit.go new file mode 100644 index 0000000..329e546 --- /dev/null +++ b/apps/api/internal/runner/output_token_limit.go @@ -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} +} diff --git a/apps/api/internal/runner/output_token_limit_test.go b/apps/api/internal/runner/output_token_limit_test.go new file mode 100644 index 0000000..37262d4 --- /dev/null +++ b/apps/api/internal/runner/output_token_limit_test.go @@ -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) + } +} diff --git a/apps/api/internal/runner/param_processor.go b/apps/api/internal/runner/param_processor.go index f1216bf..18a42c1 100644 --- a/apps/api/internal/runner/param_processor.go +++ b/apps/api/internal/runner/param_processor.go @@ -55,6 +55,7 @@ type parameterPreprocessChange struct { func NewParamProcessorChain() ParamProcessorChain { return ParamProcessorChain{ processors: []paramProcessor{ + outputTokenLimitProcessor{}, resolutionNormalizeProcessor{}, aspectRatioProcessor{}, imageSizeProcessor{}, diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index f5adbd5..abda6fb 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -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) diff --git a/apps/api/internal/runner/queue_worker.go b/apps/api/internal/runner/queue_worker.go index 040d3ba..2b3e8b7 100644 --- a/apps/api/internal/runner/queue_worker.go +++ b/apps/api/internal/runner/queue_worker.go @@ -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, }) diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index fb0409c..6461363 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -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") diff --git a/apps/api/internal/runner/service_test.go b/apps/api/internal/runner/service_test.go index db21854..ce99869 100644 --- a/apps/api/internal/runner/service_test.go +++ b/apps/api/internal/runner/service_test.go @@ -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"}}, diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 83c284a..6c5f7d4 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -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 { diff --git a/apps/api/internal/store/runtime_types.go b/apps/api/internal/store/runtime_types.go index 6819ff7..c4c4b5f 100644 --- a/apps/api/internal/store/runtime_types.go +++ b/apps/api/internal/store/runtime_types.go @@ -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 { diff --git a/apps/api/migrations/0062_keling_30_turbo.sql b/apps/api/migrations/0062_keling_30_turbo.sql new file mode 100644 index 0000000..d554634 --- /dev/null +++ b/apps/api/migrations/0062_keling_30_turbo.sql @@ -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(); diff --git a/scripts/deyun-video-e2e.mjs b/scripts/deyun-video-e2e.mjs new file mode 100755 index 0000000..44bad7e --- /dev/null +++ b/scripts/deyun-video-e2e.mjs @@ -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; +}); diff --git a/scripts/output-token-limit-e2e.mjs b/scripts/output-token-limit-e2e.mjs new file mode 100644 index 0000000..b66149d --- /dev/null +++ b/scripts/output-token-limit-e2e.mjs @@ -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;