diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index f999c21..f1101a9 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -4081,6 +4081,99 @@ } } }, + "/api/v1/embeddings": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tasks" + ], + "summary": "创建或执行 AI 任务", + "parameters": [ + { + "type": "boolean", + "description": "true 时异步创建任务并返回 202", + "name": "X-Async", + "in": "header" + }, + { + "description": "AI 任务请求,字段随任务类型变化", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.TaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.CompatibleResponse" + } + }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "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" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/v1/files/upload": { "post": { "security": [ @@ -4641,6 +4734,99 @@ } } }, + "/api/v1/reranks": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tasks" + ], + "summary": "创建或执行 AI 任务", + "parameters": [ + { + "type": "boolean", + "description": "true 时异步创建任务并返回 202", + "name": "X-Async", + "in": "header" + }, + { + "description": "AI 任务请求,字段随任务类型变化", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.TaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.CompatibleResponse" + } + }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "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" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/v1/responses": { "post": { "security": [ @@ -5533,6 +5719,99 @@ } } }, + "/embeddings": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tasks" + ], + "summary": "创建或执行 AI 任务", + "parameters": [ + { + "type": "boolean", + "description": "true 时异步创建任务并返回 202", + "name": "X-Async", + "in": "header" + }, + { + "description": "AI 任务请求,字段随任务类型变化", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.TaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.CompatibleResponse" + } + }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "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" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/healthz": { "get": { "description": "返回服务进程、运行环境和身份模式,供负载均衡或人工排障使用。", @@ -5765,6 +6044,99 @@ } } }, + "/reranks": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tasks" + ], + "summary": "创建或执行 AI 任务", + "parameters": [ + { + "type": "boolean", + "description": "true 时异步创建任务并返回 202", + "name": "X-Async", + "in": "header" + }, + { + "description": "AI 任务请求,字段随任务类型变化", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.TaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.CompatibleResponse" + } + }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "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" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/responses": { "post": { "security": [ @@ -6057,6 +6429,99 @@ } } }, + "/v1/embeddings": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tasks" + ], + "summary": "创建或执行 AI 任务", + "parameters": [ + { + "type": "boolean", + "description": "true 时异步创建任务并返回 202", + "name": "X-Async", + "in": "header" + }, + { + "description": "AI 任务请求,字段随任务类型变化", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.TaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.CompatibleResponse" + } + }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "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" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/v1/files/upload": { "post": { "security": [ @@ -6311,6 +6776,99 @@ } } }, + "/v1/reranks": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tasks" + ], + "summary": "创建或执行 AI 任务", + "parameters": [ + { + "type": "boolean", + "description": "true 时异步创建任务并返回 202", + "name": "X-Async", + "in": "header" + }, + { + "description": "AI 任务请求,字段随任务类型变化", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.TaskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.CompatibleResponse" + } + }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "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" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/v1/responses": { "post": { "security": [ diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index a583b79..166862e 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -4912,6 +4912,67 @@ paths: summary: 创建 Chat Completions tags: - tasks + /api/v1/embeddings: + post: + consumes: + - application/json + description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible + 路径同步返回兼容响应或 SSE 流。 + parameters: + - description: true 时异步创建任务并返回 202 + in: header + name: X-Async + type: boolean + - description: AI 任务请求,字段随任务类型变化 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.TaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.CompatibleResponse' + "202": + description: Accepted + schema: + $ref: '#/definitions/httpapi.TaskAcceptedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "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' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 创建或执行 AI 任务 + tags: + - tasks /api/v1/files/upload: post: consumes: @@ -5271,6 +5332,67 @@ paths: summary: 列出目录供应商 tags: - catalog + /api/v1/reranks: + post: + consumes: + - application/json + description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible + 路径同步返回兼容响应或 SSE 流。 + parameters: + - description: true 时异步创建任务并返回 202 + in: header + name: X-Async + type: boolean + - description: AI 任务请求,字段随任务类型变化 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.TaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.CompatibleResponse' + "202": + description: Accepted + schema: + $ref: '#/definitions/httpapi.TaskAcceptedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "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' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 创建或执行 AI 任务 + tags: + - tasks /api/v1/responses: post: consumes: @@ -5847,6 +5969,67 @@ paths: summary: 创建或执行 AI 任务 tags: - tasks + /embeddings: + post: + consumes: + - application/json + description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible + 路径同步返回兼容响应或 SSE 流。 + parameters: + - description: true 时异步创建任务并返回 202 + in: header + name: X-Async + type: boolean + - description: AI 任务请求,字段随任务类型变化 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.TaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.CompatibleResponse' + "202": + description: Accepted + schema: + $ref: '#/definitions/httpapi.TaskAcceptedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "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' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 创建或执行 AI 任务 + tags: + - tasks /healthz: get: description: 返回服务进程、运行环境和身份模式,供负载均衡或人工排障使用。 @@ -5999,6 +6182,67 @@ paths: summary: 就绪检查 tags: - system + /reranks: + post: + consumes: + - application/json + description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible + 路径同步返回兼容响应或 SSE 流。 + parameters: + - description: true 时异步创建任务并返回 202 + in: header + name: X-Async + type: boolean + - description: AI 任务请求,字段随任务类型变化 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.TaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.CompatibleResponse' + "202": + description: Accepted + schema: + $ref: '#/definitions/httpapi.TaskAcceptedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "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' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 创建或执行 AI 任务 + tags: + - tasks /responses: post: consumes: @@ -6191,6 +6435,67 @@ paths: summary: 创建或执行 AI 任务 tags: - tasks + /v1/embeddings: + post: + consumes: + - application/json + description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible + 路径同步返回兼容响应或 SSE 流。 + parameters: + - description: true 时异步创建任务并返回 202 + in: header + name: X-Async + type: boolean + - description: AI 任务请求,字段随任务类型变化 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.TaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.CompatibleResponse' + "202": + description: Accepted + schema: + $ref: '#/definitions/httpapi.TaskAcceptedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "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' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 创建或执行 AI 任务 + tags: + - tasks /v1/files/upload: post: consumes: @@ -6357,6 +6662,67 @@ paths: summary: 创建或执行 AI 任务 tags: - tasks + /v1/reranks: + post: + consumes: + - application/json + description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible + 路径同步返回兼容响应或 SSE 流。 + parameters: + - description: true 时异步创建任务并返回 202 + in: header + name: X-Async + type: boolean + - description: AI 任务请求,字段随任务类型变化 + in: body + name: input + required: true + schema: + $ref: '#/definitions/httpapi.TaskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.CompatibleResponse' + "202": + description: Accepted + schema: + $ref: '#/definitions/httpapi.TaskAcceptedResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "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' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 创建或执行 AI 任务 + tags: + - tasks /v1/responses: post: consumes: diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index c521227..dac7435 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -151,6 +151,107 @@ func TestOpenAIClientChatContract(t *testing.T) { } } +func TestOpenAIClientEmbeddingsContract(t *testing.T) { + var gotPath string + var gotModel string + var gotDimensions float64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + gotModel, _ = body["model"].(string) + gotDimensions, _ = body["dimensions"].(float64) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "embd-test", + "object": "list", + "model": gotModel, + "data": []any{map[string]any{ + "object": "embedding", + "index": 0, + "embedding": []any{0.1, 0.2, 0.3}, + }}, + "usage": map[string]any{"prompt_tokens": 3, "total_tokens": 3}, + }) + })) + defer server.Close() + + response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "embeddings", + Model: "aliyun-bailian-openai:text-embedding-v4", + Body: map[string]any{ + "model": "aliyun-bailian-openai:text-embedding-v4", + "input": []any{"hello"}, + "dimensions": 3, + }, + Candidate: store.RuntimeModelCandidate{ + BaseURL: server.URL, + ProviderModelName: "text-embedding-v4", + Credentials: map[string]any{"apiKey": "test-key"}, + }, + }) + if err != nil { + t.Fatalf("run embeddings client: %v", err) + } + if gotPath != "/embeddings" || gotModel != "text-embedding-v4" || gotDimensions != 3 { + t.Fatalf("unexpected embeddings request path=%s model=%s dimensions=%v", gotPath, gotModel, gotDimensions) + } + if response.Usage.InputTokens != 3 || response.Usage.TotalTokens != 3 || response.Result["id"] != "embd-test" { + t.Fatalf("unexpected embeddings response: %+v", response) + } +} + +func TestOpenAIClientAliyunRerankUsesCompatibleAPIBase(t *testing.T) { + var gotPath string + var gotModel string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + gotModel, _ = body["model"].(string) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "rerank-test", + "object": "list", + "model": gotModel, + "results": []any{ + map[string]any{"index": 0, "relevance_score": 0.93}, + map[string]any{"index": 2, "relevance_score": 0.34}, + }, + "usage": map[string]any{"total_tokens": 9}, + }) + })) + defer server.Close() + + response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "reranks", + Model: "aliyun-bailian-openai:qwen3-rerank", + Body: map[string]any{ + "model": "aliyun-bailian-openai:qwen3-rerank", + "query": "what is rerank", + "documents": []any{"rerank sorts documents", "unrelated"}, + "top_n": 2, + }, + Candidate: store.RuntimeModelCandidate{ + Provider: "aliyun-bailian-openai", + BaseURL: server.URL + "/compatible-mode/v1", + ProviderModelName: "qwen3-rerank", + Credentials: map[string]any{"apiKey": "test-key"}, + }, + }) + if err != nil { + t.Fatalf("run rerank client: %v", err) + } + if gotPath != "/compatible-api/v1/reranks" || gotModel != "qwen3-rerank" { + t.Fatalf("unexpected rerank request path=%s model=%s", gotPath, gotModel) + } + if response.Usage.TotalTokens != 9 || response.Result["id"] != "rerank-test" { + t.Fatalf("unexpected rerank response: %+v", response) + } +} + func TestOpenAIClientChatRequestNormalizesToolContext(t *testing.T) { var captured map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/api/internal/clients/openai.go b/apps/api/internal/clients/openai.go index 12ef168..95eada7 100644 --- a/apps/api/internal/clients/openai.go +++ b/apps/api/internal/clients/openai.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) type OpenAIClient struct { @@ -27,10 +29,10 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error body = NormalizeChatCompletionRequestBody(body) } body["model"] = upstreamModelName(request.Candidate) - stream := request.Stream || boolValue(body, "stream") + stream := openAIEndpointSupportsStream(request.Kind) && (request.Stream || boolValue(body, "stream")) ensureOpenAIStreamUsage(body, request.Kind, stream) raw, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, endpoint), bytes.NewReader(raw)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(openAIBaseURL(request.Kind, request.Candidate), endpoint), bytes.NewReader(raw)) if err != nil { return Response{}, err } @@ -81,6 +83,10 @@ func openAIEndpoint(kind string) string { return "/chat/completions" case "responses": return "/responses" + case "embeddings": + return "/embeddings" + case "reranks": + return "/reranks" case "images.generations": return "/images/generations" case "images.edits": @@ -90,6 +96,24 @@ func openAIEndpoint(kind string) string { } } +func openAIEndpointSupportsStream(kind string) bool { + return kind == "chat.completions" || kind == "responses" +} + +func openAIBaseURL(kind string, candidate store.RuntimeModelCandidate) string { + base := strings.TrimSpace(candidate.BaseURL) + if kind != "reranks" { + return base + } + if strings.Contains(base, "/compatible-mode/") && (strings.EqualFold(candidate.Provider, "aliyun-bailian-openai") || strings.Contains(base, "dashscope")) { + return strings.Replace(base, "/compatible-mode/", "/compatible-api/", 1) + } + if base == "" && strings.EqualFold(candidate.Provider, "aliyun-bailian-openai") { + return "https://dashscope.aliyuncs.com/compatible-api/v1" + } + return base +} + func cloneBody(body map[string]any) map[string]any { out := map[string]any{} for key, value := range body { diff --git a/apps/api/internal/clients/simulation.go b/apps/api/internal/clients/simulation.go index 2cbff49..ded3675 100644 --- a/apps/api/internal/clients/simulation.go +++ b/apps/api/internal/clients/simulation.go @@ -131,6 +131,10 @@ func simulatedResult(request Request) map[string]any { "output_text": fmt.Sprintf("simulation response from %s", request.Candidate.Provider), "usage": map[string]any{"input_tokens": 12, "output_tokens": 8, "total_tokens": 20}, } + case "embeddings": + return simulatedEmbeddingResult(request) + case "reranks": + return simulatedRerankResult(request) case "images.edits": return map[string]any{ "id": "img-edit-simulated", @@ -172,6 +176,106 @@ func simulatedResult(request Request) map[string]any { } } +func simulatedEmbeddingResult(request Request) map[string]any { + inputCount := simulatedEmbeddingInputCount(request.Body["input"]) + dimensions := intValue(request.Body, "dimensions", 3) + if dimensions <= 0 { + dimensions = 3 + } + if dimensions > 2048 { + dimensions = 2048 + } + data := make([]any, 0, inputCount) + for index := 0; index < inputCount; index += 1 { + embedding := make([]any, 0, dimensions) + for dimension := 0; dimension < dimensions; dimension += 1 { + embedding = append(embedding, float64(index+1)/10+float64(dimension)/100) + } + data = append(data, map[string]any{ + "object": "embedding", + "index": index, + "embedding": embedding, + }) + } + usage := simulatedUsage(request) + return map[string]any{ + "id": "embd-simulated", + "object": "list", + "model": request.Model, + "data": data, + "usage": map[string]any{"prompt_tokens": usage.InputTokens, "total_tokens": usage.TotalTokens}, + } +} + +func simulatedEmbeddingInputCount(value any) int { + switch typed := value.(type) { + case []any: + if len(typed) > 0 { + return len(typed) + } + case []string: + if len(typed) > 0 { + return len(typed) + } + } + return 1 +} + +func simulatedRerankResult(request Request) map[string]any { + documents := simulatedRerankDocuments(request.Body["documents"]) + topN := intValue(request.Body, "top_n", len(documents)) + if topN <= 0 || topN > len(documents) { + topN = len(documents) + } + results := make([]any, 0, topN) + for index := 0; index < topN; index += 1 { + score := 0.95 - float64(index)*0.1 + if score < 0 { + score = 0 + } + result := map[string]any{ + "index": index, + "relevance_score": score, + } + if boolValue(request.Body, "return_documents") { + result["document"] = map[string]any{"text": documents[index]} + } + results = append(results, result) + } + usage := simulatedUsage(request) + return map[string]any{ + "id": "rerank-simulated", + "object": "list", + "model": request.Model, + "results": results, + "usage": map[string]any{"total_tokens": usage.TotalTokens}, + } +} + +func simulatedRerankDocuments(value any) []string { + switch typed := value.(type) { + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + text := stringValue(map[string]any{"value": item}, "value") + if text == "" { + if record, ok := item.(map[string]any); ok { + text = firstNonEmptyString(stringValue(record, "text"), stringValue(record, "content")) + } + } + out = append(out, text) + } + if len(out) > 0 { + return out + } + case []string: + if len(typed) > 0 { + return typed + } + } + return []string{"simulated document"} +} + func simulatedImageData(request Request, url string, fallbackPrompt string) []any { count := simulatedOutputCount(request.Body) items := make([]any, 0, count) @@ -207,6 +311,9 @@ func simulatedUsage(request Request) Usage { if request.ModelType == "chat" || request.ModelType == "text_generate" || request.Kind == "responses" { return Usage{InputTokens: 12, OutputTokens: 8, TotalTokens: 20} } + if request.ModelType == "text_embedding" || request.ModelType == "text_rerank" || request.Kind == "embeddings" || request.Kind == "reranks" { + return Usage{InputTokens: 16, TotalTokens: 16} + } return Usage{} } diff --git a/apps/api/internal/httpapi/chat_completions_mode_test.go b/apps/api/internal/httpapi/chat_completions_mode_test.go index d16b9f4..b9a683b 100644 --- a/apps/api/internal/httpapi/chat_completions_mode_test.go +++ b/apps/api/internal/httpapi/chat_completions_mode_test.go @@ -31,6 +31,33 @@ func TestPlanTaskResponseTreatsAPIV1ChatCompletionsAsSynchronousCompatibleRespon } } +func TestPlanTaskResponseTreatsAPIV1EmbeddingAndRerankAsSynchronousCompatibleResponse(t *testing.T) { + for _, item := range []struct { + kind string + path string + }{ + {kind: "embeddings", path: "/api/v1/embeddings"}, + {kind: "reranks", path: "/api/v1/reranks"}, + } { + t.Run(item.kind, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, item.path, nil) + req.Header.Set("X-Async", "true") + + plan := planTaskResponse(item.kind, false, map[string]any{"stream": true}, req) + + if plan.asyncMode { + t.Fatalf("%s must not enter async task mode", item.path) + } + if !plan.compatibleMode { + t.Fatalf("%s should return compatible response payloads", item.path) + } + if plan.streamMode { + t.Fatal("embedding and rerank endpoints should stay JSON-only even when stream=true is present") + } + }) + } +} + func TestPlanTaskResponseKeepsAsyncTaskModeForOtherAPIV1Tasks(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil) req.Header.Set("X-Async", "true") diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 8f97b94..dbc244a 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -876,6 +876,8 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque // @Failure 429 {object} ErrorEnvelope // @Failure 502 {object} ErrorEnvelope // @Router /api/v1/responses [post] +// @Router /api/v1/embeddings [post] +// @Router /api/v1/reranks [post] // @Router /api/v1/images/generations [post] // @Router /api/v1/images/edits [post] // @Router /api/v1/videos/generations [post] @@ -883,6 +885,10 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque // @Router /v1/chat/completions [post] // @Router /responses [post] // @Router /v1/responses [post] +// @Router /embeddings [post] +// @Router /v1/embeddings [post] +// @Router /reranks [post] +// @Router /v1/reranks [post] // @Router /images/generations [post] // @Router /v1/images/generations [post] // @Router /images/edits [post] @@ -1085,17 +1091,25 @@ type taskResponsePlan struct { func planTaskResponse(kind string, compatible bool, body map[string]any, r *http.Request) taskResponsePlan { asyncMode := asyncRequest(r) compatibleMode := compatible - if kind == "chat.completions" && !compatible { + if synchronousCompatibleKind(kind) && !compatible { asyncMode = false compatibleMode = true } return taskResponsePlan{ asyncMode: asyncMode, compatibleMode: compatibleMode, - streamMode: boolValue(body, "stream"), + streamMode: streamCompatibleKind(kind) && boolValue(body, "stream"), } } +func synchronousCompatibleKind(kind string) bool { + return kind == "chat.completions" || kind == "embeddings" || kind == "reranks" +} + +func streamCompatibleKind(kind string) bool { + return kind == "chat.completions" || kind == "responses" +} + func writeTaskAccepted(w http.ResponseWriter, task store.GatewayTask) { writeJSON(w, http.StatusAccepted, map[string]any{ "taskId": task.ID, @@ -1120,6 +1134,12 @@ func apiKeyScopeAllowed(user *auth.User, kind string) bool { if required == "chat" && (scope == "text" || scope == "text_generate") { return true } + if required == "embedding" && scope == "text_embedding" { + return true + } + if required == "rerank" && scope == "text_rerank" { + return true + } } return false } @@ -1128,6 +1148,10 @@ func scopeForTaskKind(kind string) string { switch kind { case "chat.completions", "responses": return "chat" + case "embeddings": + return "embedding" + case "reranks": + return "rerank" case "images.generations", "images.edits": return "image" case "videos.generations": diff --git a/apps/api/internal/httpapi/model_catalog.go b/apps/api/internal/httpapi/model_catalog.go index 755362f..cc2fa63 100644 --- a/apps/api/internal/httpapi/model_catalog.go +++ b/apps/api/internal/httpapi/model_catalog.go @@ -1025,6 +1025,7 @@ func modelCatalogCapabilityDefinitions() []ModelCatalogFilterOption { {Value: "text_to_speech", Label: "语音合成"}, {Value: "audio_understanding", Label: "音频理解"}, {Value: "text_embedding", Label: "Embedding"}, + {Value: "text_rerank", Label: "重排序"}, {Value: "omni", Label: "全模态"}, {Value: "omni_video", Label: "全模态视频"}, {Value: "multimodal", Label: "多模态"}, @@ -1120,6 +1121,8 @@ func canonicalCapabilityFilterValue(value string) string { switch normalized { case "embedding": return "text_embedding" + case "rerank", "reranks": + return "text_rerank" case "model": return "model_3d" default: @@ -1143,6 +1146,8 @@ func capabilityFilterValueForTag(tag string) string { return "structured_output" case "数字人": return "digital_human" + case "重排序": + return "text_rerank" case "3D 模型": return "model_3d" case "文生 3D": @@ -1165,6 +1170,9 @@ func capabilityLabel(value string) string { "responses": "Responses", "text_embedding": "Embedding", "embedding": "Embedding", + "text_rerank": "重排序", + "rerank": "重排序", + "reranks": "重排序", "image_generate": "图像生成", "image_edit": "图像编辑", "image_analysis": "图像分析", diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index b9ab6f5..577087a 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -128,6 +128,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor 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.auth.Require(auth.PermissionBasic, server.createAPIV1ChatCompletions())) mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false))) + mux.Handle("POST /api/v1/embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", false))) + mux.Handle("POST /api/v1/reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", false))) mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false))) mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false))) mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations", false))) @@ -140,6 +142,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("POST /v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", true))) mux.Handle("POST /responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", true))) mux.Handle("POST /v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", true))) + mux.Handle("POST /embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", true))) + mux.Handle("POST /v1/embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", true))) + mux.Handle("POST /reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", true))) + mux.Handle("POST /v1/reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", true))) mux.Handle("POST /images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true))) mux.Handle("POST /v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true))) mux.Handle("POST /images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true))) diff --git a/apps/api/internal/runner/limits.go b/apps/api/internal/runner/limits.go index 84066d8..c533c93 100644 --- a/apps/api/internal/runner/limits.go +++ b/apps/api/internal/runner/limits.go @@ -159,16 +159,14 @@ func hasRules(policy map[string]any) bool { } func estimateRequestTokens(body map[string]any) int { - text := "" - if prompt := stringFromMap(body, "prompt"); prompt != "" { - text += prompt - } - if input := stringFromMap(body, "input"); input != "" { - text += input - } + var text strings.Builder + appendTokenEstimateText(&text, body["prompt"]) + appendTokenEstimateText(&text, body["input"]) + appendTokenEstimateText(&text, body["query"]) + appendTokenEstimateText(&text, body["documents"]) for _, item := range contentItems(body["content"]) { if stringFromAny(item["type"]) == "text" { - text += stringFromAny(item["text"]) + appendTokenEstimateText(&text, item["text"]) } } if messages, ok := body["messages"].([]any); ok { @@ -176,19 +174,41 @@ func estimateRequestTokens(body map[string]any) int { message, _ := raw.(map[string]any) switch content := message["content"].(type) { case string: - text += content + appendTokenEstimateText(&text, content) case []any: for _, rawPart := range content { part, _ := rawPart.(map[string]any) - text += stringFromMap(part, "text") + appendTokenEstimateText(&text, part["text"]) } } } } - if text == "" { + estimatedText := text.String() + if estimatedText == "" { return 1 } - return len([]rune(text))/4 + 1 + return len([]rune(estimatedText))/4 + 1 +} + +func appendTokenEstimateText(out *strings.Builder, value any) { + switch typed := value.(type) { + case string: + out.WriteString(typed) + case []string: + for _, item := range typed { + out.WriteString(item) + } + case []any: + for _, item := range typed { + appendTokenEstimateText(out, item) + } + case map[string]any: + for _, key := range []string{"text", "content", "query", "document"} { + if text := stringFromAny(typed[key]); text != "" { + out.WriteString(text) + } + } + } } func tokenUsageAmounts(usage clients.Usage) map[string]float64 { diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index f2c4fde..4e2a70b 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -40,8 +40,11 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body } func (s *Service) estimatedBillings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) []any { - usage := clients.Usage{InputTokens: estimateRequestTokens(body), OutputTokens: int(floatFromAny(body["max_tokens"]))} - if usage.OutputTokens == 0 { + usage := clients.Usage{InputTokens: estimateRequestTokens(body)} + if isTextGenerationKind(kind) { + usage.OutputTokens = int(floatFromAny(body["max_tokens"])) + } + if isTextGenerationKind(kind) && usage.OutputTokens == 0 { usage.OutputTokens = 64 } usage.TotalTokens = usage.InputTokens + usage.OutputTokens @@ -56,19 +59,25 @@ func (s *Service) estimatedBillings(ctx context.Context, user *auth.User, kind s func (s *Service) billings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate, response clients.Response, simulated bool) []any { config := s.effectiveBillingConfig(ctx, candidate) discount := effectiveDiscount(ctx, s.store, user, candidate) - if isTextGenerationKind(kind) { + if isTextBillingKind(kind) { inputTokens := response.Usage.InputTokens outputTokens := response.Usage.OutputTokens + if isTextInputOnlyKind(kind) && inputTokens == 0 && response.Usage.TotalTokens > 0 { + inputTokens = response.Usage.TotalTokens + } if inputTokens == 0 && outputTokens == 0 { inputTokens = estimateRequestTokens(body) - outputTokens = 1 + if isTextGenerationKind(kind) { + outputTokens = 1 + } } inputAmount := roundPrice(float64(inputTokens) / 1000 * resourcePrice(config, "text", "textInputPer1k", "inputTokenPrice", "basePrice") * discount) - outputAmount := roundPrice(float64(outputTokens) / 1000 * resourcePrice(config, "text", "textOutputPer1k", "outputTokenPrice", "basePrice") * discount) - return []any{ - billingLine(candidate, "text_input", "1k_tokens", inputTokens, inputAmount, discount, simulated), - billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated), + lines := []any{billingLine(candidate, "text_input", "1k_tokens", inputTokens, inputAmount, discount, simulated)} + if isTextGenerationKind(kind) { + outputAmount := roundPrice(float64(outputTokens) / 1000 * resourcePrice(config, "text", "textOutputPer1k", "outputTokenPrice", "basePrice") * discount) + lines = append(lines, billingLine(candidate, "text_output", "1k_tokens", outputTokens, outputAmount, discount, simulated)) } + return lines } count := requestOutputCount(body) resource := "image" diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index f4607a3..e9ff718 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -698,7 +698,7 @@ func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID s func skipTaskParameterPreprocessingLog(modelType string) bool { switch strings.TrimSpace(modelType) { - case "text_generate", "chat", "responses", "text": + case "text_generate", "text_embedding", "text_rerank", "chat", "responses", "text": return true default: return false @@ -923,6 +923,10 @@ func modelTypeFromKind(kind string, body map[string]any) string { switch kind { case "chat.completions", "responses": return "text_generate" + case "embeddings": + return "text_embedding" + case "reranks": + return "text_rerank" case "images.generations", "images.edits": if kind == "images.edits" { return "image_edit" @@ -943,7 +947,7 @@ func modelTypeFromKind(kind string, body map[string]any) string { func requestedModelTypeFromBody(body map[string]any) string { for _, key := range []string{"modelType", "model_type", "capability", "capabilityType"} { - value := strings.TrimSpace(stringFromMap(body, key)) + value := canonicalModelType(strings.TrimSpace(stringFromMap(body, key))) if isKnownModelType(value) { return value } @@ -951,9 +955,21 @@ func requestedModelTypeFromBody(body map[string]any) string { return "" } +func canonicalModelType(value string) string { + normalized := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), "-", "_") + switch normalized { + case "embedding": + return "text_embedding" + case "rerank", "reranks": + return "text_rerank" + default: + return normalized + } +} + func isKnownModelType(value string) bool { switch value { - case "text_generate", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni": + case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni": return true default: return false @@ -1001,6 +1017,14 @@ func isTextGenerationKind(kind string) bool { return kind == "chat.completions" || kind == "responses" } +func isTextInputOnlyKind(kind string) bool { + return kind == "embeddings" || kind == "reranks" +} + +func isTextBillingKind(kind string) bool { + return isTextGenerationKind(kind) || isTextInputOnlyKind(kind) +} + func isSimulation(task store.GatewayTask, candidate store.RuntimeModelCandidate) bool { if task.RunMode == "simulation" { return true @@ -1115,6 +1139,17 @@ func validateRequest(kind string, body map[string]any) error { if body["input"] == nil && body["messages"] == nil { return errors.New("input or messages is required") } + case "embeddings": + if body["input"] == nil { + return errors.New("input is required") + } + case "reranks": + if body["query"] == nil { + return errors.New("query is required") + } + if !hasRerankDocuments(body["documents"]) { + return errors.New("documents is required") + } case "images.generations", "images.edits": if strings.TrimSpace(stringFromMap(body, "prompt")) == "" { return errors.New("prompt is required") @@ -1123,6 +1158,17 @@ func validateRequest(kind string, body map[string]any) error { return nil } +func hasRerankDocuments(value any) bool { + switch typed := value.(type) { + case []any: + return len(typed) > 0 + case []string: + return len(typed) > 0 + default: + return false + } +} + func parameterPreprocessClientError(err error) *clients.ClientError { if err == nil { return nil diff --git a/apps/api/internal/store/model_billing_filter.go b/apps/api/internal/store/model_billing_filter.go index 6e39b94..f434c95 100644 --- a/apps/api/internal/store/model_billing_filter.go +++ b/apps/api/internal/store/model_billing_filter.go @@ -47,7 +47,7 @@ func billingResourcesForModelTypes(modelTypes []string) map[string]bool { resources := map[string]bool{} for _, modelType := range modelTypes { switch normalizeBillingType(modelType) { - case "chat", "text", "responses", "text_generate", "text_embedding", "embedding", + case "chat", "text", "responses", "text_generate", "text_embedding", "embedding", "text_rerank", "rerank", "image_analysis", "video_understanding", "audio_understanding", "omni", "tools_call": resources["text"] = true case "image", "images.generations", "image_generate": diff --git a/apps/api/migrations/0044_openai_compatible_embedding_rerank.sql b/apps/api/migrations/0044_openai_compatible_embedding_rerank.sql new file mode 100644 index 0000000..c7c177c --- /dev/null +++ b/apps/api/migrations/0044_openai_compatible_embedding_rerank.sql @@ -0,0 +1,253 @@ +WITH aliyun_model_defs AS ( + SELECT + 'aliyun-bailian-openai' AS provider_key, + 'aliyun-bailian-openai:text-embedding-v4' AS canonical_model_key, + 'text-embedding-v4' AS provider_model_name, + 'Qwen3-Embedding-v4' AS display_name, + 'Qwen3-Embedding-v4' AS model_alias, + 'Qwen3-Embedding 系列,默认维度 1024,最长输入 8192 tokens;支持 100+ 语种与多种编程语言。' AS description, + 'https://static.51easyai.com/qwen-color.webp' AS icon_path, + jsonb_build_array('text_embedding') AS model_type, + jsonb_build_object( + 'text_embedding', jsonb_build_object( + 'dimensions', jsonb_build_array(2048, 1536, 1024, 768, 512, 256, 128, 64), + 'defaultDimension', 1024, + 'maxRows', 10, + 'maxTokensPerRow', 8192 + ), + 'originalTypes', jsonb_build_array('text_embedding') + ) AS capabilities + UNION ALL + SELECT + 'aliyun-bailian-openai', + 'aliyun-bailian-openai:text-embedding-v3', + 'text-embedding-v3', + 'Qwen3-Embedding-v3', + 'Qwen3-Embedding-v3', + 'Qwen3-Embedding 系列,默认维度 1024;支持中文、英文及 50+ 主流语种。', + 'https://static.51easyai.com/qwen-color.webp', + jsonb_build_array('text_embedding'), + jsonb_build_object( + 'text_embedding', jsonb_build_object( + 'dimensions', jsonb_build_array(1024, 768, 512, 256, 128, 64), + 'defaultDimension', 1024 + ), + 'originalTypes', jsonb_build_array('text_embedding') + ) + UNION ALL + SELECT + 'aliyun-bailian-openai', + 'aliyun-bailian-openai:text-embedding-v2', + 'text-embedding-v2', + 'Text-Embedding-v2', + 'Text-Embedding-v2', + '固定维度 1536,最长输入 2048 tokens;支持中英西法葡印尼日韩德俄等语种。', + 'https://static.51easyai.com/qwen-color.webp', + jsonb_build_array('text_embedding'), + jsonb_build_object( + 'text_embedding', jsonb_build_object( + 'dimensions', jsonb_build_array(1536), + 'defaultDimension', 1536, + 'maxRows', 25, + 'maxTokensPerRow', 2048 + ), + 'originalTypes', jsonb_build_array('text_embedding') + ) + UNION ALL + SELECT + 'aliyun-bailian-openai', + 'aliyun-bailian-openai:text-embedding-v1', + 'text-embedding-v1', + 'Text-Embedding-v1', + 'Text-Embedding-v1', + '百炼 OpenAI 兼容渠道 embedding 基础模型。', + 'https://static.51easyai.com/qwen-color.webp', + jsonb_build_array('text_embedding'), + jsonb_build_object('originalTypes', jsonb_build_array('text_embedding')) + UNION ALL + SELECT + 'aliyun-bailian-openai', + 'aliyun-bailian-openai:qwen3-rerank', + 'qwen3-rerank', + 'Qwen3-Rerank', + 'Qwen3-Rerank', + '阿里云百炼 OpenAI 兼容重排序模型,支持 100+ 语种,适用于语义文本搜索和 RAG。', + 'https://static.51easyai.com/qwen-color.webp', + jsonb_build_array('text_rerank'), + jsonb_build_object( + 'text_rerank', jsonb_build_object( + 'maxDocuments', 500, + 'maxTokensPerDocument', 4000, + 'maxRequestTokens', 120000, + 'supportTopN', true, + 'supportInstruct', true + ), + 'originalTypes', jsonb_build_array('text_rerank') + ) +), +source_rows AS ( + SELECT + providers.id AS provider_id, + defs.provider_key, + defs.canonical_model_key, + defs.provider_model_name, + defs.model_type, + defs.display_name, + defs.model_alias, + defs.description, + defs.icon_path, + defs.capabilities, + COALESCE(template.base_billing_config, '{"text":{"basePrice":0.01,"baseWeight":1}}'::jsonb) AS base_billing_config, + COALESCE(template.default_rate_limit_policy, '{}'::jsonb) AS default_rate_limit_policy, + COALESCE( + template.pricing_rule_set_id, + (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1) + ) AS pricing_rule_set_id, + COALESCE( + template.runtime_policy_set_id, + (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1) + ) AS runtime_policy_set_id, + COALESCE(template.runtime_policy_override, '{}'::jsonb) AS runtime_policy_override, + COALESCE(template.pricing_version, 1) AS pricing_version + FROM aliyun_model_defs defs + LEFT JOIN model_catalog_providers providers + ON providers.provider_key = defs.provider_key + OR providers.provider_code = defs.provider_key + LEFT JOIN base_model_catalog template + ON template.canonical_model_key = 'aliyun-bailian-openai:text-embedding-v4' +), +payload AS ( + SELECT + source_rows.*, + jsonb_build_object( + 'source', 'aliyun.model-studio.docs', + 'sourceProviderCode', provider_key, + 'sourceProviderName', '阿里云百炼(OpenAI兼容)', + 'sourceSpecType', 'openai', + 'originalTypes', model_type, + 'alias', model_alias, + 'description', description, + 'iconPath', icon_path, + 'billingType', 'external-api', + 'billingMode', '', + 'referenceModel', '', + 'modelWeight', NULL, + 'selectable', true, + 'rawModel', jsonb_build_object( + 'name', provider_model_name, + 'types', model_type, + 'icon_path', icon_path, + 'alias', model_alias, + 'description', description, + 'capabilities', capabilities - 'originalTypes' + ) + ) AS metadata + FROM source_rows +), +snapshot AS ( + SELECT + payload.*, + jsonb_build_object( + 'providerKey', provider_key, + 'canonicalModelKey', canonical_model_key, + 'providerModelName', provider_model_name, + 'modelType', model_type, + 'modelAlias', model_alias, + 'capabilities', capabilities, + 'baseBillingConfig', base_billing_config, + 'defaultRateLimitPolicy', default_rate_limit_policy, + 'pricingRuleSetId', COALESCE(pricing_rule_set_id::text, ''), + 'runtimePolicySetId', COALESCE(runtime_policy_set_id::text, ''), + 'runtimePolicyOverride', runtime_policy_override, + 'metadata', metadata, + 'pricingVersion', pricing_version, + 'status', 'active' + ) AS default_snapshot + FROM payload +) +INSERT INTO base_model_catalog ( + provider_id, + provider_key, + canonical_model_key, + provider_model_name, + model_type, + display_name, + capabilities, + base_billing_config, + default_rate_limit_policy, + pricing_rule_set_id, + runtime_policy_set_id, + runtime_policy_override, + metadata, + catalog_type, + default_snapshot, + pricing_version, + status +) +SELECT + provider_id, + provider_key, + canonical_model_key, + provider_model_name, + model_type, + display_name, + capabilities, + base_billing_config, + default_rate_limit_policy, + pricing_rule_set_id, + runtime_policy_set_id, + runtime_policy_override, + metadata, + 'system', + default_snapshot, + pricing_version, + 'active' +FROM snapshot +ON CONFLICT (canonical_model_key) DO UPDATE +SET provider_id = EXCLUDED.provider_id, + provider_key = EXCLUDED.provider_key, + provider_model_name = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name + ELSE base_model_catalog.provider_model_name + END, + model_type = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.model_type + ELSE base_model_catalog.model_type + END, + display_name = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name + ELSE base_model_catalog.display_name + END, + capabilities = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.capabilities + ELSE base_model_catalog.capabilities + END, + base_billing_config = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.base_billing_config + ELSE base_model_catalog.base_billing_config + END, + default_rate_limit_policy = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_rate_limit_policy + ELSE base_model_catalog.default_rate_limit_policy + END, + pricing_rule_set_id = COALESCE(base_model_catalog.pricing_rule_set_id, EXCLUDED.pricing_rule_set_id), + runtime_policy_set_id = COALESCE(base_model_catalog.runtime_policy_set_id, EXCLUDED.runtime_policy_set_id), + runtime_policy_override = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.runtime_policy_override + ELSE base_model_catalog.runtime_policy_override + END, + metadata = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.metadata + ELSE base_model_catalog.metadata + END, + catalog_type = 'system', + default_snapshot = EXCLUDED.default_snapshot, + pricing_version = CASE + WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.pricing_version + ELSE base_model_catalog.pricing_version + END, + status = CASE + WHEN base_model_catalog.customized_at IS NULL THEN 'active' + ELSE base_model_catalog.status + END, + updated_at = now(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 508f0c3..1d2cee3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -540,7 +540,7 @@ export function App() { try { const response = await createApiKey(token, { name: apiKeyForm.name, - scopes: ['chat', 'image', 'video'], + scopes: ['chat', 'embedding', 'rerank', 'image', 'video'], expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined, }); setApiKeySecret(response.secret); @@ -907,11 +907,20 @@ export function App() { async function submitTask(event: FormEvent) { event.preventDefault(); - const credential = apiKeySecret || token; + const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : ''; + const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id; + const credential = selectedApiKeySecret || (fallbackApiKeySecret ? apiKeySecretsById[fallbackApiKeySecret] : '') || apiKeySecret || token; + const credentialLabel = selectedApiKeySecret || fallbackApiKeySecret || apiKeySecret ? '本地 API Key' : '当前 Access Token'; setCoreState('loading'); setCoreMessage(''); try { const response = await runTask(credential, taskForm); + if (response.localOnly) { + setTaskResult(response.task); + setCoreState('ready'); + setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`); + return; + } const syncTask = (detail: GatewayTask) => { setTaskResult(detail); setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]); @@ -921,7 +930,7 @@ export function App() { setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]); invalidateDataKeys('tasks', 'wallet', 'walletTransactions'); setCoreState('ready'); - setCoreMessage(`${taskForm.kind} 已通过 ${apiKeySecret ? '本地 API Key' : '当前 Access Token'} 完成 simulation。`); + setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`); } catch (err) { setCoreState('error'); setCoreMessage(err instanceof Error ? err.message : '测试任务失败'); @@ -1170,12 +1179,16 @@ export function App() { {activePage === 'docs' && ( >; runMode?: string; simulation?: boolean; stream?: boolean }, +): Promise> { + return request>('/v1/chat/completions', { + body: input, + method: 'POST', + token, + }); +} + +export async function createEmbedding( + token: string, + input: { model: string; input: string | string[]; dimensions?: number; runMode?: string; simulation?: boolean }, +): Promise> { + return request>('/v1/embeddings', { + body: input, + method: 'POST', + token, + }); +} + +export async function createRerank( + token: string, + input: { model: string; query: string; documents: string[]; top_n?: number; runMode?: string; simulation?: boolean }, +): Promise> { + return request>('/v1/reranks', { + body: input, + method: 'POST', + token, + }); +} + export async function streamChatCompletions( token: string, input: { model: string; messages: Array>; simulation?: boolean }, diff --git a/apps/web/src/lib/run-task.ts b/apps/web/src/lib/run-task.ts index 517677e..9cf43c3 100644 --- a/apps/web/src/lib/run-task.ts +++ b/apps/web/src/lib/run-task.ts @@ -1,8 +1,45 @@ import type { GatewayTask } from '@easyai-ai-gateway/contracts'; -import { createChatTask, createImageEditTask, createImageGenerationTask } from '../api'; +import { createCompatibleChatCompletion, createEmbedding, createImageEditTask, createImageGenerationTask, createRerank } from '../api'; import type { TaskForm } from '../types'; -export function runTask(token: string, task: TaskForm): Promise<{ task: GatewayTask; next: Record }> { +export interface RunTaskResponse { + localOnly?: boolean; + next?: Record; + task: GatewayTask; +} + +export async function runTask(token: string, task: TaskForm): Promise { + if (task.kind === 'chat.completions') { + const result = await createCompatibleChatCompletion(token, { + model: task.model, + runMode: 'simulation', + simulation: true, + stream: false, + messages: [{ role: 'user', content: task.prompt }], + }); + return { localOnly: true, task: compatibleTask(task, result) }; + } + if (task.kind === 'embeddings') { + const result = await createEmbedding(token, { + model: task.model, + input: embeddingInput(task.prompt), + dimensions: task.dimensions, + runMode: 'simulation', + simulation: true, + }); + return { localOnly: true, task: compatibleTask(task, result) }; + } + if (task.kind === 'reranks') { + const result = await createRerank(token, { + model: task.model, + query: task.prompt, + documents: rerankDocuments(task.documents), + top_n: task.topN, + runMode: 'simulation', + simulation: true, + }); + return { localOnly: true, task: compatibleTask(task, result) }; + } if (task.kind === 'images.generations') { return createImageGenerationTask(token, { model: task.model, @@ -23,10 +60,78 @@ export function runTask(token: string, task: TaskForm): Promise<{ task: GatewayT simulation: true, }); } - return createChatTask(token, { + throw new Error(`Unsupported task kind: ${task.kind}`); +} + +function compatibleTask(task: TaskForm, result: Record): GatewayTask { + const now = new Date().toISOString(); + return { + id: `docs-${task.kind}-${Date.now()}`, + asyncMode: false, + createdAt: now, + finishedAt: now, + kind: task.kind, model: task.model, + modelType: modelTypeForKind(task.kind), + request: requestSnapshot(task), + result, + runMode: 'simulation', + status: 'succeeded', + updatedAt: now, + userId: 'docs-runner', + }; +} + +function requestSnapshot(task: TaskForm): Record { + if (task.kind === 'embeddings') { + return { + model: task.model, + input: embeddingInput(task.prompt), + dimensions: task.dimensions, + runMode: 'simulation', + simulation: true, + }; + } + if (task.kind === 'reranks') { + return { + model: task.model, + query: task.prompt, + documents: rerankDocuments(task.documents), + top_n: task.topN, + runMode: 'simulation', + simulation: true, + }; + } + return { + model: task.model, + messages: [{ role: 'user', content: task.prompt }], runMode: 'simulation', simulation: true, - messages: [{ role: 'user', content: task.prompt }], - }); + stream: false, + }; +} + +function embeddingInput(prompt: string) { + const lines = splitLines(prompt); + return lines.length > 1 ? lines : (lines[0] ?? prompt); +} + +function rerankDocuments(value?: string) { + const documents = splitLines(value ?? ''); + return documents.length ? documents : ['AI Gateway 提供 OpenAI 兼容接口。', '图片生成任务支持异步队列。']; +} + +function splitLines(value: string) { + return value + .split(/\n+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function modelTypeForKind(kind: TaskForm['kind']) { + if (kind === 'embeddings') return 'text_embedding'; + if (kind === 'reranks') return 'text_rerank'; + if (kind === 'images.generations') return 'image_generate'; + if (kind === 'images.edits') return 'image_edit'; + return 'text_generate'; } diff --git a/apps/web/src/navigation.ts b/apps/web/src/navigation.ts index 1caf236..c3fb4a8 100644 --- a/apps/web/src/navigation.ts +++ b/apps/web/src/navigation.ts @@ -57,7 +57,7 @@ export const adminPages = [ export const apiDocPages = [ { title: '鉴权与限流', path: '/docs/auth', description: '本地账号、JWT、OpenAPI Key、TPM/RPM/并发限制和错误码。' }, - { title: 'Chat / Responses', path: '/docs/api/chat', description: '对话、stream、结构化输出、取消请求和示例代码。' }, + { title: 'Chat / Embeddings / Reranks', path: '/docs/api/chat', description: '对话、文本向量、重排序、鉴权和示例代码。' }, { title: '图片 / 视频', path: '/docs/api/media', description: '生图、图像编辑、生视频、任务进度和结果取回。' }, { title: '在线调用测试', path: '/docs/playground', description: '选择模型和 API Key,编辑参数,查看实时响应和 billings。' }, ]; diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index ed99f39..73bc999 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -1,33 +1,52 @@ -import { useMemo, type FormEvent } from 'react'; -import type { GatewayTask } from '@easyai-ai-gateway/contracts'; -import { BookOpen, Play, Search, Send } from 'lucide-react'; +import { useEffect, useMemo, type FormEvent } from 'react'; +import type { GatewayApiKey, GatewayTask } from '@easyai-ai-gateway/contracts'; +import { BookOpen, KeyRound, Play, Search, Send } from 'lucide-react'; import { Badge, Button, Select, Textarea } from '../components/ui'; -import type { ApiDocSection, LoadState, TaskForm } from '../types'; +import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types'; +import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared'; -const docs: Array<{ key: ApiDocSection; group: string; method: string; path: string; title: string }> = [ - { key: 'chat', group: '聊天(Chat)', method: 'POST', path: '/v1/chat/completions', title: 'Chat(聊天)' }, - { key: 'imageGeneration', group: '图片', method: 'POST', path: '/v1/images/generations', title: '创建图片' }, - { key: 'imageEdit', group: '图片', method: 'POST', path: '/v1/images/edits', title: '编辑图片' }, - { key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估' }, - { key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件' }, +interface ApiDocItem { + group: string; + key: ApiDocSection; + kind?: TaskKind; + lead: string; + method: string; + path: string; + title: string; +} + +const docs: ApiDocItem[] = [ + { key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' }, + { key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embedding,API Key 需要 embedding 权限。' }, + { key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API Key 需要 rerank 权限。' }, + { key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' }, + { key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' }, + { key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' }, + { key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' }, ]; const guideItems = ['获取 Base URL 和 API Key', '通知设置-WebHook 参数介绍', '错误码', '测试模式']; const taskKindOptions = [ ['chat.completions', 'Chat'], + ['embeddings', '文本向量'], + ['reranks', '重排序'], ['images.generations', '生图'], ['images.edits', '图像编辑'], ] as const; export function ApiDocsPage(props: { activeDocSection: ApiDocSection; - apiKeySecret: string; + apiKeySecretsById: Record; + apiKeys: GatewayApiKey[]; canRun: boolean; coreMessage: string; coreState: LoadState; + selectedApiKeyId: string; taskForm: TaskForm; taskResult: GatewayTask | null; + onApiKeyChange: (apiKeyId: string) => void; + onCreateApiKey: () => void; onLogin: () => void; onDocSectionChange: (value: ApiDocSection) => void; onSubmitTask: (event: FormEvent) => void; @@ -35,8 +54,16 @@ export function ApiDocsPage(props: { }) { const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0]; const isFileDoc = current.key === 'files'; + const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); + const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId); const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]); + useEffect(() => { + if (current.kind && props.taskForm.kind !== current.kind) { + props.onTaskFormChange(defaultTaskForKind(current.kind, props.taskForm)); + } + }, [current.kind, props.taskForm.kind]); + function handleSubmit(event: FormEvent) { if (!props.canRun) { event.preventDefault(); @@ -46,6 +73,21 @@ export function ApiDocsPage(props: { props.onSubmitTask(event); } + function handleDocClick(item: ApiDocItem) { + if (item.kind) { + props.onTaskFormChange(defaultTaskForKind(item.kind, props.taskForm)); + } + props.onDocSectionChange(item.key); + } + + function handleKindChange(kind: TaskKind) { + props.onTaskFormChange(defaultTaskForKind(kind, props.taskForm)); + const nextSection = docSectionForKind(kind); + if (nextSection !== props.activeDocSection) { + props.onDocSectionChange(nextSection); + } + } + return (
-

- 保持与原 integration-platform / OpenAI 兼容接口一致,支持本地 API Key 和 server-main 接入 token。 -

+

{current.lead}

@@ -90,7 +130,7 @@ export function ApiDocsPage(props: {
- +
@@ -104,12 +144,9 @@ export function ApiDocsPage(props: { ) : ( - <> - - - - - + bodyParamRows(current.key).map((row) => ( + + )) )}
@@ -127,9 +164,27 @@ export function ApiDocsPage(props: { POST {current.path} + + {apiKeyNotice && ( +
+ {apiKeyNotice} + +
+ )}