feat(api): 统一官方兼容接口响应协议
兼容接口现在以入口协议作为最终响应协议,同协议保留官方 Wire 响应,跨协议统一转换成功、任务状态与错误结构。 同时修正异步提交状态边界,持久化兼容公开任务标识和官方提交响应,并新增迁移、流式响应及协议契约测试。 验证:go vet ./...;go test ./...;govulncheck ./...;pnpm lint;pnpm test;pnpm build;pnpm audit --audit-level high;pnpm openapi;全部 CI 脚本。
This commit is contained in:
+327
-163
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "", ""),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS compatibility_submit_body jsonb NOT NULL DEFAULT '{}'::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 <> '';
|
||||
Reference in New Issue
Block a user