diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 500e41f..4f9a1ad 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -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" }, diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index a57f9b6..2e8ccac 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -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 diff --git a/apps/api/internal/clients/gemini.go b/apps/api/internal/clients/gemini.go index 31a964a..c1dd487 100644 --- a/apps/api/internal/clients/gemini.go +++ b/apps/api/internal/clients/gemini.go @@ -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 { diff --git a/apps/api/internal/clients/helpers.go b/apps/api/internal/clients/helpers.go index 98e35bb..00e2255 100644 --- a/apps/api/internal/clients/helpers.go +++ b/apps/api/internal/clients/helpers.go @@ -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) { diff --git a/apps/api/internal/clients/keling.go b/apps/api/internal/clients/keling.go index d5c82f5..69ec78d 100644 --- a/apps/api/internal/clients/keling.go +++ b/apps/api/internal/clients/keling.go @@ -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 } diff --git a/apps/api/internal/clients/openai.go b/apps/api/internal/clients/openai.go index 8ee27f5..5e4404e 100644 --- a/apps/api/internal/clients/openai.go +++ b/apps/api/internal/clients/openai.go @@ -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) diff --git a/apps/api/internal/clients/provider_task.go b/apps/api/internal/clients/provider_task.go index 68602d9..2fb8c71 100644 --- a/apps/api/internal/clients/provider_task.go +++ b/apps/api/internal/clients/provider_task.go @@ -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()) } diff --git a/apps/api/internal/clients/responses_compat.go b/apps/api/internal/clients/responses_compat.go index c361890..63c0783 100644 --- a/apps/api/internal/clients/responses_compat.go +++ b/apps/api/internal/clients/responses_compat.go @@ -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{}{ diff --git a/apps/api/internal/clients/submission_state_test.go b/apps/api/internal/clients/submission_state_test.go new file mode 100644 index 0000000..4dfc06b --- /dev/null +++ b/apps/api/internal/clients/submission_state_test.go @@ -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) + } +} diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 9b2d924..599deef 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -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 diff --git a/apps/api/internal/clients/universal.go b/apps/api/internal/clients/universal.go index 4d1b4da..bf528bf 100644 --- a/apps/api/internal/clients/universal.go +++ b/apps/api/internal/clients/universal.go @@ -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()) } diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index 297105a..fc727ac 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -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) } diff --git a/apps/api/internal/clients/wire_response_test.go b/apps/api/internal/clients/wire_response_test.go new file mode 100644 index 0000000..685fa37 --- /dev/null +++ b/apps/api/internal/clients/wire_response_test.go @@ -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) + } +} diff --git a/apps/api/internal/httpapi/compat_protocol.go b/apps/api/internal/httpapi/compat_protocol.go new file mode 100644 index 0000000..603afbc --- /dev/null +++ b/apps/api/internal/httpapi/compat_protocol.go @@ -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, + }}) +} diff --git a/apps/api/internal/httpapi/compat_protocol_test.go b/apps/api/internal/httpapi/compat_protocol_test.go new file mode 100644 index 0000000..6d45dbb --- /dev/null +++ b/apps/api/internal/httpapi/compat_protocol_test.go @@ -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) + } + } +} diff --git a/apps/api/internal/httpapi/gemini_compat.go b/apps/api/internal/httpapi/gemini_compat.go index bd0e8f7..4fa8c59 100644 --- a/apps/api/internal/httpapi/gemini_compat.go +++ b/apps/api/internal/httpapi/gemini_compat.go @@ -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 diff --git a/apps/api/internal/httpapi/gemini_compat_test.go b/apps/api/internal/httpapi/gemini_compat_test.go index ebd1a66..9e62386 100644 --- a/apps/api/internal/httpapi/gemini_compat_test.go +++ b/apps/api/internal/httpapi/gemini_compat_test.go @@ -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", "", ""), diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 5206a2c..0079946 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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 diff --git a/apps/api/internal/httpapi/keling_compat_handlers.go b/apps/api/internal/httpapi/keling_compat_handlers.go index 1ab322c..cba14fd 100644 --- a/apps/api/internal/httpapi/keling_compat_handlers.go +++ b/apps/api/internal/httpapi/keling_compat_handlers.go @@ -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": diff --git a/apps/api/internal/httpapi/keling_compat_handlers_test.go b/apps/api/internal/httpapi/keling_compat_handlers_test.go index 5446e21..6d5b41c 100644 --- a/apps/api/internal/httpapi/keling_compat_handlers_test.go +++ b/apps/api/internal/httpapi/keling_compat_handlers_test.go @@ -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) diff --git a/apps/api/internal/httpapi/kling_compat.go b/apps/api/internal/httpapi/kling_compat.go index e5e9ca7..39e0a5a 100644 --- a/apps/api/internal/httpapi/kling_compat.go +++ b/apps/api/internal/httpapi/kling_compat.go @@ -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)) diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index ba7d72d..24701de 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -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"` diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index edc7df4..13a0734 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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())) diff --git a/apps/api/internal/httpapi/streaming.go b/apps/api/internal/httpapi/streaming.go index 385016a..d4b47b0 100644 --- a/apps/api/internal/httpapi/streaming.go +++ b/apps/api/internal/httpapi/streaming.go @@ -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 } diff --git a/apps/api/internal/httpapi/volces_compat_handlers.go b/apps/api/internal/httpapi/volces_compat_handlers.go index 1323c0d..96be443 100644 --- a/apps/api/internal/httpapi/volces_compat_handlers.go +++ b/apps/api/internal/httpapi/volces_compat_handlers.go @@ -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 { diff --git a/apps/api/internal/httpapi/volces_compat_handlers_test.go b/apps/api/internal/httpapi/volces_compat_handlers_test.go index 2069843..12e7725 100644 --- a/apps/api/internal/httpapi/volces_compat_handlers_test.go +++ b/apps/api/internal/httpapi/volces_compat_handlers_test.go @@ -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) + } } } diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index a2c1959..1a6d975 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -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 diff --git a/apps/api/internal/store/compatibility_tasks.go b/apps/api/internal/store/compatibility_tasks.go new file mode 100644 index 0000000..a59d476 --- /dev/null +++ b/apps/api/internal/store/compatibility_tasks.go @@ -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 +} diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 404601b..a587f7d 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -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 } diff --git a/apps/api/internal/store/tasks_runtime.go b/apps/api/internal/store/tasks_runtime.go index b034161..a23f811 100644 --- a/apps/api/internal/store/tasks_runtime.go +++ b/apps/api/internal/store/tasks_runtime.go @@ -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 { diff --git a/apps/api/internal/store/volces_compatible_tasks.go b/apps/api/internal/store/volces_compatible_tasks.go index 5293ae3..39a0838 100644 --- a/apps/api/internal/store/volces_compatible_tasks.go +++ b/apps/api/internal/store/volces_compatible_tasks.go @@ -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 +} diff --git a/apps/api/migrations/0075_compatibility_protocol_metadata.sql b/apps/api/migrations/0075_compatibility_protocol_metadata.sql new file mode 100644 index 0000000..446a72c --- /dev/null +++ b/apps/api/migrations/0075_compatibility_protocol_metadata.sql @@ -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 <> '';