feat(api): 合并官方兼容接口响应协议 (#24)

合并兼容协议响应统一、提交状态修正、兼容任务元数据迁移及相应契约测试。\n\n已在最新 main 上完成完整本地门禁验证。
This commit was merged in pull request #24.
This commit is contained in:
2026-07-22 15:38:46 +08:00
32 changed files with 2436 additions and 591 deletions
+327 -163
View File
@@ -5469,43 +5469,43 @@
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"402": {
"description": "Payment Required",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
}
}
@@ -5532,6 +5532,18 @@
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
}
}
},
@@ -5556,8 +5568,31 @@
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/httpapi.VolcesContentsGenerationTaskResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
}
}
@@ -5590,8 +5625,13 @@
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/httpapi.VolcesContentsGenerationTaskResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
}
}
@@ -5623,8 +5663,13 @@
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/definitions/httpapi.VolcesContentsGenerationTaskResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.VolcesErrorEnvelope"
}
}
}
@@ -5637,7 +5682,6 @@
"BearerAuth": []
}
],
"description": "统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。",
"consumes": [
"application/json"
],
@@ -5645,24 +5689,12 @@
"application/json"
],
"tags": [
"tasks"
"embeddings"
],
"summary": "创建或执行 AI 任务",
"summary": "创建 OpenAI Embeddings",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"type": "string",
"description": "可选请求幂等键;同一用户范围内唯一",
"name": "Idempotency-Key",
"in": "header"
},
{
"description": "AI 任务请求,字段随任务类型变化",
"description": "OpenAI Embeddings 请求",
"name": "input",
"in": "body",
"required": true,
@@ -5678,52 +5710,28 @@
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"402": {
"description": "Payment Required",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
}
}
@@ -5930,7 +5938,6 @@
"BearerAuth": []
}
],
"description": "统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。",
"consumes": [
"application/json"
],
@@ -5938,24 +5945,12 @@
"application/json"
],
"tags": [
"tasks"
"images"
],
"summary": "创建或执行 AI 任务",
"summary": "创建或编辑 OpenAI Images",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"type": "string",
"description": "可选请求幂等键;同一用户范围内唯一",
"name": "Idempotency-Key",
"in": "header"
},
{
"description": "AI 任务请求,字段随任务类型变化",
"description": "OpenAI Images 请求",
"name": "input",
"in": "body",
"required": true,
@@ -5971,52 +5966,28 @@
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"402": {
"description": "Payment Required",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
}
}
@@ -6029,7 +6000,6 @@
"BearerAuth": []
}
],
"description": "统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。",
"consumes": [
"application/json"
],
@@ -6037,24 +6007,12 @@
"application/json"
],
"tags": [
"tasks"
"images"
],
"summary": "创建或执行 AI 任务",
"summary": "创建或编辑 OpenAI Images",
"parameters": [
{
"type": "boolean",
"description": "true 时异步创建任务并返回 202",
"name": "X-Async",
"in": "header"
},
{
"type": "string",
"description": "可选请求幂等键;同一用户范围内唯一",
"name": "Idempotency-Key",
"in": "header"
},
{
"description": "AI 任务请求,字段随任务类型变化",
"description": "OpenAI Images 请求",
"name": "input",
"in": "body",
"required": true,
@@ -6070,52 +6028,28 @@
"$ref": "#/definitions/httpapi.CompatibleResponse"
}
},
"202": {
"description": "Accepted",
"schema": {
"$ref": "#/definitions/httpapi.TaskAcceptedResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"402": {
"description": "Payment Required",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
}
}
@@ -6289,7 +6223,7 @@
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.KlingErrorEnvelope"
}
}
}
@@ -6391,7 +6325,7 @@
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.KlingErrorEnvelope"
}
}
}
@@ -6603,7 +6537,8 @@
"application/json"
],
"produces": [
"application/json"
"application/json",
"text/event-stream"
],
"tags": [
"gemini-compatible"
@@ -6639,25 +6574,98 @@
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
}
}
}
},
"/api/v1/models/{model}:streamGenerateContent": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "使用统一 /api/v1 前缀接收 Gemini generateContent 请求;旧 /v1 和 /v1beta 路径保留兼容。",
"consumes": [
"application/json"
],
"produces": [
"application/json",
"text/event-stream"
],
"tags": [
"gemini-compatible"
],
"summary": "Gemini generateContent 兼容接口",
"parameters": [
{
"type": "string",
"description": "模型名称",
"name": "model",
"in": "path",
"required": true
},
{
"description": "Gemini generateContent 请求",
"name": "input",
"in": "body",
"required": true,
"schema": {
"type": "object",
"additionalProperties": true
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/httpapi.GeminiErrorEnvelope"
}
}
}
@@ -7392,42 +7400,36 @@
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.ResponsesCompatibleResponse"
},
"headers": {
"X-Gateway-Task-Id": {
"type": "string",
"description": "网关审计任务 ID"
}
}
},
"400": {
"description": "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"402": {
"description": "Payment Required",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
},
"503": {
"description": "response_chain_unavailable",
"schema": {
"$ref": "#/definitions/httpapi.ErrorEnvelope"
"$ref": "#/definitions/httpapi.OpenAIErrorEnvelope"
}
}
}
@@ -9849,6 +9851,38 @@
}
}
},
"httpapi.GeminiErrorEnvelope": {
"type": "object",
"properties": {
"error": {
"$ref": "#/definitions/httpapi.GeminiErrorStatus"
}
}
},
"httpapi.GeminiErrorStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"example": 400
},
"details": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {}
}
},
"message": {
"type": "string",
"example": "Invalid argument"
},
"status": {
"type": "string",
"example": "INVALID_ARGUMENT"
}
}
},
"httpapi.HealthResponse": {
"type": "object",
"properties": {
@@ -10097,6 +10131,26 @@
}
}
},
"httpapi.KlingErrorEnvelope": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"example": 1001
},
"error": {
"type": "string",
"example": "invalid_parameter"
},
"message": {
"type": "string",
"example": "invalid parameter"
},
"request_id": {
"type": "string"
}
}
},
"httpapi.ModelCatalogFilterOption": {
"type": "object",
"properties": {
@@ -10390,6 +10444,35 @@
}
}
},
"httpapi.OpenAIErrorEnvelope": {
"type": "object",
"properties": {
"error": {
"$ref": "#/definitions/httpapi.OpenAIErrorPayload"
}
}
},
"httpapi.OpenAIErrorPayload": {
"type": "object",
"properties": {
"code": {
"type": "string",
"example": "invalid_parameter"
},
"message": {
"type": "string",
"example": "Invalid parameter"
},
"param": {
"type": "string",
"example": "model"
},
"type": {
"type": "string",
"example": "invalid_request_error"
}
}
},
"httpapi.PlatformListResponse": {
"type": "object",
"properties": {
@@ -11117,6 +11200,67 @@
}
}
},
"httpapi.VolcesContentsGenerationTaskResponse": {
"type": "object",
"properties": {
"content": {
"type": "object",
"additionalProperties": {}
},
"created_at": {
"type": "integer"
},
"error": {
"type": "object",
"additionalProperties": {}
},
"id": {
"type": "string",
"example": "cgt-202607221234"
},
"model": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"queued",
"running",
"succeeded",
"failed",
"cancelled"
]
},
"updated_at": {
"type": "integer"
},
"usage": {
"type": "object",
"additionalProperties": {}
}
}
},
"httpapi.VolcesErrorEnvelope": {
"type": "object",
"properties": {
"error": {
"$ref": "#/definitions/httpapi.VolcesErrorPayload"
}
}
},
"httpapi.VolcesErrorPayload": {
"type": "object",
"properties": {
"code": {
"type": "string",
"example": "invalid_parameter"
},
"message": {
"type": "string",
"example": "model is required"
}
}
},
"httpapi.WalletAdjustmentResponse": {
"type": "object",
"properties": {
@@ -12629,6 +12773,26 @@
"cancellable": {
"type": "boolean"
},
"compatibilityProtocol": {
"type": "string"
},
"compatibilityPublicId": {
"type": "string"
},
"compatibilitySourceProtocol": {
"type": "string"
},
"compatibilitySubmitBody": {
"type": "object",
"additionalProperties": {}
},
"compatibilitySubmitHeaders": {
"type": "object",
"additionalProperties": {}
},
"compatibilitySubmitHttpStatus": {
"type": "integer"
},
"conversationId": {
"type": "string"
},
+236 -124
View File
@@ -395,6 +395,28 @@ definitions:
example: /static/uploaded/upload-abc123.png
type: string
type: object
httpapi.GeminiErrorEnvelope:
properties:
error:
$ref: '#/definitions/httpapi.GeminiErrorStatus'
type: object
httpapi.GeminiErrorStatus:
properties:
code:
example: 400
type: integer
details:
items:
additionalProperties: {}
type: object
type: array
message:
example: Invalid argument
type: string
status:
example: INVALID_ARGUMENT
type: string
type: object
httpapi.HealthResponse:
properties:
env:
@@ -572,6 +594,20 @@ definitions:
example: false
type: boolean
type: object
httpapi.KlingErrorEnvelope:
properties:
code:
example: 1001
type: integer
error:
example: invalid_parameter
type: string
message:
example: invalid parameter
type: string
request_id:
type: string
type: object
httpapi.ModelCatalogFilterOption:
properties:
count:
@@ -764,6 +800,26 @@ definitions:
example: env
type: string
type: object
httpapi.OpenAIErrorEnvelope:
properties:
error:
$ref: '#/definitions/httpapi.OpenAIErrorPayload'
type: object
httpapi.OpenAIErrorPayload:
properties:
code:
example: invalid_parameter
type: string
message:
example: Invalid parameter
type: string
param:
example: model
type: string
type:
example: invalid_request_error
type: string
type: object
httpapi.PlatformListResponse:
properties:
items:
@@ -1281,6 +1337,49 @@ definitions:
example: https://example.com/source.mp4
type: string
type: object
httpapi.VolcesContentsGenerationTaskResponse:
properties:
content:
additionalProperties: {}
type: object
created_at:
type: integer
error:
additionalProperties: {}
type: object
id:
example: cgt-202607221234
type: string
model:
type: string
status:
enum:
- queued
- running
- succeeded
- failed
- cancelled
type: string
updated_at:
type: integer
usage:
additionalProperties: {}
type: object
type: object
httpapi.VolcesErrorEnvelope:
properties:
error:
$ref: '#/definitions/httpapi.VolcesErrorPayload'
type: object
httpapi.VolcesErrorPayload:
properties:
code:
example: invalid_parameter
type: string
message:
example: model is required
type: string
type: object
httpapi.WalletAdjustmentResponse:
properties:
account:
@@ -2306,6 +2405,20 @@ definitions:
type: array
cancellable:
type: boolean
compatibilityProtocol:
type: string
compatibilityPublicId:
type: string
compatibilitySourceProtocol:
type: string
compatibilitySubmitBody:
additionalProperties: {}
type: object
compatibilitySubmitHeaders:
additionalProperties: {}
type: object
compatibilitySubmitHttpStatus:
type: integer
conversationId:
type: string
createdAt:
@@ -6985,31 +7098,31 @@ paths:
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"402":
description: Payment Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"429":
description: Too Many Requests
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
security:
- BearerAuth: []
summary: 创建 Chat Completions
@@ -7025,6 +7138,14 @@ paths:
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
security:
- BearerAuth: []
summary: 列出火山内容生成任务
@@ -7041,8 +7162,23 @@ paths:
"200":
description: OK
schema:
additionalProperties: true
type: object
$ref: '#/definitions/httpapi.VolcesContentsGenerationTaskResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
"429":
description: Too Many Requests
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
security:
- BearerAuth: []
summary: 创建火山内容生成任务
@@ -7063,8 +7199,11 @@ paths:
"200":
description: OK
schema:
additionalProperties: true
type: object
$ref: '#/definitions/httpapi.VolcesContentsGenerationTaskResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
security:
- BearerAuth: []
summary: 取消火山内容生成任务
@@ -7083,8 +7222,11 @@ paths:
"200":
description: OK
schema:
additionalProperties: true
type: object
$ref: '#/definitions/httpapi.VolcesContentsGenerationTaskResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.VolcesErrorEnvelope'
security:
- BearerAuth: []
summary: 查询火山内容生成任务
@@ -7094,17 +7236,8 @@ paths:
post:
consumes:
- application/json
description: 统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: 可选请求幂等键;同一用户范围内唯一
in: header
name: Idempotency-Key
type: string
- description: AI 任务请求,字段随任务类型变化
- description: OpenAI Embeddings 请求
in: body
name: input
required: true
@@ -7117,43 +7250,27 @@ paths:
description: OK
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"402":
description: Payment Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"429":
description: Too Many Requests
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
security:
- BearerAuth: []
summary: 创建或执行 AI 任务
summary: 创建 OpenAI Embeddings
tags:
- tasks
- embeddings
/api/v1/files/upload:
post:
consumes:
@@ -7283,17 +7400,8 @@ paths:
post:
consumes:
- application/json
description: 统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: 可选请求幂等键;同一用户范围内唯一
in: header
name: Idempotency-Key
type: string
- description: AI 任务请求,字段随任务类型变化
- description: OpenAI Images 请求
in: body
name: input
required: true
@@ -7306,58 +7414,33 @@ paths:
description: OK
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"402":
description: Payment Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"429":
description: Too Many Requests
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
security:
- BearerAuth: []
summary: 创建或执行 AI 任务
summary: 创建或编辑 OpenAI Images
tags:
- tasks
- images
/api/v1/images/generations:
post:
consumes:
- application/json
description: 统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。
parameters:
- description: true 时异步创建任务并返回 202
in: header
name: X-Async
type: boolean
- description: 可选请求幂等键;同一用户范围内唯一
in: header
name: Idempotency-Key
type: string
- description: AI 任务请求,字段随任务类型变化
- description: OpenAI Images 请求
in: body
name: input
required: true
@@ -7370,43 +7453,27 @@ paths:
description: OK
schema:
$ref: '#/definitions/httpapi.CompatibleResponse'
"202":
description: Accepted
schema:
$ref: '#/definitions/httpapi.TaskAcceptedResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"402":
description: Payment Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
"404":
description: Not Found
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"429":
description: Too Many Requests
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
security:
- BearerAuth: []
summary: 创建或执行 AI 任务
summary: 创建或编辑 OpenAI Images
tags:
- tasks
- images
/api/v1/images/vectorize:
post:
consumes:
@@ -7515,7 +7582,7 @@ paths:
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.KlingErrorEnvelope'
security:
- BearerAuth: []
summary: 创建可灵 V1 Omni 视频任务
@@ -7581,7 +7648,7 @@ paths:
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.KlingErrorEnvelope'
security:
- BearerAuth: []
summary: 创建可灵 API 2.0 Omni 视频任务
@@ -7728,6 +7795,7 @@ paths:
type: object
produces:
- application/json
- text/event-stream
responses:
"200":
description: OK
@@ -7737,19 +7805,67 @@ paths:
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
security:
- BearerAuth: []
summary: Gemini generateContent 兼容接口
tags:
- gemini-compatible
/api/v1/models/{model}:streamGenerateContent:
post:
consumes:
- application/json
description: 使用统一 /api/v1 前缀接收 Gemini generateContent 请求;旧 /v1 和 /v1beta 路径保留兼容。
parameters:
- description: 模型名称
in: path
name: model
required: true
type: string
- description: Gemini generateContent 请求
in: body
name: input
required: true
schema:
additionalProperties: true
type: object
produces:
- application/json
- text/event-stream
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/httpapi.GeminiErrorEnvelope'
security:
- BearerAuth: []
summary: Gemini generateContent 兼容接口
@@ -8220,33 +8336,29 @@ paths:
responses:
"200":
description: OK
headers:
X-Gateway-Task-Id:
description: 网关审计任务 ID
type: string
schema:
$ref: '#/definitions/httpapi.ResponsesCompatibleResponse'
"400":
description: invalid_previous_response_id / unsupported_response_tool /
unsupported_response_parameter
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"402":
description: Payment Required
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"403":
description: Forbidden
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
"503":
description: response_chain_unavailable
schema:
$ref: '#/definitions/httpapi.ErrorEnvelope'
$ref: '#/definitions/httpapi.OpenAIErrorEnvelope'
security:
- BearerAuth: []
summary: 创建 OpenAI Responses
+75 -3
View File
@@ -1,6 +1,7 @@
package clients
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -24,18 +25,35 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
}
body := geminiBody(request)
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey), bytes.NewReader(raw))
endpoint := geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey)
if request.Stream {
endpoint = geminiStreamURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return Response{}, err
}
req.Header.Set("Content-Type", "application/json")
responseStartedAt := time.Now()
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return Response{}, err
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
var result map[string]any
var wire *WireResponse
if request.Stream && resp.StatusCode >= 200 && resp.StatusCode < 300 {
wire = &WireResponse{Protocol: ProtocolGeminiGenerateContent, StatusCode: resp.StatusCode, Headers: compatibleResponseHeaders(resp.Header)}
result, err = decodeGeminiStreamResponse(resp, request.StreamDelta, wire)
} else {
result, wire, err = decodeHTTPResponseForProtocol(resp, ProtocolGeminiGenerateContent)
}
responseFinishedAt := time.Now()
if err != nil {
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
@@ -52,10 +70,20 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
ResponseStartedAt: responseStartedAt,
ResponseFinishedAt: responseFinishedAt,
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
UpstreamProtocol: ProtocolGeminiGenerateContent,
Wire: wire,
}, nil
}
func geminiURL(baseURL string, model string, apiKey string) string {
return geminiActionURL(baseURL, model, "generateContent", apiKey, false)
}
func geminiStreamURL(baseURL string, model string, apiKey string) string {
return geminiActionURL(baseURL, model, "streamGenerateContent", apiKey, true)
}
func geminiActionURL(baseURL string, model string, action string, apiKey string, stream bool) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
base = "https://generativelanguage.googleapis.com"
@@ -67,7 +95,51 @@ func geminiURL(baseURL string, model string, apiKey string) string {
base += "/v1beta"
}
escapedModel := url.PathEscape(model)
return fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
endpoint := fmt.Sprintf("%s/models/%s:%s?key=%s", base, escapedModel, action, url.QueryEscape(apiKey))
if stream {
endpoint += "&alt=sse"
}
return endpoint
}
func decodeGeminiStreamResponse(resp *http.Response, onDelta StreamDelta, wire *WireResponse) (map[string]any, error) {
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 16*1024*1024)
result := map[string]any{}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var event map[string]any
if err := json.Unmarshal([]byte(data), &event); err != nil {
return nil, &ClientError{Code: "invalid_response", Message: err.Error(), StatusCode: resp.StatusCode, Retryable: false, Wire: wire}
}
result = event
if onDelta != nil {
if err := onDelta(StreamDeltaEvent{
Text: geminiText(event),
Event: event,
WireProtocol: ProtocolGeminiGenerateContent,
WireStatusCode: resp.StatusCode,
WireHeaders: compatibleResponseHeaders(resp.Header),
}); err != nil {
return nil, err
}
}
}
if err := scanner.Err(); err != nil {
return nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true, Wire: wire}
}
if len(result) == 0 {
return nil, &ClientError{Code: "invalid_response", Message: "gemini stream returned no events", StatusCode: resp.StatusCode, Retryable: false, Wire: wire}
}
return result, nil
}
func geminiBody(request Request) map[string]any {
+68 -4
View File
@@ -44,25 +44,89 @@ func intValue(body map[string]any, key string, fallback int) int {
}
func decodeHTTPResponse(resp *http.Response) (map[string]any, error) {
result, _, err := decodeHTTPResponseForProtocol(resp, "")
return result, err
}
func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[string]any, *WireResponse, error) {
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
wire := &WireResponse{
Protocol: strings.TrimSpace(protocol),
StatusCode: resp.StatusCode,
Headers: compatibleResponseHeaders(resp.Header),
RawJSON: append([]byte(nil), raw...),
}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &wire.Body)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &ClientError{
return nil, wire, &ClientError{
Code: statusCodeName(resp.StatusCode),
Message: errorMessage(raw, resp.Status),
StatusCode: resp.StatusCode,
RequestID: requestIDFromHTTPResponse(resp),
Retryable: HTTPRetryable(resp.StatusCode),
Wire: wire,
}
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
wire.Body = map[string]any{}
return map[string]any{}, wire, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, &ClientError{Code: "invalid_response", Message: err.Error(), Retryable: false}
return nil, wire, &ClientError{Code: "invalid_response", Message: err.Error(), StatusCode: resp.StatusCode, Retryable: false, Wire: wire}
}
return out, nil
wire.Body = cloneBody(out)
return out, wire, nil
}
func compatibleResponseHeaders(source http.Header) map[string][]string {
if len(source) == 0 {
return nil
}
allowed := map[string]bool{
"content-type": true, "retry-after": true,
"x-request-id": true, "request-id": true,
"x-ratelimit-limit-requests": true, "x-ratelimit-remaining-requests": true,
"x-ratelimit-reset-requests": true, "x-ratelimit-limit-tokens": true,
"x-ratelimit-remaining-tokens": true, "x-ratelimit-reset-tokens": true,
"x-goog-request-id": true, "x-goog-upload-status": true,
"x-goog-upload-url": true, "x-goog-upload-size-received": true,
}
out := map[string][]string{}
for key, values := range source {
if !allowed[strings.ToLower(strings.TrimSpace(key))] {
continue
}
out[http.CanonicalHeaderKey(key)] = append([]string(nil), values...)
}
if len(out) == 0 {
return nil
}
return out
}
func notifySubmissionStarted(request Request) error {
if request.OnUpstreamSubmissionStarted == nil {
return nil
}
return request.OnUpstreamSubmissionStarted()
}
func notifyResponseReceived(request Request) error {
if request.OnUpstreamResponseReceived == nil {
return nil
}
return request.OnUpstreamResponseReceived()
}
func notifyWireResponse(request Request, wire *WireResponse) error {
if wire == nil || request.OnUpstreamWireResponse == nil {
return nil
}
return request.OnUpstreamWireResponse(wire)
}
func decodeOpenAIStreamResponse(resp *http.Response, onDelta StreamDelta) (map[string]any, error) {
+44 -12
View File
@@ -80,7 +80,18 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
}()
if upstreamTaskID == "" {
submitResult, requestID, err := c.postJSON(ctx, request, prepared.Endpoint, token, prepared.Payload)
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
submitResult, requestID, submitWire, err := c.postJSON(ctx, request, prepared.Endpoint, token, prepared.Payload)
if notifyErr := notifyWireResponse(request, submitWire); notifyErr != nil {
return Response{}, notifyErr
}
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
@@ -122,6 +133,11 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
}
lastResult = pollResult
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(upstreamTaskID, pollResult); err != nil {
return Response{}, err
}
}
switch kelingTaskStatus(pollResult) {
case "succeed":
@@ -176,7 +192,18 @@ func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, toke
if err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload)
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
submitResult, requestID, submitWire, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload, ProtocolKlingV2Omni)
if notifyErr := notifyWireResponse(request, submitWire); notifyErr != nil {
return Response{}, notifyErr
}
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
@@ -224,6 +251,11 @@ func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, toke
if err != nil {
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
}
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(upstreamTaskID, pollResult); err != nil {
return Response{}, err
}
}
task := kelingTaskAPITask(pollResult, upstreamTaskID)
lastStatus = strings.ToLower(strings.TrimSpace(stringFromAny(task["status"])))
@@ -567,31 +599,31 @@ func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request
return elements, createdIDs, nil
}
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) {
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body)
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, *WireResponse, error) {
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body, ProtocolKlingV1Omni)
}
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any) (map[string]any, string, error) {
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any, protocol string) (map[string]any, string, *WireResponse, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
if err != nil {
return nil, "", err
return nil, "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
return nil, "", nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, protocol)
if err != nil {
return result, requestID, err
return result, requestID, wire, err
}
if code := intFromAny(result["code"]); code != 0 {
return result, requestID, &ClientError{Code: kelingEnvelopeErrorCode(result), Message: kelingEnvelopeErrorMessage(result), RequestID: firstNonEmpty(requestID, stringFromAny(result["request_id"])), Retryable: false}
return result, requestID, wire, &ClientError{Code: kelingEnvelopeErrorCode(result), Message: kelingEnvelopeErrorMessage(result), RequestID: firstNonEmpty(requestID, stringFromAny(result["request_id"])), Retryable: false, Wire: wire}
}
return result, firstNonEmpty(requestID, stringFromAny(result["request_id"])), nil
return result, firstNonEmpty(requestID, stringFromAny(result["request_id"])), wire, nil
}
func (c KelingClient) getJSON(ctx context.Context, request Request, path string, token string) (map[string]any, string, error) {
@@ -663,7 +695,7 @@ func (c KelingClient) cleanupKelingElements(ctx context.Context, request Request
if id == "" {
continue
}
_, _, _ = c.postJSON(ctx, request, "/general/delete-elements", token, map[string]any{"element_id": id})
_, _, _, _ = c.postJSON(ctx, request, "/general/delete-elements", token, map[string]any{"element_id": id})
}
return nil
}
+53 -3
View File
@@ -70,23 +70,37 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
responseStartedAt := time.Now()
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return Response{}, err
}
requestID := requestIDFromHTTPResponse(resp)
var result map[string]any
var wire *WireResponse
upstreamResponseID := ""
nativeStreamDelta := openAIWireStreamDelta(request.StreamDelta, openAIWireProtocol(endpointKind), resp)
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses && stream {
result, upstreamResponseID, err = decodeNativeResponsesStream(resp, request.StreamDelta)
result, upstreamResponseID, err = decodeNativeResponsesStream(resp, nativeStreamDelta)
wire = &WireResponse{Protocol: openAIWireProtocol(endpointKind), StatusCode: resp.StatusCode, Headers: compatibleResponseHeaders(resp.Header)}
} else {
var streamDelta StreamDelta = request.StreamDelta
var streamDelta StreamDelta = nativeStreamDelta
var adapter *chatResponsesStreamAdapter
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions && stream {
adapter = newChatResponsesStreamAdapter(request.PublicResponseID, request.Model)
streamDelta = func(event StreamDeltaEvent) error { return adapter.delta(event, request.StreamDelta) }
}
result, err = decodeOpenAIResponse(resp, stream, streamDelta)
if stream {
result, err = decodeOpenAIResponse(resp, true, streamDelta)
wire = &WireResponse{Protocol: openAIWireProtocol(endpointKind), StatusCode: resp.StatusCode, Headers: compatibleResponseHeaders(resp.Header)}
} else {
result, wire, err = decodeHTTPResponseForProtocol(resp, openAIWireProtocol(endpointKind))
}
if err == nil && endpointKind == "chat.completions" {
result = NormalizeChatCompletionResult(result)
}
@@ -103,6 +117,12 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
Progress: providerProgress(request), ResponseStartedAt: responseStartedAt, ResponseFinishedAt: time.Now(),
UpstreamProtocol: protocol, UpstreamEndpoint: endpoint, UpstreamResponseID: upstreamResponseID,
PublicResponseID: request.PublicResponseID, ResponseConverted: true,
Wire: func() *WireResponse {
if wire != nil {
wire.Converted = true
}
return wire
}(),
}, nil
}
}
@@ -138,9 +158,39 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
UpstreamEndpoint: endpoint,
UpstreamResponseID: upstreamResponseID,
PublicResponseID: publicResponseID,
Wire: wire,
}, nil
}
func openAIWireStreamDelta(next StreamDelta, protocol string, response *http.Response) StreamDelta {
if next == nil {
return nil
}
return func(event StreamDeltaEvent) error {
event.WireProtocol = protocol
if response != nil {
event.WireStatusCode = response.StatusCode
event.WireHeaders = compatibleResponseHeaders(response.Header)
}
return next(event)
}
}
func openAIWireProtocol(kind string) string {
switch kind {
case "chat.completions":
return ProtocolOpenAIChatCompletions
case "responses":
return ProtocolOpenAIResponses
case "embeddings":
return ProtocolOpenAIEmbeddings
case "images.generations", "images.edits":
return ProtocolOpenAIImages
default:
return "openai_" + strings.ReplaceAll(kind, ".", "_")
}
}
func decodeOpenAIResponse(resp *http.Response, stream bool, onDelta StreamDelta) (map[string]any, error) {
if stream {
result, err := decodeOpenAIStreamResponse(resp, onDelta)
@@ -45,7 +45,15 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response,
requestID := upstreamTaskID
var submitResult map[string]any
if upstreamTaskID == "" {
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
result, id, err := c.submit(ctx, request, payload)
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
if err != nil {
return Response{}, annotateResponseError(err, id, startedAt, time.Now())
}
@@ -15,6 +15,12 @@ const (
ProtocolOpenAIChatCompletions = "openai_chat_completions"
ProtocolOpenAIResponses = "openai_responses"
ProtocolAnthropicMessages = "anthropic_messages"
ProtocolOpenAIEmbeddings = "openai_embeddings"
ProtocolOpenAIImages = "openai_images"
ProtocolGeminiGenerateContent = "gemini_generate_content"
ProtocolVolcesContents = "volces_contents_generations_v3"
ProtocolKlingV1Omni = "kling_v1_omni_video"
ProtocolKlingV2Omni = "kling_v2_omni_video"
)
var supportedResponseFallbackParameters = map[string]struct{}{
@@ -0,0 +1,51 @@
package clients
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestLocalUnsupportedKindDoesNotMarkUpstreamSubmissionStarted(t *testing.T) {
started, responseReceived := false, false
_, err := (VolcesClient{}).Run(context.Background(), Request{
Kind: "speech.generations",
Candidate: store.RuntimeModelCandidate{
Provider: "volces", Credentials: map[string]any{"apiKey": "test"},
},
OnUpstreamSubmissionStarted: func() error { started = true; return nil },
OnUpstreamResponseReceived: func() error { responseReceived = true; return nil },
})
if err == nil || ErrorCode(err) != "unsupported_kind" {
t.Fatalf("expected local unsupported_kind, got %v", err)
}
if started || responseReceived {
t.Fatalf("local validation must remain not_submitted: started=%v response=%v", started, responseReceived)
}
}
func TestNetworkDisconnectMarksSubmittingWithoutResponseReceived(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
baseURL := upstream.URL
upstream.Close()
started, responseReceived := false, false
_, err := (VolcesClient{HTTPClient: upstream.Client()}).Run(context.Background(), Request{
Kind: "images.generations",
Body: map[string]any{"prompt": "test"},
Candidate: store.RuntimeModelCandidate{
Provider: "volces", BaseURL: baseURL, ModelName: "seedream-test",
Credentials: map[string]any{"apiKey": "test"},
},
OnUpstreamSubmissionStarted: func() error { started = true; return nil },
OnUpstreamResponseReceived: func() error { responseReceived = true; return nil },
})
if err == nil || ErrorCode(err) != "network" {
t.Fatalf("expected network error, got %v", err)
}
if !started || responseReceived {
t.Fatalf("network disconnect must remain submitting: started=%v response=%v", started, responseReceived)
}
}
+50 -17
View File
@@ -11,23 +11,38 @@ import (
)
type Request struct {
Kind string
ModelType string
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
RemoteTaskID string
RemoteTaskPayload map[string]any
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
OnRemoteTaskPolled func(remoteTaskID string, payload map[string]any) error
Stream bool
StreamDelta StreamDelta
UpstreamProtocol string
PublicResponseID string
PublicPreviousResponseID string
UpstreamPreviousResponseID string
PreviousResponseTurns []ResponseTurn
Kind string
ModelType string
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
RemoteTaskID string
RemoteTaskPayload map[string]any
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
OnRemoteTaskPolled func(remoteTaskID string, payload map[string]any) error
OnUpstreamSubmissionStarted func() error
OnUpstreamResponseReceived func() error
OnUpstreamWireResponse func(*WireResponse) error
Stream bool
StreamDelta StreamDelta
UpstreamProtocol string
PublicResponseID string
PublicPreviousResponseID string
UpstreamPreviousResponseID string
PreviousResponseTurns []ResponseTurn
}
// WireResponse preserves the provider-facing response independently from the
// canonical result used by billing, retries, and cross-protocol conversion.
// Headers are filtered before this value leaves the clients package.
type WireResponse struct {
Protocol string `json:"protocol,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
Body map[string]any `json:"body,omitempty"`
RawJSON []byte `json:"-"`
Converted bool `json:"converted,omitempty"`
}
type ResponseTurn struct {
@@ -54,6 +69,7 @@ type Response struct {
ResponseChainDepth int
ResponseConverted bool
InternalResult map[string]any
Wire *WireResponse
}
type Usage struct {
@@ -75,6 +91,10 @@ type StreamDeltaEvent struct {
Text string
ReasoningContent string
Event map[string]any
WireProtocol string
WireConverted bool
WireStatusCode int
WireHeaders map[string][]string
}
type StreamDelta func(event StreamDeltaEvent) error
@@ -112,6 +132,15 @@ type ClientError struct {
ResponseFinishedAt time.Time
ResponseDurationMS int64
Retryable bool
Wire *WireResponse
}
func ErrorWireResponse(err error) *WireResponse {
var clientErr *ClientError
if errors.As(err, &clientErr) {
return clientErr.Wire
}
return nil
}
func ErrorParam(err error) string {
@@ -142,6 +171,10 @@ func upstreamModelName(candidate store.RuntimeModelCandidate) string {
}
func ErrorCode(err error) string {
var coded interface{ ErrorCode() string }
if errors.As(err, &coded) && strings.TrimSpace(coded.ErrorCode()) != "" {
return strings.TrimSpace(coded.ErrorCode())
}
var clientErr *ClientError
if errors.As(err, &clientErr) && clientErr.Code != "" {
return clientErr.Code
+8
View File
@@ -40,7 +40,15 @@ func (c UniversalClient) Run(ctx context.Context, request Request) (Response, er
if err != nil {
return Response{}, err
}
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
submitResult, submitRequestID, err = c.universalSubmit(ctx, executor, request, modelType, payload)
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, startedAt, time.Now())
}
+58 -15
View File
@@ -46,12 +46,18 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
req.Header.Set("Authorization", "Bearer "+apiKey)
responseStartedAt := time.Now()
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return Response{}, err
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, ProtocolOpenAIImages)
responseFinishedAt := time.Now()
if err != nil {
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
@@ -67,6 +73,8 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
ResponseStartedAt: responseStartedAt,
ResponseFinishedAt: responseFinishedAt,
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
UpstreamProtocol: ProtocolOpenAIImages,
Wire: wire,
}, nil
}
@@ -80,7 +88,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
if err := validateVolcesVideoTaskBody(body); err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, taskPath, 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())
@@ -119,7 +127,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
case <-nextPoll.C:
pollStartedAt := time.Now()
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
pollResult, pollRequestID, pollWire, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
pollFinishedAt := time.Now()
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
lastRequestID = requestID
@@ -151,6 +159,8 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
ResponseStartedAt: submitStartedAt,
ResponseFinishedAt: pollFinishedAt,
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
UpstreamProtocol: ProtocolVolcesContents,
Wire: pollWire,
}, nil
case "failed", "cancelled":
return Response{}, &ClientError{
@@ -214,44 +224,73 @@ func volcesVideoTaskPath(request Request) string {
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) {
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, *WireResponse, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
if err != nil {
return nil, "", err
return nil, "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
if err := notifySubmissionStarted(request); err != nil {
return nil, "", nil, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
return nil, "", nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return nil, "", nil, err
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, ProtocolVolcesContents)
if err != nil {
return result, requestID, err
if notifyErr := notifyWireResponse(request, wire); notifyErr != nil {
return result, requestID, wire, notifyErr
}
return result, requestID, wire, err
}
original := result
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
if wire != nil {
wire.Converted = volcesCompatibleResultConverted(original)
}
if notifyErr := notifyWireResponse(request, wire); notifyErr != nil {
return result, firstNonEmpty(requestID, envelopeRequestID), wire, notifyErr
}
return result, firstNonEmpty(requestID, envelopeRequestID), wire, err
}
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) {
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, *WireResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
if err != nil {
return nil, "", err
return nil, "", nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
return nil, "", nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, ProtocolVolcesContents)
if err != nil {
return result, requestID, err
return result, requestID, wire, err
}
original := result
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
if wire != nil {
wire.Converted = volcesCompatibleResultConverted(original)
}
return result, firstNonEmpty(requestID, envelopeRequestID), wire, err
}
func volcesCompatibleResultConverted(result map[string]any) bool {
if _, ok := result["error"].(map[string]any); ok {
return true
}
_, hasCode := result["code"]
_, hasData := result["data"].(map[string]any)
return hasCode && hasData
}
func normalizeVolcesCompatibleResult(result map[string]any) (map[string]any, string, error) {
@@ -354,6 +393,10 @@ func cleanProviderBody(body map[string]any) map[string]any {
"poll_interval_ms",
"pollTimeoutSeconds",
"poll_timeout_seconds",
"_gateway_compatibility",
"_gateway_target_protocol",
"_compat_provider",
"_kling_compat_version",
} {
delete(out, key)
}
@@ -0,0 +1,85 @@
package clients
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestDecodeHTTPResponseForProtocolCapturesOfficialErrorWire(t *testing.T) {
response := &http.Response{
StatusCode: http.StatusTooManyRequests,
Status: "429 Too Many Requests",
Header: http.Header{
"Content-Type": {"application/json"},
"X-Request-Id": {"req_1"},
"X-Ratelimit-Reset-Requests": {"1s"},
"Set-Cookie": {"secret=must-not-leak"},
"Connection": {"keep-alive"},
},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"slow down","future_field":true}}`)),
}
_, wire, err := decodeHTTPResponseForProtocol(response, ProtocolOpenAIResponses)
if err == nil {
t.Fatal("expected upstream error")
}
if wire == nil || wire.StatusCode != http.StatusTooManyRequests || wire.Protocol != ProtocolOpenAIResponses {
t.Fatalf("unexpected wire response: %+v", wire)
}
if wire.Headers["X-Request-Id"][0] != "req_1" || wire.Headers["X-Ratelimit-Reset-Requests"][0] != "1s" {
t.Fatalf("official response headers were lost: %+v", wire.Headers)
}
if _, ok := wire.Headers["Set-Cookie"]; ok {
t.Fatalf("sensitive header leaked: %+v", wire.Headers)
}
if _, ok := wire.Headers["Connection"]; ok {
t.Fatalf("hop-by-hop header leaked: %+v", wire.Headers)
}
if ErrorWireResponse(err) != wire {
t.Fatal("client error did not retain its wire response")
}
}
func TestGeminiNativeStreamPreservesOfficialEvents(t *testing.T) {
var requestedPath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.RequestURI()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("X-Goog-Request-Id", "goog-stream-1")
_, _ = io.WriteString(w, "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello\"}]}}],\"futureOfficialField\":true}\n\n")
}))
defer upstream.Close()
var events []StreamDeltaEvent
response, err := (GeminiClient{HTTPClient: upstream.Client()}).Run(context.Background(), Request{
Kind: "chat.completions", ModelType: "text", Model: "gemini-test", Stream: true,
Body: map[string]any{"prompt": "hello"},
Candidate: store.RuntimeModelCandidate{
Provider: "gemini", BaseURL: upstream.URL, ProviderModelName: "gemini-test",
Credentials: map[string]any{"apiKey": "test-key"},
},
StreamDelta: func(event StreamDeltaEvent) error {
events = append(events, event)
return nil
},
})
if err != nil {
t.Fatalf("Gemini stream failed: %v", err)
}
if !strings.Contains(requestedPath, ":streamGenerateContent") || !strings.Contains(requestedPath, "alt=sse") {
t.Fatalf("unexpected Gemini stream endpoint: %s", requestedPath)
}
if len(events) != 1 || events[0].WireProtocol != ProtocolGeminiGenerateContent || events[0].Event["futureOfficialField"] != true {
raw, _ := json.Marshal(events)
t.Fatalf("official Gemini event was not preserved: %s", raw)
}
if response.Wire == nil || response.Wire.Headers["X-Goog-Request-Id"][0] != "goog-stream-1" {
t.Fatalf("Gemini stream wire metadata was lost: %+v", response.Wire)
}
}
@@ -0,0 +1,180 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func (s *Server) requireProtocolUser(protocol string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := s.auth.Authenticate(r)
if err != nil {
status, code, message := http.StatusUnauthorized, "unauthorized", "unauthorized"
var requestErr *auth.RequestAuthError
if errors.As(err, &requestErr) {
status, code, message = requestErr.Status, requestErr.Code, requestErr.Message
}
writeProtocolError(w, protocol, status, message, nil, code)
return
}
if auth.PermissionLevel(user.Roles) < 1 {
writeProtocolError(w, protocol, http.StatusForbidden, "forbidden", nil, "permission_denied")
return
}
if strings.EqualFold(strings.TrimSpace(user.Source), "oidc") {
result, resolveErr := s.resolveOIDCUserProjection(r.Context(), r, user)
if resolveErr != nil {
status, code, message := http.StatusServiceUnavailable, errorCodeGatewayProvisioningFailed, "Gateway 账号初始化失败,请稍后重试"
switch {
case errors.Is(resolveErr, store.ErrOIDCUserNotProvisioned):
status, code, message = http.StatusForbidden, errorCodeGatewayUserNotProvisioned, "该账号尚未开通 EasyAI Gateway"
case errors.Is(resolveErr, store.ErrOIDCUserDisabled):
status, code, message = http.StatusForbidden, errorCodeGatewayUserDisabled, "该 Gateway 账号已停用,请联系管理员"
case errors.Is(resolveErr, store.ErrOIDCTenantUnavailable):
status, code, message = http.StatusServiceUnavailable, errorCodeGatewayTenantUnavailable, "Gateway 租户尚未就绪,请联系管理员"
}
writeProtocolError(w, protocol, status, message, nil, code)
return
}
user = result.User
}
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
})
}
func targetProtocolForTaskRequest(kind string, r *http.Request) string {
switch kind {
case "chat.completions":
return clients.ProtocolOpenAIChatCompletions
case "responses":
return clients.ProtocolOpenAIResponses
case "embeddings":
return clients.ProtocolOpenAIEmbeddings
case "images.generations", "images.edits":
return clients.ProtocolOpenAIImages
default:
return ""
}
}
func wireResponseMatches(wire *clients.WireResponse, targetProtocol string) bool {
return wire != nil && !wire.Converted && strings.TrimSpace(wire.Protocol) == strings.TrimSpace(targetProtocol)
}
func writeWireResponse(w http.ResponseWriter, wire *clients.WireResponse) {
if wire == nil {
return
}
for name, values := range wire.Headers {
for _, value := range values {
if strings.EqualFold(name, "Content-Type") {
w.Header().Set(name, value)
} else {
w.Header().Add(name, value)
}
}
}
status := wire.StatusCode
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
if len(wire.RawJSON) > 0 {
_, _ = w.Write(wire.RawJSON)
return
}
_ = json.NewEncoder(w).Encode(wire.Body)
}
func writeProtocolError(w http.ResponseWriter, protocol string, status int, message string, details map[string]any, code string) {
switch protocol {
case clients.ProtocolGeminiGenerateContent:
writeGeminiError(w, status, message, details, code)
case clients.ProtocolVolcesContents:
writeVolcesError(w, status, message, code)
case clients.ProtocolKlingV1Omni, clients.ProtocolKlingV2Omni:
writeKelingCompatError(w, "", newKelingCompatError(status, kelingCompatBusinessCode(code, message), message))
default:
writeOpenAIError(w, status, message, details, code)
}
}
func writeOpenAIError(w http.ResponseWriter, status int, message string, details map[string]any, code string) {
errorType := "server_error"
if status >= 400 && status < 500 {
errorType = "invalid_request_error"
}
var param any
if details != nil {
param = details["param"]
}
payload := map[string]any{
"message": message,
"type": errorType,
"param": param,
"code": nil,
}
if strings.TrimSpace(code) != "" {
payload["code"] = code
}
writeJSON(w, status, map[string]any{"error": payload})
}
func writeGeminiError(w http.ResponseWriter, status int, message string, details map[string]any, code string) {
writeJSON(w, status, geminiErrorEnvelope(status, message, details, code))
}
func geminiErrorEnvelope(status int, message string, details map[string]any, code string) map[string]any {
detailList := []any{}
if len(details) > 0 {
detailList = append(detailList, details)
}
return map[string]any{"error": map[string]any{
"code": status,
"message": message,
"status": googleRPCStatus(status, code),
"details": detailList,
}}
}
func googleRPCStatus(status int, code string) string {
switch status {
case http.StatusBadRequest:
return "INVALID_ARGUMENT"
case http.StatusUnauthorized:
return "UNAUTHENTICATED"
case http.StatusForbidden:
return "PERMISSION_DENIED"
case http.StatusNotFound:
return "NOT_FOUND"
case http.StatusConflict:
return "ALREADY_EXISTS"
case http.StatusTooManyRequests:
return "RESOURCE_EXHAUSTED"
case http.StatusServiceUnavailable:
return "UNAVAILABLE"
case http.StatusGatewayTimeout:
return "DEADLINE_EXCEEDED"
default:
if strings.EqualFold(code, "cancelled") || strings.EqualFold(code, "canceled") {
return "CANCELLED"
}
return "INTERNAL"
}
}
func writeVolcesError(w http.ResponseWriter, status int, message string, code string) {
if strings.TrimSpace(code) == "" {
code = http.StatusText(status)
}
writeJSON(w, status, map[string]any{"error": map[string]any{
"code": code,
"message": message,
}})
}
@@ -0,0 +1,176 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestProtocolErrorsUseOfficialShapesWithoutGatewayExtensions(t *testing.T) {
tests := []struct {
name string
protocol string
status int
assertBody func(*testing.T, map[string]any)
}{
{
name: "openai", protocol: clients.ProtocolOpenAIChatCompletions, status: http.StatusBadRequest,
assertBody: func(t *testing.T, body map[string]any) {
errorBody := requireObject(t, body["error"])
if errorBody["type"] != "invalid_request_error" || errorBody["code"] != "unsupported_kind" {
t.Fatalf("unexpected OpenAI error: %+v", body)
}
assertNoKeys(t, errorBody, "status", "retryable", "taskId", "gateway_status")
},
},
{
name: "gemini", protocol: clients.ProtocolGeminiGenerateContent, status: http.StatusTooManyRequests,
assertBody: func(t *testing.T, body map[string]any) {
errorBody := requireObject(t, body["error"])
if errorBody["code"] != float64(http.StatusTooManyRequests) || errorBody["status"] != "RESOURCE_EXHAUSTED" {
t.Fatalf("unexpected Gemini error: %+v", body)
}
assertNoKeys(t, errorBody, "retryable", "taskId", "gateway_status")
},
},
{
name: "volces", protocol: clients.ProtocolVolcesContents, status: http.StatusBadGateway,
assertBody: func(t *testing.T, body map[string]any) {
errorBody := requireObject(t, body["error"])
if errorBody["code"] != "upstream_submission_unknown" {
t.Fatalf("unexpected Volces error: %+v", body)
}
assertNoKeys(t, errorBody, "status", "retryable", "taskId", "gateway_status")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
code := "unsupported_kind"
if test.name == "gemini" {
code = "rate_limit"
} else if test.name == "volces" {
code = "upstream_submission_unknown"
}
writeProtocolError(recorder, test.protocol, test.status, "failed", nil, code)
if recorder.Code != test.status {
t.Fatalf("status=%d, want %d", recorder.Code, test.status)
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
test.assertBody(t, body)
})
}
}
func TestWireResponsePassthroughPreservesStatusUnknownFieldsAndAllowedHeaders(t *testing.T) {
recorder := httptest.NewRecorder()
wire := &clients.WireResponse{
Protocol: clients.ProtocolOpenAIResponses,
StatusCode: http.StatusAccepted,
Headers: map[string][]string{
"Content-Type": {"application/json"},
"X-Request-Id": {"req_official"},
"Retry-After": {"2"},
},
RawJSON: []byte(`{"id":"resp_1","future_official_field":{"v":1}}`),
}
writeWireResponse(recorder, wire)
if recorder.Code != http.StatusAccepted || recorder.Header().Get("X-Request-Id") != "req_official" || recorder.Header().Get("Retry-After") != "2" {
t.Fatalf("wire metadata was not preserved: status=%d headers=%v", recorder.Code, recorder.Header())
}
if got := strings.TrimSpace(recorder.Body.String()); got != string(wire.RawJSON) {
t.Fatalf("wire body changed: %s", got)
}
if !wireResponseMatches(wire, clients.ProtocolOpenAIResponses) {
t.Fatal("native unconverted wire response must be eligible for passthrough")
}
wire.Converted = true
if wireResponseMatches(wire, clients.ProtocolOpenAIResponses) {
t.Fatal("converted wire response must not be eligible for passthrough")
}
}
func TestCompatibilityStatusMappings(t *testing.T) {
for internal, want := range map[string]string{
"queued": "queued", "running": "running", "succeeded": "succeeded", "failed": "failed", "cancelled": "cancelled",
} {
if got := volcesCompatibleTaskStatus(internal); got != want {
t.Fatalf("Volces status %q=%q, want %q", internal, got, want)
}
}
for internal, want := range map[string]string{
"queued": "submitted", "running": "processing", "succeeded": "succeed", "failed": "failed", "cancelled": "failed",
} {
if got := klingV1Status(internal); got != want {
t.Fatalf("Kling V1 status %q=%q, want %q", internal, got, want)
}
}
for internal, want := range map[string]string{
"queued": "submitted", "running": "processing", "succeeded": "succeeded", "failed": "failed", "cancelled": "failed",
} {
if got := klingV2Status(internal); got != want {
t.Fatalf("Kling V2 status %q=%q, want %q", internal, got, want)
}
}
}
func TestNativeOpenAIStreamPreservesUnknownEventFields(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
executor := &fakeTaskExecutor{
deltas: []clients.StreamDeltaEvent{{
Event: map[string]any{
"id": "chatcmpl_1", "object": "chat.completion.chunk",
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": "hi"}}},
"future_official_field": map[string]any{"enabled": true},
},
WireProtocol: clients.ProtocolOpenAIChatCompletions,
WireHeaders: map[string][]string{"X-Request-Id": {"req_stream_1"}},
}},
output: map[string]any{"id": "chatcmpl_1"},
}
writeProtocolCompatibleTaskResponse(
context.Background(), recorder, request, executor,
"chat.completions", "gpt-test", clients.ProtocolOpenAIChatCompletions,
store.GatewayTask{ID: "gateway-task"}, &auth.User{}, true, false,
)
if recorder.Header().Get("X-Request-Id") != "req_stream_1" {
t.Fatalf("official stream header was lost: %+v", recorder.Header())
}
body := recorder.Body.String()
if !strings.Contains(body, `"future_official_field":{"enabled":true}`) || strings.Count(body, "data: [DONE]") != 1 {
t.Fatalf("native stream event was reconstructed or duplicated: %s", body)
}
if strings.Contains(body, "gateway-task") {
t.Fatalf("native stream leaked gateway task id: %s", body)
}
}
func requireObject(t *testing.T, value any) map[string]any {
t.Helper()
result, ok := value.(map[string]any)
if !ok {
t.Fatalf("value is not an object: %#v", value)
}
return result
}
func assertNoKeys(t *testing.T, object map[string]any, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := object[key]; ok {
t.Fatalf("unexpected gateway extension %q in %+v", key, object)
}
}
}
+144 -28
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -46,7 +47,7 @@ var geminiGenerateContentRoutePrefixes = []string{
}
func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
handler := s.requireUser(auth.PermissionBasic, http.HandlerFunc(s.geminiGenerateContent))
handler := s.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(s.geminiGenerateContent))
for _, prefix := range geminiGenerateContentRoutePrefixes {
mux.Handle("POST "+prefix, geminiGenerateContentRouteHandler(prefix, handler))
}
@@ -54,22 +55,37 @@ func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
func geminiGenerateContentRouteHandler(prefix string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
model, ok := geminiGenerateContentModelFromPath(prefix, r.URL.Path)
model, stream, ok := geminiContentModelFromPath(prefix, r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
r.SetPathValue("model", model)
r.SetPathValue("stream", strconv.FormatBool(stream))
next.ServeHTTP(w, r)
})
}
func geminiGenerateContentModelFromPath(prefix string, requestPath string) (string, bool) {
func geminiContentModelFromPath(prefix string, requestPath string) (string, bool, bool) {
if !strings.HasPrefix(requestPath, prefix) {
return "", false
return "", false, false
}
model, ok := strings.CutSuffix(strings.TrimPrefix(requestPath, prefix), ":generateContent")
if !ok || strings.TrimSpace(model) == "" || strings.Contains(model, "/") {
relative := strings.TrimPrefix(requestPath, prefix)
for _, action := range []struct {
suffix string
stream bool
}{{":generateContent", false}, {":streamGenerateContent", true}} {
model, ok := strings.CutSuffix(relative, action.suffix)
if ok && strings.TrimSpace(model) != "" && !strings.Contains(model, "/") {
return model, action.stream, true
}
}
return "", false, false
}
func geminiGenerateContentModelFromPath(prefix string, requestPath string) (string, bool) {
model, stream, ok := geminiContentModelFromPath(prefix, requestPath)
if !ok || stream {
return "", false
}
return model, true
@@ -81,38 +97,47 @@ func geminiGenerateContentModelFromPath(prefix string, requestPath string) (stri
// @Tags gemini-compatible
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Security BearerAuth
// @Param model path string true "模型名称"
// @Param input body map[string]interface{} true "Gemini generateContent 请求"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Failure 400 {object} GeminiErrorEnvelope
// @Failure 401 {object} GeminiErrorEnvelope
// @Failure 403 {object} GeminiErrorEnvelope
// @Failure 502 {object} GeminiErrorEnvelope
// @Router /api/v1/models/{model}:generateContent [post]
// @Router /api/v1/models/{model}:streamGenerateContent [post]
func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
writeGeminiTaskError := func(status int, message string, details map[string]any, code string) {
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, status, message, details, code)
}
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
writeGeminiTaskError(http.StatusUnauthorized, "unauthorized", nil, "unauthorized")
return
}
var native map[string]any
if err := json.NewDecoder(r.Body).Decode(&native); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
writeGeminiTaskError(http.StatusBadRequest, "invalid json body", nil, "invalid_json_body")
return
}
mapping, err := geminiImageTaskBody(r.PathValue("model"), native)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
writeGeminiTaskError(http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
streamMode := r.PathValue("stream") == "true"
if streamMode {
mapping.Body["stream"] = true
}
if !apiKeyScopeAllowed(user, mapping.Kind) {
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
writeGeminiTaskError(http.StatusForbidden, "api key scope does not allow this capability", nil, "permission_denied")
return
}
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
writeGeminiTaskError(http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", nil, "invalid_idempotency_key")
return
}
prepared, err := s.prepareTaskRequest(r.Context(), r, user, mapping.Body)
@@ -122,7 +147,7 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
if code := clients.ErrorCode(err); strings.HasPrefix(code, "upload_") || code == "request_asset_upload_failed" {
status = http.StatusBadGateway
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
writeGeminiTaskError(status, err.Error(), nil, clients.ErrorCode(err))
return
}
createInput := store.CreateTaskInput{
@@ -137,16 +162,16 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
}
if hasIdempotencyKey {
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(mapping.Kind, false, false, prepared.Body)
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(mapping.Kind, false, streamMode, prepared.Body)
}
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
if err != nil {
if errors.Is(err, store.ErrIdempotencyKeyReused) {
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
writeGeminiTaskError(http.StatusConflict, "Idempotency-Key was reused for a different request", nil, "idempotency_key_reused")
return
}
s.logger.Error("create gemini task failed", "kind", mapping.Kind, "error_category", "task_create_failed")
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
writeGeminiTaskError(http.StatusInternalServerError, "create task failed", nil, "task_create_failed")
return
}
task := created.Task
@@ -154,31 +179,122 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
if s.billingMetrics != nil {
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
}
w.Header().Set("Idempotent-Replayed", "true")
w.Header().Set("X-Gateway-Task-Id", task.ID)
if streamMode {
writeGeminiTaskError(http.StatusConflict, "streaming idempotent replay is not supported", nil, "idempotency_stream_replay_unsupported")
return
}
if task.Status == "succeeded" {
if _, native := task.Result["candidates"]; native {
writeJSON(w, http.StatusOK, task.Result)
return
}
writeJSON(w, http.StatusOK, geminiGenerateContentResponse(task.Result, mapping.Model))
return
}
writeIdempotentTaskReplay(w, task, true)
status := http.StatusConflict
if task.Status == "failed" || task.Status == "cancelled" {
status = storedTaskErrorStatus(task.ErrorCode)
}
writeGeminiTaskError(status, firstNonEmpty(task.ErrorMessage, task.Error, task.Message, "task is not complete"), nil, firstNonEmpty(task.ErrorCode, task.Status))
return
}
runCtx, cancelRun := s.requestExecutionContext(r)
defer cancelRun()
if streamMode {
s.writeGeminiGenerateContentStream(runCtx, w, r, task, user, mapping.Model, writeGeminiTaskError)
return
}
result, runErr := s.runner.Execute(runCtx, task, user)
if runErr != nil {
if !requestStillConnected(r) {
return
}
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, wire)
return
}
writeGeminiTaskError(statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
return
}
if !requestStillConnected(r) {
return
}
if wireResponseMatches(result.Wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, result.Wire)
return
}
writeJSON(w, http.StatusOK, geminiGenerateContentResponse(result.Output, mapping.Model))
}
func (s *Server) writeGeminiGenerateContentStream(runCtx context.Context, w http.ResponseWriter, r *http.Request, task store.GatewayTask, user *auth.User, model string, writeGeminiTaskError func(int, string, map[string]any, string)) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, _ := w.(http.Flusher)
nativePassthrough := false
convertedFrames := false
result, runErr := s.runner.ExecuteStream(runCtx, task, user, func(delta clients.StreamDeltaEvent) error {
if !requestStillConnected(r) {
return nil
}
if delta.Event != nil && delta.WireProtocol == clients.ProtocolGeminiGenerateContent && !delta.WireConverted {
for name, values := range delta.WireHeaders {
for _, value := range values {
if strings.EqualFold(name, "Content-Type") {
w.Header().Set(name, value)
} else {
w.Header().Add(name, value)
}
}
}
nativePassthrough = true
writeGeminiSSEFrame(w, delta.Event)
} else if delta.Text != "" {
convertedFrames = true
writeGeminiSSEFrame(w, geminiTextStreamFrame(delta.Text))
}
if flusher != nil {
flusher.Flush()
}
return nil
})
if runErr != nil {
if !requestStillConnected(r) {
return
}
if !nativePassthrough && !convertedFrames {
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, wire)
return
}
writeGeminiTaskError(statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
} else if !nativePassthrough {
status := statusFromRunError(runErr)
writeGeminiSSEFrame(w, geminiErrorEnvelope(status, runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr)))
if flusher != nil {
flusher.Flush()
}
}
return
}
if !nativePassthrough && !convertedFrames && requestStillConnected(r) {
writeGeminiSSEFrame(w, geminiGenerateContentResponse(result.Output, model))
if flusher != nil {
flusher.Flush()
}
}
}
func writeGeminiSSEFrame(w http.ResponseWriter, payload map[string]any) {
raw, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
}
func geminiTextStreamFrame(text string) map[string]any {
return map[string]any{"candidates": []any{map[string]any{
"content": map[string]any{"role": "model", "parts": []any{map[string]any{"text": text}}},
}}}
}
func geminiImageTaskBody(model string, native map[string]any) (geminiImageTaskMapping, error) {
model = strings.TrimSpace(model)
if model == "" {
@@ -424,7 +540,7 @@ func geminiUsageMetadataFromOutput(output map[string]any) map[string]any {
// @Router /api/v1/gemini/upload/{version}/files [post]
func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
if geminiUploadCommandHas(r, "start") {
@@ -453,13 +569,13 @@ func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
// @Router /api/v1/gemini/upload/{version}/files/{uploadID} [post]
func (s *Server) geminiFilesUploadFinalize(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
uploadID := strings.TrimSpace(r.PathValue("uploadID"))
value, ok := s.geminiUploadSessions.LoadAndDelete(uploadID)
if !ok {
writeError(w, http.StatusBadRequest, "Gemini upload session not found", "bad_request")
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, "Gemini upload session not found", nil, "bad_request")
return
}
session, _ := value.(geminiUploadSession)
@@ -498,7 +614,7 @@ func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Reques
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
payload, err := io.ReadAll(r.Body)
if err != nil || len(payload) == 0 {
writeError(w, http.StatusBadRequest, "file payload is required", "bad_request")
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, http.StatusBadRequest, "file payload is required", nil, "bad_request")
return
}
mimeType := firstNonEmptyGeminiString(session.MimeType, http.DetectContentType(payload), "application/octet-stream")
@@ -515,7 +631,7 @@ func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Reques
if clients.ErrorCode(err) == "upload_no_channel" {
status = http.StatusServiceUnavailable
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
writeProtocolError(w, clients.ProtocolGeminiGenerateContent, status, err.Error(), nil, clients.ErrorCode(err))
return
}
sizeBytes := session.SizeBytes
@@ -69,6 +69,16 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) {
}
}
func TestGeminiStreamGenerateContentModelFromPath(t *testing.T) {
model, stream, ok := geminiContentModelFromPath("/v1beta/models/", "/v1beta/models/gemini-2.5-flash:streamGenerateContent")
if !ok || !stream || model != "gemini-2.5-flash" {
t.Fatalf("unexpected stream route parse: model=%q stream=%v ok=%v", model, stream, ok)
}
if _, ok := geminiGenerateContentModelFromPath("/v1beta/models/", "/v1beta/models/gemini-2.5-flash:streamGenerateContent"); ok {
t.Fatal("non-stream route parser must not accept streamGenerateContent")
}
}
func TestRegisterGeminiGenerateContentRoutes(t *testing.T) {
server := &Server{
auth: auth.New("test-secret", "", ""),
+151 -50
View File
@@ -1038,10 +1038,7 @@ 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/embeddings [post]
// @Router /api/v1/reranks [post]
// @Router /api/v1/images/generations [post]
// @Router /api/v1/images/edits [post]
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
// @Router /api/v1/music/generations [post]
@@ -1049,9 +1046,21 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Router /api/v1/voice_clone [post]
func (s *Server) createTask(kind string, compatible bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetProtocol := targetProtocolForTaskRequest(kind, r)
writeTaskError := func(status int, message string, details map[string]any, codes ...string) {
if targetProtocol == "" {
writeErrorWithDetails(w, status, message, details, codes...)
return
}
code := ""
if len(codes) > 0 {
code = codes[0]
}
writeProtocolError(w, targetProtocol, status, message, details, code)
}
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
writeTaskError(http.StatusUnauthorized, "unauthorized", nil)
return
}
@@ -1061,32 +1070,37 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if code := clients.ErrorCode(err); strings.HasPrefix(code, "upload_") || code == "request_asset_upload_failed" || code == "request_asset_public_url_required" {
status = http.StatusBadGateway
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
writeTaskError(status, err.Error(), nil, 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))
writeTaskError(http.StatusBadRequest, err.Error(), map[string]any{"param": clients.ErrorParam(err)}, clients.ErrorCode(err))
return
}
}
requestedModel := requestModelName(body)
model := canonicalTaskModelName(kind, requestedModel)
if model == "" {
writeError(w, http.StatusBadRequest, "model is required")
writeTaskError(http.StatusBadRequest, "model is required", nil, "invalid_parameter")
return
}
if model != requestedModel {
body["model"] = model
}
if !apiKeyScopeAllowed(user, kind) {
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
writeTaskError(http.StatusForbidden, "api key scope does not allow this capability", nil, "permission_denied")
return
}
responsePlan := planTaskResponse(kind, compatible, body, r)
if targetProtocol != "" {
// OpenAI compatibility endpoints are synchronous APIs. Gateway async
// task envelopes belong only to native Gateway routes.
responsePlan.asyncMode = false
}
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
if err != nil {
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
writeTaskError(http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", nil, "invalid_idempotency_key")
return
}
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
@@ -1096,7 +1110,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if code := clients.ErrorCode(err); strings.HasPrefix(code, "upload_") || code == "request_asset_upload_failed" {
status = http.StatusBadGateway
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
writeTaskError(status, err.Error(), nil, clients.ErrorCode(err))
return
}
@@ -1117,11 +1131,11 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
if err != nil {
if errors.Is(err, store.ErrIdempotencyKeyReused) {
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
writeTaskError(http.StatusConflict, "Idempotency-Key was reused for a different request", nil, "idempotency_key_reused")
return
}
s.logger.Error("create task failed", "kind", kind, "error_category", "task_create_failed")
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
writeTaskError(http.StatusInternalServerError, "create task failed", nil, "task_create_failed")
return
}
task := created.Task
@@ -1129,9 +1143,23 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
if s.billingMetrics != nil {
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
}
w.Header().Set("Idempotent-Replayed", "true")
if targetProtocol == "" {
w.Header().Set("Idempotent-Replayed", "true")
}
if responsePlan.streamMode {
writeError(w, http.StatusConflict, "streaming idempotent replay is not supported", "idempotency_stream_replay_unsupported")
writeTaskError(http.StatusConflict, "streaming idempotent replay is not supported", nil, "idempotency_stream_replay_unsupported")
return
}
if targetProtocol != "" {
if task.Status == "succeeded" {
writeJSON(w, http.StatusOK, task.Result)
return
}
status := http.StatusConflict
if task.Status == "failed" || task.Status == "cancelled" {
status = storedTaskErrorStatus(task.ErrorCode)
}
writeTaskError(status, firstNonEmpty(task.ErrorMessage, task.Error, task.Message, "task is not complete"), nil, firstNonEmpty(task.ErrorCode, task.Status))
return
}
writeIdempotentTaskReplay(w, task, responsePlan.compatibleMode)
@@ -1139,7 +1167,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
}
if responsePlan.asyncMode {
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
writeError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
writeTaskError(http.StatusInternalServerError, err.Error(), nil, "enqueue_failed")
return
}
writeTaskAccepted(w, task)
@@ -1148,7 +1176,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
runCtx, cancelRun := s.requestExecutionContext(r)
defer cancelRun()
if responsePlan.compatibleMode {
writeCompatibleTaskResponse(runCtx, w, r, s.runner, kind, model, task, user, responsePlan.streamMode, streamIncludeUsage(body))
writeProtocolCompatibleTaskResponse(runCtx, w, r, s.runner, kind, model, targetProtocol, task, user, responsePlan.streamMode, streamIncludeUsage(body))
return
}
result, runErr := s.runner.Execute(runCtx, task, user)
@@ -1174,13 +1202,13 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
// @Param X-Async header bool false "该接口忽略此参数"
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope
// @Failure 404 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
// @Router /api/v1/chat/completions [post]
func (s *Server) createAPIV1ChatCompletions() http.Handler {
return s.createTask("chat.completions", false)
@@ -1196,12 +1224,12 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// @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
// @Failure 400 {object} OpenAIErrorEnvelope "invalid_parameter"
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
func openAIChatCompletionsDoc() {}
// openAIResponsesDoc godoc
@@ -1214,15 +1242,45 @@ func openAIChatCompletionsDoc() {}
// @Security BearerAuth
// @Param input body ResponsesRequest true "Responses 请求;Chat 回退只支持自定义 function tools"
// @Success 200 {object} ResponsesCompatibleResponse
// @Header 200 {string} X-Gateway-Task-Id "网关审计任务 ID"
// @Failure 400 {object} ErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter"
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
// @Failure 400 {object} OpenAIErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter"
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 402 {object} OpenAIErrorEnvelope
// @Failure 403 {object} OpenAIErrorEnvelope
// @Failure 503 {object} OpenAIErrorEnvelope "response_chain_unavailable"
// @Router /api/v1/responses [post]
func openAIResponsesDoc() {}
// openAIEmbeddingsDoc godoc
// @Summary 创建 OpenAI Embeddings
// @Tags embeddings
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param input body TaskRequest true "OpenAI Embeddings 请求"
// @Success 200 {object} CompatibleResponse
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
// @Router /api/v1/embeddings [post]
func openAIEmbeddingsDoc() {}
// openAIImagesDoc godoc
// @Summary 创建或编辑 OpenAI Images
// @Tags images
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param input body TaskRequest true "OpenAI Images 请求"
// @Success 200 {object} CompatibleResponse
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
// @Failure 429 {object} OpenAIErrorEnvelope
// @Failure 502 {object} OpenAIErrorEnvelope
// @Router /api/v1/images/generations [post]
// @Router /api/v1/images/edits [post]
func openAIImagesDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
@@ -1254,14 +1312,44 @@ type taskExecutor interface {
}
func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter, r *http.Request, executor taskExecutor, kind string, model string, task store.GatewayTask, user *auth.User, streamMode bool, includeUsage bool) {
w.Header().Set("X-Gateway-Task-Id", task.ID)
writeProtocolCompatibleTaskResponse(runCtx, w, r, executor, kind, model, "", task, user, streamMode, includeUsage)
}
func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter, r *http.Request, executor taskExecutor, kind string, model string, targetProtocol string, task store.GatewayTask, user *auth.User, streamMode bool, includeUsage bool) {
if targetProtocol == "" {
w.Header().Set("X-Gateway-Task-Id", task.ID)
}
if streamMode {
flusher := prepareCompatibleStream(w)
streamWriter := newCompatibleStreamWriter(kind, model, includeUsage)
nativeStreamPassthrough := false
result, runErr := executor.ExecuteStream(runCtx, task, user, func(delta clients.StreamDeltaEvent) error {
if !requestStillConnected(r) {
return nil
}
if targetProtocol != "" && delta.Event != nil && delta.WireProtocol == targetProtocol && !delta.WireConverted {
for name, values := range delta.WireHeaders {
for _, value := range values {
if strings.EqualFold(name, "Content-Type") {
w.Header().Set(name, value)
} else {
w.Header().Add(name, value)
}
}
}
nativeStreamPassthrough = true
if kind == "responses" {
eventType, _ := delta.Event["type"].(string)
sendSSE(w, eventType, delta.Event)
} else {
raw, _ := json.Marshal(delta.Event)
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
}
if flusher != nil {
flusher.Flush()
}
return nil
}
streamWriter.writeDelta(w, delta)
if flusher != nil {
flusher.Flush()
@@ -1272,20 +1360,12 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
if !requestStillConnected(r) {
return
}
status := statusFromRunError(runErr)
errorPayload := map[string]any{
"code": runErrorCode(runErr),
"message": runErrorMessage(runErr),
"status": status,
errorPayload := map[string]any{"message": runErrorMessage(runErr), "type": "server_error", "param": nil, "code": runErrorCode(runErr)}
if status := statusFromRunError(runErr); status >= 400 && status < 500 {
errorPayload["type"] = "invalid_request_error"
}
if result.Task.ID != "" {
errorPayload["taskId"] = result.Task.ID
}
if result.Task.RequestID != "" {
errorPayload["requestId"] = result.Task.RequestID
}
for key, value := range runErrorDetails(runErr) {
errorPayload[key] = value
if param := runErrorDetails(runErr)["param"]; param != nil {
errorPayload["param"] = param
}
sendSSE(w, "error", map[string]any{"error": errorPayload})
if flusher != nil {
@@ -1296,6 +1376,15 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
if !requestStillConnected(r) {
return
}
if nativeStreamPassthrough {
if kind != "responses" {
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
}
if flusher != nil {
flusher.Flush()
}
return
}
streamWriter.writeDone(w, result.Output)
if flusher != nil {
flusher.Flush()
@@ -1308,12 +1397,24 @@ func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter,
if !requestStillConnected(r) {
return
}
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, targetProtocol) {
writeWireResponse(w, wire)
return
}
if targetProtocol != "" {
writeProtocolError(w, targetProtocol, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
return
}
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
return
}
if !requestStillConnected(r) {
return
}
if wireResponseMatches(result.Wire, targetProtocol) {
writeWireResponse(w, result.Wire)
return
}
writeJSON(w, http.StatusOK, result.Output)
}
@@ -1482,7 +1583,7 @@ func statusFromRunError(err error) int {
return http.StatusBadRequest
case clients.ErrorCode(err) == "response_chain_unavailable":
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":
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "unsupported_kind" || 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
@@ -181,11 +181,31 @@ func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create task failed"))
return
}
if err := s.store.SetTaskCompatibilitySubmission(r.Context(), task.ID, store.CompatibilitySubmission{
TargetProtocol: clients.ProtocolKlingV1Omni,
PublicID: task.ID,
}); err != nil {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create compatibility metadata failed"))
return
}
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
s.logger.Error("enqueue Kling-compatible task failed", "taskId", task.ID, "error", err)
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusServiceUnavailable, 5001, "video task queue is unavailable"))
return
}
task, createErr = s.waitForCompatibilitySubmission(r, task)
if createErr != nil {
if wire := clients.ErrorWireResponse(createErr); wireResponseMatches(wire, clients.ProtocolKlingV1Omni) {
writeWireResponse(w, wire)
return
}
writeKelingCompatError(w, requestID, kelingCompatGatewayError(createErr))
return
}
if kelingTaskUsesNativeProtocol(task) && len(task.CompatibilitySubmitBody) > 0 {
writeJSON(w, task.CompatibilitySubmitHTTPStatus, task.CompatibilitySubmitBody)
return
}
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
Code: 0,
Message: "SUCCEED",
@@ -214,7 +234,10 @@ func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
return
}
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
task, err := s.store.GetCompatibilityTask(r.Context(), clients.ProtocolKlingV1Omni, strings.TrimSpace(r.PathValue("taskID")))
if err != nil && store.IsNotFound(err) {
task, err = s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
}
if err != nil {
if store.IsNotFound(err) {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
@@ -228,6 +251,16 @@ func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
return
}
if kelingTaskUsesNativeProtocol(task) {
if raw, ok := task.Result["raw"].(map[string]any); ok && len(raw) > 0 {
writeJSON(w, http.StatusOK, raw)
return
}
if len(task.RemoteTaskPayload) > 0 {
writeJSON(w, http.StatusOK, task.RemoteTaskPayload)
return
}
}
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
Code: 0,
Message: "SUCCEED",
@@ -370,20 +403,21 @@ func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCo
}
externalTaskID := strings.TrimSpace(stringFromKelingCompat(input["external_task_id"]))
normalized := map[string]any{
"model": model,
"model_name": requestedModel,
"modelType": "omni_video",
"runMode": "real",
"content": content,
"resolution": resolution,
"mode": mode,
"sound": sound,
"audio": sound == "on",
"multi_shot": multiShot,
"watermark": watermarkEnabled,
"watermark_info": map[string]any{"enabled": watermarkEnabled},
"external_task_id": externalTaskID,
"_gateway_compatibility": kelingOmniCompatibilityMarker,
"model": model,
"model_name": requestedModel,
"modelType": "omni_video",
"runMode": "real",
"content": content,
"resolution": resolution,
"mode": mode,
"sound": sound,
"audio": sound == "on",
"multi_shot": multiShot,
"watermark": watermarkEnabled,
"watermark_info": map[string]any{"enabled": watermarkEnabled},
"external_task_id": externalTaskID,
"_gateway_compatibility": kelingOmniCompatibilityMarker,
"_gateway_target_protocol": clients.ProtocolKlingV1Omni,
}
if prompt != "" {
normalized["prompt"] = prompt
@@ -648,7 +682,7 @@ func stringFromKelingCompat(value any) string {
func kelingCompatTaskData(task store.GatewayTask) map[string]any {
data := map[string]any{
"task_id": task.ID,
"task_id": kelingCompatPublicID(task),
"task_status": kelingCompatTaskStatus(task.Status),
"task_info": map[string]any{
"external_task_id": strings.TrimSpace(stringFromKelingCompat(task.Request["external_task_id"])),
@@ -668,12 +702,20 @@ func kelingCompatTaskData(task store.GatewayTask) map[string]any {
if videos := kelingCompatTaskVideos(task.Result); len(videos) > 0 {
data["task_result"] = map[string]any{"videos": videos}
}
if task.FinalChargeAmount > 0 {
data["final_unit_deduction"] = strconv.FormatFloat(task.FinalChargeAmount, 'f', -1, 64)
}
return data
}
func kelingCompatPublicID(task store.GatewayTask) string {
if publicID := strings.TrimSpace(task.CompatibilityPublicID); publicID != "" {
return publicID
}
return task.ID
}
func kelingTaskUsesNativeProtocol(task store.GatewayTask) bool {
return task.CompatibilityProtocol == clients.ProtocolKlingV1Omni && task.CompatibilitySourceProtocol == clients.ProtocolKlingV1Omni
}
func kelingCompatTaskStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "succeeded", "success", "completed":
@@ -197,9 +197,12 @@ func TestKelingCompatTaskDataAndOwnership(t *testing.T) {
t.Fatalf("cross-user task access must be rejected")
}
data := kelingCompatTaskData(task)
if data["task_status"] != "succeed" || data["final_unit_deduction"] != "2.5" {
if data["task_status"] != "succeed" {
t.Fatalf("unexpected task data: %+v", data)
}
if _, ok := data["final_unit_deduction"]; ok {
t.Fatalf("official compatibility response must not expose billing fields: %+v", data)
}
result, _ := data["task_result"].(map[string]any)
videos, _ := result["videos"].([]any)
video, _ := videos[0].(map[string]any)
+65 -5
View File
@@ -25,7 +25,7 @@ const (
func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
handler := func(next http.HandlerFunc) http.Handler {
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
return s.requireProtocolUser(clients.ProtocolKlingV1Omni, http.HandlerFunc(next))
}
// /api/v1 is the canonical public prefix. The historical /kling paths
// remain registered below so existing clients can migrate without downtime.
@@ -60,7 +60,7 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
// @Param input body map[string]interface{} true "可灵 V1 Omni 请求"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope
// @Failure 401 {object} KlingErrorEnvelope
// @Router /api/v1/kling/v1/videos/omni-video [post]
func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
var native map[string]any
@@ -86,7 +86,7 @@ func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request)
// @Param input body map[string]interface{} true "可灵 API 2.0 请求"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope
// @Failure 401 {object} KlingErrorEnvelope
// @Router /api/v1/kling/v2/omni-video/{model} [post]
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
model, ok := klingV2ProviderModel(r.PathValue("model"))
@@ -151,11 +151,28 @@ func (s *Server) createKlingCompatTask(w http.ResponseWriter, r *http.Request, v
return
}
task := created.Task
targetProtocol := clients.ProtocolKlingV1Omni
if version == "v2" {
targetProtocol = clients.ProtocolKlingV2Omni
}
if !created.Replayed {
if err := s.store.SetTaskCompatibilitySubmission(r.Context(), task.ID, store.CompatibilitySubmission{TargetProtocol: targetProtocol, PublicID: task.ID}); err != nil {
writeKlingCompatError(w, http.StatusInternalServerError, "create compatibility metadata failed", "task_create_failed")
return
}
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
writeKlingCompatError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
return
}
task, err = s.waitForCompatibilitySubmission(r, task)
if err != nil {
if wire := clients.ErrorWireResponse(err); wireResponseMatches(wire, targetProtocol) {
writeWireResponse(w, wire)
return
}
writeKlingCompatError(w, statusFromRunError(err), err.Error(), clients.ErrorCode(err))
return
}
} else {
w.Header().Set("Idempotent-Replayed", "true")
}
@@ -181,6 +198,11 @@ func klingCompatTaskBody(version string, model string, native map[string]any) (m
body["modelType"] = "omni_video"
body["_compat_provider"] = klingCompatProvider
body["_kling_compat_version"] = version
if version == "v2" {
body["_gateway_target_protocol"] = clients.ProtocolKlingV2Omni
} else {
body["_gateway_target_protocol"] = clients.ProtocolKlingV1Omni
}
body["content"] = klingLegacyContent(body)
mode := strings.TrimSpace(stringFromRequestAny(body["mode"]))
if mode == "" && version == "v1" {
@@ -590,12 +612,23 @@ func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
}
func klingV1Envelope(task store.GatewayTask) map[string]any {
if klingTaskUsesNativeV1Protocol(task) {
if raw := klingMap(task.Result["raw"]); len(raw) > 0 {
return raw
}
if len(task.RemoteTaskPayload) > 0 {
return task.RemoteTaskPayload
}
if raw := klingMap(task.CompatibilitySubmitBody); len(raw) > 0 {
return raw
}
}
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV1TaskData(task)}
}
func klingV1TaskData(task store.GatewayTask) map[string]any {
data := map[string]any{
"task_id": task.ID, "task_status": klingV1Status(task.Status),
"task_id": klingCompatibilityPublicID(task), "task_status": klingV1Status(task.Status),
"task_info": map[string]any{"external_task_id": task.ExternalTaskID},
"created_at": task.CreatedAt.UnixMilli(), "updated_at": task.UpdatedAt.UnixMilli(),
}
@@ -613,12 +646,23 @@ func klingV1TaskData(task store.GatewayTask) map[string]any {
}
func klingV2Envelope(task store.GatewayTask) map[string]any {
if task.CompatibilityProtocol == clients.ProtocolKlingV2Omni && task.CompatibilitySourceProtocol == clients.ProtocolKlingV2Omni {
if raw := klingMap(task.Result["raw"]); len(raw) > 0 {
return raw
}
if len(task.RemoteTaskPayload) > 0 {
return task.RemoteTaskPayload
}
if len(task.CompatibilitySubmitBody) > 0 {
return task.CompatibilitySubmitBody
}
}
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV2TaskData(task)}
}
func klingV2TaskData(task store.GatewayTask) map[string]any {
data := map[string]any{
"id": task.ID, "status": klingV2Status(task.Status),
"id": klingCompatibilityPublicID(task), "status": klingV2Status(task.Status),
"create_time": task.CreatedAt.UnixMilli(), "update_time": task.UpdatedAt.UnixMilli(),
"external_id": task.ExternalTaskID,
}
@@ -631,6 +675,22 @@ func klingV2TaskData(task store.GatewayTask) map[string]any {
return data
}
func klingCompatibilityPublicID(task store.GatewayTask) string {
if publicID := strings.TrimSpace(task.CompatibilityPublicID); publicID != "" {
return publicID
}
return task.ID
}
func klingTaskUsesNativeV1Protocol(task store.GatewayTask) bool {
return task.CompatibilityProtocol == clients.ProtocolKlingV1Omni && task.CompatibilitySourceProtocol == clients.ProtocolKlingV1Omni
}
func klingMap(value any) map[string]any {
result, _ := value.(map[string]any)
return result
}
func klingTaskVideos(task store.GatewayTask) []any {
items, _ := task.Result["data"].([]any)
videos := make([]any, 0, len(items))
@@ -39,6 +39,55 @@ type ErrorPayload struct {
Param any `json:"param,omitempty"`
}
type OpenAIErrorEnvelope struct {
Error OpenAIErrorPayload `json:"error"`
}
type OpenAIErrorPayload struct {
Message string `json:"message" example:"Invalid parameter"`
Type string `json:"type" example:"invalid_request_error"`
Param string `json:"param,omitempty" example:"model"`
Code string `json:"code,omitempty" example:"invalid_parameter"`
}
type GeminiErrorEnvelope struct {
Error GeminiErrorStatus `json:"error"`
}
type GeminiErrorStatus struct {
Code int `json:"code" example:"400"`
Message string `json:"message" example:"Invalid argument"`
Status string `json:"status" example:"INVALID_ARGUMENT"`
Details []map[string]any `json:"details"`
}
type VolcesErrorEnvelope struct {
Error VolcesErrorPayload `json:"error"`
}
type VolcesErrorPayload struct {
Code string `json:"code" example:"invalid_parameter"`
Message string `json:"message" example:"model is required"`
}
type VolcesContentsGenerationTaskResponse struct {
ID string `json:"id" example:"cgt-202607221234"`
Model string `json:"model,omitempty"`
Status string `json:"status,omitempty" enums:"queued,running,succeeded,failed,cancelled"`
Content map[string]any `json:"content,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
Usage map[string]any `json:"usage,omitempty"`
Error map[string]any `json:"error,omitempty"`
}
type KlingErrorEnvelope struct {
Code int `json:"code" example:"1001"`
Message string `json:"message" example:"invalid parameter"`
RequestID string `json:"request_id"`
Error string `json:"error,omitempty" example:"invalid_parameter"`
}
type AuthResponse struct {
AccessToken string `json:"accessToken" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
TokenType string `json:"tokenType" example:"Bearer"`
+28 -27
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
@@ -256,12 +257,12 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("GET /api/v1/playground/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
mux.Handle("POST /api/v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /api/v1/chat/completions", server.requireProtocolUser(clients.ProtocolOpenAIChatCompletions, server.createAPIV1ChatCompletions()))
mux.Handle("POST /api/v1/responses", server.requireProtocolUser(clients.ProtocolOpenAIResponses, server.createTask("responses", true)))
mux.Handle("POST /api/v1/embeddings", server.requireProtocolUser(clients.ProtocolOpenAIEmbeddings, server.createTask("embeddings", true)))
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /api/v1/images/generations", server.requireProtocolUser(clients.ProtocolOpenAIImages, server.createTask("images.generations", true)))
mux.Handle("POST /api/v1/images/edits", server.requireProtocolUser(clients.ProtocolOpenAIImages, server.createTask("images.edits", true)))
mux.Handle("POST /api/v1/images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", true)))
mux.Handle("POST /api/v1/videos/upscales", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
@@ -279,20 +280,20 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("GET /api/v1/resource/material/user/materials", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listSeedancePortraitAssets)))
mux.Handle("POST /api/v1/resource/material", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createSeedancePortraitAsset)))
mux.Handle("POST /api/v1/resource/material/seedance-portrait-assets/sync", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.syncSeedancePortraitAssets)))
mux.Handle("POST /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
mux.Handle("GET /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
mux.Handle("GET /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
mux.Handle("DELETE /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
mux.Handle("POST /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
mux.Handle("GET /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
mux.Handle("GET /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
mux.Handle("DELETE /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
mux.Handle("POST /api/v3/contents/generations/tasks", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
mux.Handle("GET /api/v3/contents/generations/tasks", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
mux.Handle("GET /api/v3/contents/generations/tasks/{taskID}", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
mux.Handle("DELETE /api/v3/contents/generations/tasks/{taskID}", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
mux.Handle("POST /api/v1/contents/generations/tasks", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
mux.Handle("GET /api/v1/contents/generations/tasks", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
mux.Handle("GET /api/v1/contents/generations/tasks/{taskID}", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
mux.Handle("DELETE /api/v1/contents/generations/tasks/{taskID}", server.requireProtocolUser(clients.ProtocolVolcesContents, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
server.registerGeminiGenerateContentRoutes(mux)
server.registerKlingCompatibilityRoutes(mux)
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
mux.Handle("POST /api/v1/gemini/upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /api/v1/gemini/upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
mux.Handle("POST /upload/{version}/files", server.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(server.geminiFilesUploadFinalize)))
mux.Handle("POST /api/v1/gemini/upload/{version}/files", server.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /api/v1/gemini/upload/{version}/files/{uploadID}", server.requireProtocolUser(clients.ProtocolGeminiGenerateContent, http.HandlerFunc(server.geminiFilesUploadFinalize)))
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/v1/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
@@ -303,18 +304,18 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
mux.Handle("GET /tasks/{taskID}/param-preprocessing", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
mux.Handle("GET /tasks/{taskID}/events", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("POST /chat/completions", server.requireUser(auth.PermissionBasic, server.createTask("chat.completions", true)))
mux.Handle("POST /v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createTask("chat.completions", true)))
mux.Handle("POST /responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /chat/completions", server.requireProtocolUser(clients.ProtocolOpenAIChatCompletions, server.createTask("chat.completions", true)))
mux.Handle("POST /v1/chat/completions", server.requireProtocolUser(clients.ProtocolOpenAIChatCompletions, server.createTask("chat.completions", true)))
mux.Handle("POST /responses", server.requireProtocolUser(clients.ProtocolOpenAIResponses, server.createTask("responses", true)))
mux.Handle("POST /v1/responses", server.requireProtocolUser(clients.ProtocolOpenAIResponses, server.createTask("responses", true)))
mux.Handle("POST /embeddings", server.requireProtocolUser(clients.ProtocolOpenAIEmbeddings, server.createTask("embeddings", true)))
mux.Handle("POST /v1/embeddings", server.requireProtocolUser(clients.ProtocolOpenAIEmbeddings, server.createTask("embeddings", true)))
mux.Handle("POST /reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /images/generations", server.requireProtocolUser(clients.ProtocolOpenAIImages, server.createTask("images.generations", true)))
mux.Handle("POST /v1/images/generations", server.requireProtocolUser(clients.ProtocolOpenAIImages, server.createTask("images.generations", true)))
mux.Handle("POST /images/edits", server.requireProtocolUser(clients.ProtocolOpenAIImages, server.createTask("images.edits", true)))
mux.Handle("POST /v1/images/edits", server.requireProtocolUser(clients.ProtocolOpenAIImages, server.createTask("images.edits", true)))
mux.Handle("POST /images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /v1/images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
-1
View File
@@ -12,7 +12,6 @@ import (
func prepareCompatibleStream(w http.ResponseWriter) http.Flusher {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, _ := w.(http.Flusher)
return flusher
}
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
@@ -22,17 +23,21 @@ const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]any
// @Success 200 {object} VolcesContentsGenerationTaskResponse
// @Failure 400 {object} VolcesErrorEnvelope
// @Failure 401 {object} VolcesErrorEnvelope
// @Failure 429 {object} VolcesErrorEnvelope
// @Failure 502 {object} VolcesErrorEnvelope
// @Router /api/v1/contents/generations/tasks [post]
func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
writeVolcesError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
return
}
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
writeVolcesError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
return
}
task, err := s.createVolcesCompatibleTask(r, user, body)
@@ -40,7 +45,7 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
writeVolcesCompatibleTaskError(w, err)
return
}
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
writeJSON(w, http.StatusOK, volcesCompatibleCreateResponse(task))
}
// getVolcesContentsGenerationTask godoc
@@ -49,7 +54,8 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} VolcesContentsGenerationTaskResponse
// @Failure 404 {object} VolcesErrorEnvelope
// @Router /api/v1/contents/generations/tasks/{taskID} [get]
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r)
@@ -65,11 +71,13 @@ func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]any
// @Failure 400 {object} VolcesErrorEnvelope
// @Failure 401 {object} VolcesErrorEnvelope
// @Router /api/v1/contents/generations/tasks [get]
func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
writeVolcesError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
return
}
page := portraitAssetQueryInt(r, "page_num", "pageNumber", "page")
@@ -84,7 +92,7 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
})
if err != nil {
s.logger.Error("list Volces-compatible tasks failed", "error", err)
writeError(w, http.StatusInternalServerError, "list tasks failed")
writeVolcesError(w, http.StatusInternalServerError, "list tasks failed", "internal_error")
return
}
items := make([]any, 0)
@@ -94,8 +102,6 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "total": tasks.Total,
"page_num": tasks.Page, "page_size": tasks.PageSize,
// data/page are retained as additive gateway fields for existing callers.
"data": items, "page": tasks.Page,
})
}
@@ -106,7 +112,8 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} VolcesContentsGenerationTaskResponse
// @Failure 404 {object} VolcesErrorEnvelope
// @Router /api/v1/contents/generations/tasks/{taskID} [delete]
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r)
@@ -117,23 +124,20 @@ func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
result, err := s.runner.CancelVolcesVideoTask(r.Context(), task, user)
if err != nil {
if errors.Is(err, runner.ErrTaskAccessDenied) {
writeError(w, http.StatusNotFound, "task not found")
writeVolcesError(w, http.StatusNotFound, "task not found", "not_found")
return
}
s.logger.Error("cancel Volces-compatible task failed", "error", err)
writeError(w, http.StatusInternalServerError, "cancel task failed")
writeVolcesError(w, http.StatusInternalServerError, "cancel task failed", "internal_error")
return
}
updated, err := s.store.GetTask(r.Context(), task.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "get cancelled task failed")
writeVolcesError(w, http.StatusInternalServerError, "get cancelled task failed", "internal_error")
return
}
response := volcesCompatibleTask(updated)
response["cancelled"] = result.Cancelled
response["cancellable"] = result.Cancellable
response["message"] = result.Message
writeJSON(w, http.StatusOK, response)
_ = result
writeJSON(w, http.StatusOK, volcesCompatibleTask(updated))
}
// createLegacyVolcesVideoGeneration godoc
@@ -202,34 +206,112 @@ func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, bo
return store.GatewayTask{}, &clients.ClientError{Code: "forbidden", Message: "api key scope does not allow video generation", StatusCode: http.StatusForbidden, Retryable: false}
}
body["_gateway_compatibility"] = volcesContentsCompatibilityMarker
body["_gateway_target_protocol"] = clients.ProtocolVolcesContents
task, err := s.prepareAndCreateGatewayTask(r.Context(), r, user, "videos.generations", model, body, true)
if err != nil {
return store.GatewayTask{}, err
}
if err := s.store.SetTaskCompatibilitySubmission(r.Context(), task.ID, store.CompatibilitySubmission{
TargetProtocol: clients.ProtocolVolcesContents,
PublicID: task.ID,
}); err != nil {
return store.GatewayTask{}, err
}
task.CompatibilityProtocol = clients.ProtocolVolcesContents
task.CompatibilityPublicID = task.ID
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
return store.GatewayTask{}, &clients.ClientError{Code: "enqueue_failed", Message: err.Error(), StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
return task, nil
return s.waitForCompatibilitySubmission(r, task)
}
func (s *Server) waitForCompatibilitySubmission(r *http.Request, task store.GatewayTask) (store.GatewayTask, error) {
deadline := time.NewTimer(30 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
current, err := s.store.GetTask(r.Context(), task.ID)
if err != nil {
return store.GatewayTask{}, err
}
if current.RemoteTaskID != "" || current.Status == "succeeded" {
return current, nil
}
if current.Status == "failed" || current.Status == "cancelled" {
status := http.StatusBadGateway
switch current.ErrorCode {
case "bad_request", "invalid_parameter", "unsupported_kind", "unsupported_model", "missing_credentials":
status = http.StatusBadRequest
case "permission_denied", "forbidden":
status = http.StatusForbidden
case "rate_limit":
status = http.StatusTooManyRequests
}
return store.GatewayTask{}, &clients.ClientError{
Code: firstNonEmpty(current.ErrorCode, current.Status),
Message: firstNonEmpty(current.ErrorMessage, current.Error, current.Message),
StatusCode: status,
Retryable: false,
Wire: compatibilitySubmissionWire(current),
}
}
select {
case <-r.Context().Done():
return store.GatewayTask{}, r.Context().Err()
case <-deadline.C:
return store.GatewayTask{}, &clients.ClientError{Code: "submission_timeout", Message: "upstream did not confirm task submission before timeout", StatusCode: http.StatusGatewayTimeout, Retryable: true}
case <-ticker.C:
}
}
}
func compatibilitySubmissionWire(task store.GatewayTask) *clients.WireResponse {
if task.CompatibilitySubmitHTTPStatus == 0 || strings.TrimSpace(task.CompatibilitySourceProtocol) == "" {
return nil
}
headers := map[string][]string{}
for name, value := range task.CompatibilitySubmitHeaders {
switch typed := value.(type) {
case []any:
for _, item := range typed {
if text := strings.TrimSpace(volcesCompatString(item)); text != "" {
headers[name] = append(headers[name], text)
}
}
case []string:
headers[name] = append([]string(nil), typed...)
case string:
headers[name] = []string{typed}
}
}
return &clients.WireResponse{
Protocol: task.CompatibilitySourceProtocol,
StatusCode: task.CompatibilitySubmitHTTPStatus,
Headers: headers,
Body: cloneVolcesCompatibleMap(task.CompatibilitySubmitBody),
Converted: task.CompatibilitySourceProtocol != task.CompatibilityProtocol,
}
}
func (s *Server) volcesCompatibleTaskForUser(w http.ResponseWriter, r *http.Request) (store.GatewayTask, bool) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
writeVolcesError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
return store.GatewayTask{}, false
}
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
task, err := s.store.GetVolcesCompatibleTask(r.Context(), user, volcesContentsCompatibilityMarker, strings.TrimSpace(r.PathValue("taskID")))
if err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
writeVolcesError(w, http.StatusNotFound, "task not found", "not_found")
return store.GatewayTask{}, false
}
s.logger.Error("get Volces-compatible task failed", "error", err)
writeError(w, http.StatusInternalServerError, "get task failed")
writeVolcesError(w, http.StatusInternalServerError, "get task failed", "internal_error")
return store.GatewayTask{}, false
}
if !isVolcesCompatibleTask(task) || !kelingCompatTaskOwnedBy(task, user) {
writeError(w, http.StatusNotFound, "task not found")
writeVolcesError(w, http.StatusNotFound, "task not found", "not_found")
return store.GatewayTask{}, false
}
return task, true
@@ -240,21 +322,20 @@ func isVolcesCompatibleTask(task store.GatewayTask) bool {
}
func volcesCompatibleTask(task store.GatewayTask) map[string]any {
response := cloneVolcesCompatibleMap(task.Result)
if len(response) == 0 {
response = cloneVolcesCompatibleMap(task.RemoteTaskPayload)
if volcesTaskUsesNativeProtocol(task) {
if raw := cloneVolcesCompatibleMap(mapFromVolcesCompat(task.Result["raw"])); len(raw) > 0 {
return raw
}
if raw := cloneVolcesCompatibleMap(task.RemoteTaskPayload); len(raw) > 0 {
return raw
}
}
if response == nil {
response = map[string]any{}
}
response["id"] = task.ID
response := map[string]any{}
response["id"] = volcesCompatiblePublicID(task)
response["model"] = firstNonEmpty(volcesCompatString(response["model"]), task.Model)
response["status"] = volcesCompatibleTaskStatus(task.Status)
response["created_at"] = task.CreatedAt.Unix()
response["updated_at"] = task.UpdatedAt.Unix()
if task.RemoteTaskID != "" {
response["upstream_task_id"] = task.RemoteTaskID
}
for _, key := range []string{"content", "seed", "resolution", "ratio", "duration", "frames", "framespersecond"} {
if response[key] == nil && task.Request[key] != nil {
response[key] = task.Request[key]
@@ -266,14 +347,45 @@ func volcesCompatibleTask(task store.GatewayTask) map[string]any {
if task.Status == "failed" || task.Status == "cancelled" {
response["error"] = map[string]any{"code": firstNonEmpty(task.ErrorCode, strings.ToUpper(task.Status)), "message": firstNonEmpty(task.ErrorMessage, task.Error, task.Message)}
}
response["gateway_task_id"] = task.ID
response["gateway_status"] = task.Status
response["billings"] = task.Billings
response["billing_summary"] = task.BillingSummary
response["final_charge_amount"] = task.FinalChargeAmount
return response
}
func volcesCompatibleCreateResponse(task store.GatewayTask) map[string]any {
if volcesTaskUsesNativeProtocol(task) {
if raw := cloneVolcesCompatibleMap(task.RemoteTaskPayload); len(raw) > 0 {
return raw
}
}
return map[string]any{"id": volcesCompatiblePublicID(task)}
}
func volcesCompatiblePublicID(task store.GatewayTask) string {
if strings.TrimSpace(task.CompatibilityPublicID) != "" {
return strings.TrimSpace(task.CompatibilityPublicID)
}
if volcesTaskUsesNativeProtocol(task) && strings.TrimSpace(task.RemoteTaskID) != "" {
return strings.TrimSpace(task.RemoteTaskID)
}
return task.ID
}
func volcesTaskUsesNativeProtocol(task store.GatewayTask) bool {
if strings.TrimSpace(task.CompatibilitySourceProtocol) != "" {
return task.CompatibilitySourceProtocol == clients.ProtocolVolcesContents && task.CompatibilityProtocol == clients.ProtocolVolcesContents
}
for index := len(task.Attempts) - 1; index >= 0; index-- {
if provider := strings.ToLower(strings.TrimSpace(task.Attempts[index].Provider)); provider != "" {
return provider == "volces"
}
}
return false
}
func mapFromVolcesCompat(value any) map[string]any {
result, _ := value.(map[string]any)
return result
}
func volcesCompatibleTaskStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "succeeded", "success", "completed":
@@ -316,7 +428,11 @@ func writeVolcesCompatibleTaskError(w http.ResponseWriter, err error) {
} else if errors.As(err, &clientErr) {
status = http.StatusBadRequest
}
writeError(w, status, err.Error(), clients.ErrorCode(err))
if wire := clients.ErrorWireResponse(err); wireResponseMatches(wire, clients.ProtocolVolcesContents) {
writeWireResponse(w, wire)
return
}
writeVolcesError(w, status, err.Error(), clients.ErrorCode(err))
}
func volcesCompatString(value any) string {
@@ -7,23 +7,32 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestVolcesCompatibleTaskPreservesOfficialFieldsAndGatewayBilling(t *testing.T) {
func TestVolcesCompatibleTaskPreservesNativeOfficialFieldsWithoutGatewayExtensions(t *testing.T) {
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
task := store.GatewayTask{
ID: "gateway-task-1", Kind: "videos.generations", Status: "succeeded", Model: "doubao-seedance-2-0-mini-260615",
RemoteTaskID: "cgt-upstream-1", CreatedAt: now, UpdatedAt: now.Add(time.Second),
Result: map[string]any{
"id": "cgt-upstream-1", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
"content": map[string]any{"video_url": "https://example.com/out.mp4"}, "usage": map[string]any{"total_tokens": 9},
"raw": map[string]any{
"id": "cgt-upstream-1", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
"content": map[string]any{"video_url": "https://example.com/out.mp4"}, "usage": map[string]any{"total_tokens": 9},
"future_official_field": "preserved",
},
},
Billings: []any{map[string]any{"amount": 3}}, BillingSummary: map[string]any{"currency": "resource"}, FinalChargeAmount: 3,
Attempts: []store.TaskAttempt{{Provider: "volces"}},
}
got := volcesCompatibleTask(task)
if got["id"] != task.ID || got["upstream_task_id"] != task.RemoteTaskID || got["status"] != "succeeded" {
if got["id"] != task.RemoteTaskID || got["status"] != "succeeded" || got["future_official_field"] != "preserved" {
t.Fatalf("unexpected compatibility identity/status: %+v", got)
}
content, _ := got["content"].(map[string]any)
if content["video_url"] != "https://example.com/out.mp4" || got["usage"] == nil || got["billings"] == nil {
t.Fatalf("official or billing fields were lost: %+v", got)
if content["video_url"] != "https://example.com/out.mp4" || got["usage"] == nil {
t.Fatalf("official fields were lost: %+v", got)
}
for _, forbidden := range []string{"gateway_task_id", "gateway_status", "billings", "billing_summary", "final_charge_amount", "upstream_task_id"} {
if _, ok := got[forbidden]; ok {
t.Fatalf("compatibility response contains gateway extension %q: %+v", forbidden, got)
}
}
}
+129 -13
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"regexp"
"strconv"
@@ -39,6 +40,7 @@ type billingMetricsObserver interface {
type Result struct {
Task store.GatewayTask
Output map[string]any
Wire *clients.WireResponse
}
var ErrTaskQueued = errors.New("task queued")
@@ -56,6 +58,10 @@ func (e *upstreamSubmissionUnknownError) Error() string {
return "upstream submission result is unknown"
}
func (e *upstreamSubmissionUnknownError) ErrorCode() string {
return "upstream_submission_unknown"
}
func (e *upstreamSubmissionUnknownError) Unwrap() error {
return e.Cause
}
@@ -151,22 +157,23 @@ func (s *Service) executeWithToken(ctx context.Context, task store.GatewayTask,
}
}
if err := validateRequest(task.Kind, body); err != nil {
validationErr := parameterPreprocessClientError(err)
s.recordFailedAttempt(ctx, failedAttemptRecord{
Task: task,
Body: body,
AttemptNo: task.AttemptCount + 1,
Code: "bad_request",
Cause: err,
Cause: validationErr,
Simulated: task.RunMode == "simulation",
Scope: "request_validation",
Reason: "request_validation_failed",
ModelType: modelType,
})
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, "bad_request", err.Error(), task.RunMode == "simulation", err)
failed, finishErr := s.failTask(ctx, task.ID, task.ExecutionToken, "bad_request", validationErr.Error(), task.RunMode == "simulation", validationErr)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, err
return Result{Task: failed, Output: failed.Result}, validationErr
}
var clonedVoice clonedVoiceBinding
body, clonedVoice, err = s.resolveClonedVoiceBinding(ctx, user, task.Kind, body)
@@ -579,7 +586,7 @@ candidatesLoop:
}
walletReservationFinalized = true
s.logger.Warn("task succeeded but billing requires manual review", "taskID", task.ID, "error_category", "billing_calculation_failed")
return Result{Task: review, Output: response.Result}, nil
return Result{Task: review, Output: response.Result, Wire: response.Wire}, nil
}
finalAmountText = finalAmount.String()
} else {
@@ -637,7 +644,7 @@ candidatesLoop:
}, isSimulation(task, candidate)); err != nil {
return Result{}, err
}
return Result{Task: finished, Output: response.Result}, nil
return Result{Task: finished, Output: response.Result, Wire: response.Wire}, nil
}
var submissionUnknown *upstreamSubmissionUnknownError
if errors.As(err, &submissionUnknown) {
@@ -986,9 +993,27 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
publicResponseID = responseExecution.PublicResponseID
publicPreviousResponseID = responseExecution.PublicPreviousResponseID
}
if err := s.store.SetAttemptUpstreamSubmissionStatus(ctx, attemptID, "submitting"); err != nil {
return clients.Response{}, fmt.Errorf("mark upstream submission: %w", err)
submissionStatus := "not_submitted"
if strings.TrimSpace(task.RemoteTaskID) != "" {
submissionStatus = "response_received"
if err := s.store.SetAttemptUpstreamSubmissionStatus(ctx, attemptID, submissionStatus); err != nil {
return clients.Response{}, fmt.Errorf("restore upstream submission status: %w", err)
}
}
setSubmissionStatus := func(status string) error {
if submissionStatus == "response_received" && status != "response_received" {
return nil
}
if submissionStatus == status {
return nil
}
if err := s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, status); err != nil {
return err
}
submissionStatus = status
return nil
}
var submissionWire *clients.WireResponse
response, err := client.Run(ctx, clients.Request{
Kind: task.Kind,
ModelType: candidate.ModelType,
@@ -1002,13 +1027,36 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
if strings.TrimSpace(remoteTaskID) == "" {
return nil
}
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload)
if err := s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload); err != nil {
return err
}
task.RemoteTaskID = remoteTaskID
task.RemoteTaskPayload = payload
if err := s.persistCompatibilitySubmission(context.WithoutCancel(ctx), task, candidate, remoteTaskID, payload, submissionWire); err != nil {
return err
}
return setSubmissionStatus("response_received")
},
OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
if strings.TrimSpace(remoteTaskID) == "" {
return nil
}
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload)
if err := s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload); err != nil {
return err
}
task.RemoteTaskID = remoteTaskID
task.RemoteTaskPayload = payload
return setSubmissionStatus("response_received")
},
OnUpstreamSubmissionStarted: func() error {
return setSubmissionStatus("submitting")
},
OnUpstreamResponseReceived: func() error {
return setSubmissionStatus("response_received")
},
OnUpstreamWireResponse: func(wire *clients.WireResponse) error {
submissionWire = wire
return s.persistCompatibilitySubmission(context.WithoutCancel(ctx), task, candidate, "", wire.Body, wire)
},
Stream: boolFromMap(providerBody, "stream"),
StreamDelta: onDelta,
@@ -1020,7 +1068,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
})
callFinishedAt := time.Now()
if err == nil {
if markErr := s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, "response_received"); markErr != nil {
if markErr := setSubmissionStatus("response_received"); markErr != nil {
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: markErr}
}
}
@@ -1037,8 +1085,8 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
}
}
if err != nil {
if clients.ErrorResponseMetadata(err).StatusCode > 0 {
if markErr := s.store.SetAttemptUpstreamSubmissionStatus(context.WithoutCancel(ctx), attemptID, "response_received"); markErr != nil {
if clients.ErrorResponseMetadata(err).StatusCode > 0 && submissionStatus != "response_received" {
if markErr := setSubmissionStatus("response_received"); markErr != nil {
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: markErr}
}
}
@@ -1070,7 +1118,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
ErrorMessage: err.Error(),
})
_ = s.emit(ctx, task.ID, "task.attempt.failed", "running", "attempt_failed", 0.45, err.Error(), map[string]any{"attempt": attemptNo, "retryable": retryable, "requestId": requestID, "statusCode": clients.ErrorResponseMetadata(err).StatusCode, "metrics": metrics}, simulated)
if !simulated && clients.ErrorResponseMetadata(err).StatusCode == 0 {
if !simulated && submissionStatus == "submitting" {
return clients.Response{}, &upstreamSubmissionUnknownError{AttemptID: attemptID, Cause: err}
}
s.applyCandidateFailurePolicies(ctx, task.ID, candidate, err, simulated, singleSourceProtected)
@@ -1172,6 +1220,74 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
return response, nil
}
func (s *Service) persistCompatibilitySubmission(ctx context.Context, task store.GatewayTask, candidate store.RuntimeModelCandidate, remoteTaskID string, payload map[string]any, wire *clients.WireResponse) error {
targetProtocol := strings.TrimSpace(stringFromMap(task.Request, "_gateway_target_protocol"))
if targetProtocol == "" {
return nil
}
sourceProtocol := compatibilitySourceProtocol(task.Kind, candidate)
if wire != nil && strings.TrimSpace(wire.Protocol) != "" {
sourceProtocol = strings.TrimSpace(wire.Protocol)
}
publicID := task.ID
if sourceProtocol == targetProtocol && strings.TrimSpace(remoteTaskID) != "" {
publicID = strings.TrimSpace(remoteTaskID)
}
submitBody := payload
if nested, ok := payload["submit"].(map[string]any); ok && len(nested) > 0 {
submitBody = nested
}
httpStatus := http.StatusOK
headers := map[string]any{}
if wire != nil {
httpStatus = wire.StatusCode
if len(wire.Body) > 0 {
submitBody = wire.Body
}
for name, values := range wire.Headers {
headers[name] = append([]string(nil), values...)
}
}
return s.store.SetTaskCompatibilitySubmission(ctx, task.ID, store.CompatibilitySubmission{
TargetProtocol: targetProtocol,
PublicID: publicID,
SourceProtocol: sourceProtocol,
HTTPStatus: httpStatus,
Headers: headers,
Body: submitBody,
})
}
func compatibilitySourceProtocol(kind string, candidate store.RuntimeModelCandidate) string {
if protocol := strings.TrimSpace(candidate.ResponseProtocol); protocol != "" {
return protocol
}
switch strings.ToLower(strings.TrimSpace(candidate.Provider)) {
case "volces":
if kind == "videos.generations" {
return clients.ProtocolVolcesContents
}
case "keling", "kling":
if kind == "videos.generations" {
return clients.ProtocolKlingV1Omni
}
case "gemini":
return clients.ProtocolGeminiGenerateContent
case "openai":
switch kind {
case "chat.completions":
return clients.ProtocolOpenAIChatCompletions
case "responses":
return clients.ProtocolOpenAIResponses
case "embeddings":
return clients.ProtocolOpenAIEmbeddings
case "images.generations", "images.edits":
return clients.ProtocolOpenAIImages
}
}
return strings.ToLower(strings.TrimSpace(candidate.Provider))
}
func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID string, attemptID string, attemptNo int, candidate store.RuntimeModelCandidate, log parameterPreprocessingLog) error {
if skipTaskParameterPreprocessingLog(log.ModelType) && !log.Changed {
return nil
@@ -0,0 +1,58 @@
package store
import (
"context"
"encoding/json"
"strings"
)
type CompatibilitySubmission struct {
TargetProtocol string
PublicID string
SourceProtocol string
HTTPStatus int
Headers map[string]any
Body map[string]any
}
func (s *Store) SetTaskCompatibilitySubmission(ctx context.Context, taskID string, submission CompatibilitySubmission) error {
headers, _ := json.Marshal(emptyObjectIfNil(submission.Headers))
body, _ := json.Marshal(emptyObjectIfNil(submission.Body))
_, err := s.pool.Exec(ctx, `
UPDATE gateway_tasks
SET compatibility_protocol = NULLIF($2, ''),
compatibility_public_id = NULLIF($3, ''),
compatibility_source_protocol = NULLIF($4, ''),
compatibility_submit_http_status = NULLIF($5, 0),
compatibility_submit_headers = $6::jsonb,
compatibility_submit_body = $7::jsonb,
updated_at = now()
WHERE id = $1::uuid`,
taskID,
strings.TrimSpace(submission.TargetProtocol),
strings.TrimSpace(submission.PublicID),
strings.TrimSpace(submission.SourceProtocol),
submission.HTTPStatus,
string(headers),
string(body),
)
return err
}
func (s *Store) GetCompatibilityTask(ctx context.Context, protocol string, publicID string) (GatewayTask, error) {
task, err := scanGatewayTask(s.pool.QueryRow(ctx, `
SELECT `+gatewayTaskColumns+`
FROM gateway_tasks
WHERE compatibility_protocol = $1
AND compatibility_public_id = $2
LIMIT 1`, strings.TrimSpace(protocol), strings.TrimSpace(publicID)))
if err != nil {
return GatewayTask{}, err
}
attempts, err := s.ListTaskAttempts(ctx, task.ID)
if err != nil {
return GatewayTask{}, err
}
task.Attempts = attempts
return task, nil
}
+77 -58
View File
@@ -486,64 +486,70 @@ type CreateTaskResult struct {
}
type GatewayTask struct {
ID string `json:"id"`
ExternalTaskID string `json:"externalTaskId,omitempty"`
Kind string `json:"kind"`
RunMode string `json:"runMode"`
UserID string `json:"userId"`
GatewayUserID string `json:"gatewayUserId,omitempty"`
UserSource string `json:"userSource,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantID string `json:"tenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeyName string `json:"apiKeyName,omitempty"`
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
UserGroupID string `json:"userGroupId,omitempty"`
UserGroupKey string `json:"userGroupKey,omitempty"`
Model string `json:"model"`
ModelType string `json:"modelType,omitempty"`
RequestedModel string `json:"requestedModel,omitempty"`
ResolvedModel string `json:"resolvedModel,omitempty"`
RequestID string `json:"requestId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
NewMessageCount int `json:"newMessageCount,omitempty"`
Request map[string]any `json:"request,omitempty"`
AsyncMode bool `json:"asyncMode"`
RiverJobID int64 `json:"riverJobId,omitempty"`
Status string `json:"status"`
Cancellable *bool `json:"cancellable,omitempty"`
Submitted *bool `json:"submitted,omitempty"`
Message string `json:"message,omitempty"`
AttemptCount int `json:"attemptCount"`
RemoteTaskID string `json:"remoteTaskId,omitempty"`
RemoteTaskPayload map[string]any `json:"-"`
Result map[string]any `json:"result,omitempty"`
Billings []any `json:"billings,omitempty"`
Usage map[string]any `json:"usage"`
Metrics map[string]any `json:"metrics"`
BillingSummary map[string]any `json:"billingSummary"`
FinalChargeAmount float64 `json:"finalChargeAmount"`
BillingVersion string `json:"billingVersion"`
BillingStatus string `json:"billingStatus"`
BillingCurrency string `json:"billingCurrency"`
PricingSnapshot map[string]any `json:"pricingSnapshot,omitempty"`
RequestFingerprint string `json:"requestFingerprint,omitempty"`
ReservationAmount float64 `json:"reservationAmount"`
ExecutionToken string `json:"-"`
ExecutionLeaseUntil string `json:"executionLeaseExpiresAt,omitempty"`
BillingUpdatedAt string `json:"billingUpdatedAt,omitempty"`
BillingSettledAt string `json:"billingSettledAt,omitempty"`
ResponseStartedAt string `json:"responseStartedAt,omitempty"`
ResponseFinishedAt string `json:"responseFinishedAt,omitempty"`
ResponseDurationMS int64 `json:"responseDurationMs"`
FinishedAt string `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Attempts []TaskAttempt `json:"attempts,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
ExternalTaskID string `json:"externalTaskId,omitempty"`
Kind string `json:"kind"`
RunMode string `json:"runMode"`
UserID string `json:"userId"`
GatewayUserID string `json:"gatewayUserId,omitempty"`
UserSource string `json:"userSource,omitempty"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantID string `json:"tenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeyName string `json:"apiKeyName,omitempty"`
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
UserGroupID string `json:"userGroupId,omitempty"`
UserGroupKey string `json:"userGroupKey,omitempty"`
Model string `json:"model"`
ModelType string `json:"modelType,omitempty"`
RequestedModel string `json:"requestedModel,omitempty"`
ResolvedModel string `json:"resolvedModel,omitempty"`
RequestID string `json:"requestId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
NewMessageCount int `json:"newMessageCount,omitempty"`
Request map[string]any `json:"request,omitempty"`
AsyncMode bool `json:"asyncMode"`
RiverJobID int64 `json:"riverJobId,omitempty"`
Status string `json:"status"`
Cancellable *bool `json:"cancellable,omitempty"`
Submitted *bool `json:"submitted,omitempty"`
Message string `json:"message,omitempty"`
AttemptCount int `json:"attemptCount"`
RemoteTaskID string `json:"remoteTaskId,omitempty"`
RemoteTaskPayload map[string]any `json:"-"`
Result map[string]any `json:"result,omitempty"`
Billings []any `json:"billings,omitempty"`
Usage map[string]any `json:"usage"`
Metrics map[string]any `json:"metrics"`
BillingSummary map[string]any `json:"billingSummary"`
FinalChargeAmount float64 `json:"finalChargeAmount"`
BillingVersion string `json:"billingVersion"`
BillingStatus string `json:"billingStatus"`
BillingCurrency string `json:"billingCurrency"`
PricingSnapshot map[string]any `json:"pricingSnapshot,omitempty"`
RequestFingerprint string `json:"requestFingerprint,omitempty"`
ReservationAmount float64 `json:"reservationAmount"`
ExecutionToken string `json:"-"`
ExecutionLeaseUntil string `json:"executionLeaseExpiresAt,omitempty"`
BillingUpdatedAt string `json:"billingUpdatedAt,omitempty"`
BillingSettledAt string `json:"billingSettledAt,omitempty"`
ResponseStartedAt string `json:"responseStartedAt,omitempty"`
ResponseFinishedAt string `json:"responseFinishedAt,omitempty"`
ResponseDurationMS int64 `json:"responseDurationMs"`
FinishedAt string `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
CompatibilityProtocol string `json:"compatibilityProtocol,omitempty"`
CompatibilityPublicID string `json:"compatibilityPublicId,omitempty"`
CompatibilitySourceProtocol string `json:"compatibilitySourceProtocol,omitempty"`
CompatibilitySubmitHTTPStatus int `json:"compatibilitySubmitHttpStatus,omitempty"`
CompatibilitySubmitHeaders map[string]any `json:"compatibilitySubmitHeaders,omitempty"`
CompatibilitySubmitBody map[string]any `json:"compatibilitySubmitBody,omitempty"`
Attempts []TaskAttempt `json:"attempts,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
const gatewayTaskColumns = `
@@ -564,6 +570,9 @@ COALESCE(execution_lease_expires_at::text, ''), COALESCE(billing_updated_at::tex
COALESCE(billing_settled_at::text, ''), COALESCE(response_started_at::text, ''),
COALESCE(response_finished_at::text, ''), COALESCE(response_duration_ms, 0), COALESCE(error, ''),
COALESCE(error_code, ''), COALESCE(error_message, ''),
COALESCE(compatibility_protocol, ''), COALESCE(compatibility_public_id, ''),
COALESCE(compatibility_source_protocol, ''), COALESCE(compatibility_submit_http_status, 0),
COALESCE(compatibility_submit_headers, '{}'::jsonb), COALESCE(compatibility_submit_body, '{}'::jsonb),
created_at, updated_at, COALESCE(finished_at::text, '')`
type TaskEvent struct {
@@ -2055,6 +2064,8 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
var billingSummaryBytes []byte
var pricingSnapshotBytes []byte
var remoteTaskPayloadBytes []byte
var compatibilitySubmitHeadersBytes []byte
var compatibilitySubmitBodyBytes []byte
if err := scanner.Scan(
&task.ID,
&task.ExternalTaskID,
@@ -2107,6 +2118,12 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
&task.Error,
&task.ErrorCode,
&task.ErrorMessage,
&task.CompatibilityProtocol,
&task.CompatibilityPublicID,
&task.CompatibilitySourceProtocol,
&task.CompatibilitySubmitHTTPStatus,
&compatibilitySubmitHeadersBytes,
&compatibilitySubmitBodyBytes,
&task.CreatedAt,
&task.UpdatedAt,
&task.FinishedAt,
@@ -2121,6 +2138,8 @@ func scanGatewayTask(scanner taskScanner) (GatewayTask, error) {
task.Metrics = decodeObject(metricsBytes)
task.BillingSummary = decodeObject(billingSummaryBytes)
task.PricingSnapshot = decodeObject(pricingSnapshotBytes)
task.CompatibilitySubmitHeaders = decodeObject(compatibilitySubmitHeadersBytes)
task.CompatibilitySubmitBody = decodeObject(compatibilitySubmitBodyBytes)
return task, nil
}
+1 -1
View File
@@ -177,7 +177,7 @@ WHERE (
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
AND request->>'_compat_provider' = $4
AND request->>'_kling_compat_version' = $5
AND (id::text = $6 OR external_task_id = $6)
AND (id::text = $6 OR external_task_id = $6 OR remote_task_id = $6 OR compatibility_public_id = $6)
ORDER BY created_at DESC
LIMIT 1`, gatewayUserID, userID, apiKeyID, strings.TrimSpace(provider), strings.TrimSpace(version), strings.TrimSpace(identifier)))
if err != nil {
@@ -81,7 +81,11 @@ WHERE (
AND request->>'_gateway_compatibility' = $4
AND (NULLIF($5, '') IS NULL OR LOWER(status) = $5)
AND (NULLIF($6, '') IS NULL OR model = $6 OR resolved_model = $6)
AND (COALESCE(array_length($7::text[], 1), 0) = 0 OR id::text = ANY($7::text[]))`
AND (
COALESCE(array_length($7::text[], 1), 0) = 0
OR id::text = ANY($7::text[])
OR remote_task_id = ANY($7::text[])
)`
var total int
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks `+whereSQL, args...).Scan(&total); err != nil {
return TaskListResult{}, err
@@ -107,5 +111,45 @@ LIMIT $8 OFFSET $9`, append(args, pageSize, (page-1)*pageSize)...)
if err := rows.Err(); err != nil {
return TaskListResult{}, err
}
items, err = s.attachTaskAttempts(ctx, items)
if err != nil {
return TaskListResult{}, err
}
return TaskListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// GetVolcesCompatibleTask resolves both the gateway UUID used for converted
// tasks and the upstream Ark task ID exposed by native passthrough tasks.
func (s *Store) GetVolcesCompatibleTask(ctx context.Context, user *auth.User, compatibilityMarker string, identifier string) (GatewayTask, error) {
gatewayUserID := localGatewayUserID(user)
userID, apiKeyID := "", ""
if user != nil {
userID = strings.TrimSpace(user.ID)
apiKeyID = strings.TrimSpace(user.APIKeyID)
}
if gatewayUserID == "" && userID == "" {
return GatewayTask{}, ErrLocalUserRequired
}
task, err := scanGatewayTask(s.pool.QueryRow(ctx, `
SELECT `+gatewayTaskColumns+`
FROM gateway_tasks
WHERE (
(NULLIF($1, '')::uuid IS NOT NULL AND gateway_user_id = NULLIF($1, '')::uuid)
OR (NULLIF($1, '')::uuid IS NULL AND NULLIF($2, '') IS NOT NULL AND user_id = $2)
)
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
AND kind = 'videos.generations'
AND request->>'_gateway_compatibility' = $4
AND (id::text = $5 OR remote_task_id = $5)
ORDER BY created_at DESC
LIMIT 1`, gatewayUserID, userID, apiKeyID, strings.TrimSpace(compatibilityMarker), strings.TrimSpace(identifier)))
if err != nil {
return GatewayTask{}, err
}
attempts, err := s.ListTaskAttempts(ctx, task.ID)
if err != nil {
return GatewayTask{}, err
}
task.Attempts = attempts
return task, nil
}
@@ -0,0 +1,18 @@
ALTER TABLE gateway_tasks
ADD COLUMN IF NOT EXISTS compatibility_protocol text,
ADD COLUMN IF NOT EXISTS compatibility_public_id text,
ADD COLUMN IF NOT EXISTS compatibility_source_protocol text,
ADD COLUMN IF NOT EXISTS compatibility_submit_http_status integer,
ADD COLUMN IF NOT EXISTS compatibility_submit_headers jsonb,
ADD COLUMN IF NOT EXISTS compatibility_submit_body jsonb;
CREATE UNIQUE INDEX IF NOT EXISTS gateway_tasks_compatibility_public_id_unique
ON gateway_tasks (compatibility_protocol, compatibility_public_id)
WHERE compatibility_protocol IS NOT NULL
AND compatibility_protocol <> ''
AND compatibility_public_id IS NOT NULL
AND compatibility_public_id <> '';
CREATE INDEX IF NOT EXISTS gateway_tasks_remote_task_compatibility_lookup
ON gateway_tasks (remote_task_id)
WHERE remote_task_id IS NOT NULL AND remote_task_id <> '';