From a076ce5756cc53b815e85805560c4cf1a8600c3a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:08:11 +0800 Subject: [PATCH] feat: Add API key authentication system for external service access (#1757) * Initial plan * feat: Add API key authentication system backend Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * feat: Add API key management UI in frontend sidebar Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * fix: Correct import paths in API controller groups Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * fix: Address code review feedback - add i18n and validation Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * refactor: Enable API key auth on existing endpoints instead of creating separate service API - Added USER_TOKEN_OR_API_KEY auth type that accepts both authentication methods - Removed separate /api/service/v1/models endpoints - Updated existing endpoints (models, bots, pipelines) to accept API keys - External services can now use API keys to access all existing LangBot APIs - Updated documentation to reflect unified API approach Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * docs: Add OpenAPI specification for API key authenticated endpoints Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * chore: rename openapi spec * perf: ui and i18n * fix: ui bug * chore: tidy docs * chore: fix linter errors --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin --- docs/API_KEY_AUTH.md | 291 +++ TESTING_SUMMARY.md => docs/TESTING_SUMMARY.md | 0 docs/service-api-openapi.json | 1944 +++++++++++++++++ pkg/api/http/controller/group.py | 59 + pkg/api/http/controller/groups/apikeys.py | 43 + .../controller/groups/pipelines/pipelines.py | 8 +- .../http/controller/groups/platform/bots.py | 6 +- .../http/controller/groups/provider/models.py | 12 +- pkg/api/http/service/apikey.py | 79 + pkg/core/app.py | 3 + pkg/core/stages/build_app.py | 4 + pkg/entity/persistence/apikey.py | 21 + .../ApiKeyManagementDialog.tsx | 353 +++ .../components/home-sidebar/HomeSidebar.tsx | 23 + web/src/i18n/locales/en-US.ts | 17 + web/src/i18n/locales/ja-JP.ts | 17 + web/src/i18n/locales/zh-Hans.ts | 16 + web/src/i18n/locales/zh-Hant.ts | 16 + 18 files changed, 2899 insertions(+), 13 deletions(-) create mode 100644 docs/API_KEY_AUTH.md rename TESTING_SUMMARY.md => docs/TESTING_SUMMARY.md (100%) create mode 100644 docs/service-api-openapi.json create mode 100644 pkg/api/http/controller/groups/apikeys.py create mode 100644 pkg/api/http/service/apikey.py create mode 100644 pkg/entity/persistence/apikey.py create mode 100644 web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md new file mode 100644 index 00000000..3aa0d363 --- /dev/null +++ b/docs/API_KEY_AUTH.md @@ -0,0 +1,291 @@ +# API Key Authentication + +LangBot now supports API key authentication for external systems to access its HTTP service API. + +## Managing API Keys + +API keys can be managed through the web interface: + +1. Log in to the LangBot web interface +2. Click the "API Keys" button at the bottom of the sidebar +3. Create, view, copy, or delete API keys as needed + +## Using API Keys + +### Authentication Headers + +Include your API key in the request header using one of these methods: + +**Method 1: X-API-Key header (Recommended)** +``` +X-API-Key: lbk_your_api_key_here +``` + +**Method 2: Authorization Bearer token** +``` +Authorization: Bearer lbk_your_api_key_here +``` + +## Available APIs + +All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access: + +- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding` +- **Bot Management** - `/api/v1/platform/bots` +- **Pipeline Management** - `/api/v1/pipelines` +- **Knowledge Base** - `/api/v1/knowledge/*` +- **MCP Servers** - `/api/v1/mcp/servers` +- And more... + +### Authentication Methods + +Each endpoint accepts **either**: +1. **User Token** (via `Authorization: Bearer `) - for web UI and authenticated users +2. **API Key** (via `X-API-Key` or `Authorization: Bearer `) - for external services + +## Example: Model Management + +### List All LLM Models + +```http +GET /api/v1/provider/models/llm +X-API-Key: lbk_your_api_key_here +``` + +Response: +```json +{ + "code": 0, + "msg": "ok", + "data": { + "models": [ + { + "uuid": "model-uuid", + "name": "GPT-4", + "description": "OpenAI GPT-4 model", + "requester": "openai-chat-completions", + "requester_config": {...}, + "abilities": ["chat", "vision"], + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00" + } + ] + } +} +``` + +### Create a New LLM Model + +```http +POST /api/v1/provider/models/llm +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Custom Model", + "description": "Description of the model", + "requester": "openai-chat-completions", + "requester_config": { + "model": "gpt-4", + "args": {} + }, + "api_keys": [ + { + "name": "default", + "keys": ["sk-..."] + } + ], + "abilities": ["chat"], + "extra_args": {} +} +``` + +### Update an LLM Model + +```http +PUT /api/v1/provider/models/llm/{model_uuid} +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "Updated Model Name", + "description": "Updated description", + ... +} +``` + +### Delete an LLM Model + +```http +DELETE /api/v1/provider/models/llm/{model_uuid} +X-API-Key: lbk_your_api_key_here +``` + +## Example: Bot Management + +### List All Bots + +```http +GET /api/v1/platform/bots +X-API-Key: lbk_your_api_key_here +``` + +### Create a New Bot + +```http +POST /api/v1/platform/bots +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Bot", + "adapter": "telegram", + "config": {...} +} +``` + +## Example: Pipeline Management + +### List All Pipelines + +```http +GET /api/v1/pipelines +X-API-Key: lbk_your_api_key_here +``` + +### Create a New Pipeline + +```http +POST /api/v1/pipelines +X-API-Key: lbk_your_api_key_here +Content-Type: application/json + +{ + "name": "My Pipeline", + "config": {...} +} +``` + +## Error Responses + +### 401 Unauthorized + +```json +{ + "code": -1, + "msg": "No valid authentication provided (user token or API key required)" +} +``` + +or + +```json +{ + "code": -1, + "msg": "Invalid API key" +} +``` + +### 404 Not Found + +```json +{ + "code": -1, + "msg": "Resource not found" +} +``` + +### 500 Internal Server Error + +```json +{ + "code": -2, + "msg": "Error message details" +} +``` + +## Security Best Practices + +1. **Keep API keys secure**: Store them securely and never commit them to version control +2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission +3. **Rotate keys regularly**: Create new API keys periodically and delete old ones +4. **Use descriptive names**: Give your API keys meaningful names to track their usage +5. **Delete unused keys**: Remove API keys that are no longer needed +6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity + +## Example: Python Client + +```python +import requests + +API_KEY = "lbk_your_api_key_here" +BASE_URL = "http://your-langbot-server:5300" + +headers = { + "X-API-Key": API_KEY, + "Content-Type": "application/json" +} + +# List all models +response = requests.get(f"{BASE_URL}/api/v1/provider/models/llm", headers=headers) +models = response.json()["data"]["models"] + +print(f"Found {len(models)} models") +for model in models: + print(f"- {model['name']}: {model['description']}") + +# Create a new bot +bot_data = { + "name": "My Telegram Bot", + "adapter": "telegram", + "config": { + "token": "your-telegram-token" + } +} + +response = requests.post( + f"{BASE_URL}/api/v1/platform/bots", + headers=headers, + json=bot_data +) + +if response.status_code == 200: + bot_uuid = response.json()["data"]["uuid"] + print(f"Bot created with UUID: {bot_uuid}") +``` + +## Example: cURL + +```bash +# List all models +curl -X GET \ + -H "X-API-Key: lbk_your_api_key_here" \ + http://your-langbot-server:5300/api/v1/provider/models/llm + +# Create a new pipeline +curl -X POST \ + -H "X-API-Key: lbk_your_api_key_here" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Pipeline", + "config": {...} + }' \ + http://your-langbot-server:5300/api/v1/pipelines + +# Get bot logs +curl -X POST \ + -H "X-API-Key: lbk_your_api_key_here" \ + -H "Content-Type: application/json" \ + -d '{ + "from_index": -1, + "max_count": 10 + }' \ + http://your-langbot-server:5300/api/v1/platform/bots/{bot_uuid}/logs +``` + +## Notes + +- The same endpoints work for both the web UI (with user tokens) and external services (with API keys) +- No need to learn different API paths - use the existing API documentation with API key authentication +- All endpoints that previously required user authentication now also accept API keys + diff --git a/TESTING_SUMMARY.md b/docs/TESTING_SUMMARY.md similarity index 100% rename from TESTING_SUMMARY.md rename to docs/TESTING_SUMMARY.md diff --git a/docs/service-api-openapi.json b/docs/service-api-openapi.json new file mode 100644 index 00000000..1e81adee --- /dev/null +++ b/docs/service-api-openapi.json @@ -0,0 +1,1944 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "LangBot API with API Key Authentication", + "description": "LangBot external service API documentation. These endpoints support API Key authentication \nfor external systems to programmatically access LangBot resources.\n\n**Authentication Methods:**\n- User Token (via `Authorization: Bearer `)\n- API Key (via `X-API-Key: ` or `Authorization: Bearer `)\n\nAll endpoints documented here accept BOTH authentication methods.\n", + "version": "4.4.1", + "contact": { + "name": "LangBot", + "url": "https://langbot.app" + }, + "license": { + "name": "AGPL-3.0", + "url": "https://github.com/langbot-app/LangBot/blob/master/LICENSE" + } + }, + "servers": [ + { + "url": "http://localhost:5300", + "description": "Local development server" + } + ], + "tags": [ + { + "name": "Models - LLM", + "description": "Large Language Model management operations" + }, + { + "name": "Models - Embedding", + "description": "Embedding model management operations" + }, + { + "name": "Bots", + "description": "Bot instance management operations" + }, + { + "name": "Pipelines", + "description": "Pipeline configuration management operations" + } + ], + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "paths": { + "/api/v1/provider/models/llm": { + "get": { + "tags": [ + "Models - LLM" + ], + "summary": "List all LLM models", + "description": "Retrieve a list of all configured LLM models", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LLMModel" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Models - LLM" + ], + "summary": "Create a new LLM model", + "description": "Create and configure a new LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMModelCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Model created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/v1/provider/models/llm/{model_uuid}": { + "get": { + "tags": [ + "Models - LLM" + ], + "summary": "Get a specific LLM model", + "description": "Retrieve details of a specific LLM model by UUID", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/LLMModel" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Models - LLM" + ], + "summary": "Update an LLM model", + "description": "Update the configuration of an existing LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LLMModelUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Models - LLM" + ], + "summary": "Delete an LLM model", + "description": "Remove an LLM model from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Model deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/provider/models/llm/{model_uuid}/test": { + "post": { + "tags": [ + "Models - LLM" + ], + "summary": "Test an LLM model", + "description": "Test the connectivity and functionality of an LLM model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Model configuration to test" + } + } + } + }, + "responses": { + "200": { + "description": "Model test successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/v1/provider/models/embedding": { + "get": { + "tags": [ + "Models - Embedding" + ], + "summary": "List all embedding models", + "description": "Retrieve a list of all configured embedding models", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmbeddingModel" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Models - Embedding" + ], + "summary": "Create a new embedding model", + "description": "Create and configure a new embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingModelCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Model created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/provider/models/embedding/{model_uuid}": { + "get": { + "tags": [ + "Models - Embedding" + ], + "summary": "Get a specific embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/EmbeddingModel" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Models - Embedding" + ], + "summary": "Update an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddingModelUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Model updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Models - Embedding" + ], + "summary": "Delete an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "responses": { + "200": { + "description": "Model deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/provider/models/embedding/{model_uuid}/test": { + "post": { + "tags": [ + "Models - Embedding" + ], + "summary": "Test an embedding model", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ModelUUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Model test successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/platform/bots": { + "get": { + "tags": [ + "Bots" + ], + "summary": "List all bots", + "description": "Retrieve a list of all configured bot instances", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "bots": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Bot" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Bots" + ], + "summary": "Create a new bot", + "description": "Create and configure a new bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Bot created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/platform/bots/{bot_uuid}": { + "get": { + "tags": [ + "Bots" + ], + "summary": "Get a specific bot", + "description": "Retrieve details of a specific bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Bot UUID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "bot": { + "$ref": "#/components/schemas/Bot" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Bots" + ], + "summary": "Update a bot", + "description": "Update the configuration of an existing bot instance", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Bot updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Bots" + ], + "summary": "Delete a bot", + "description": "Remove a bot instance from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Bot deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/platform/bots/{bot_uuid}/logs": { + "post": { + "tags": [ + "Bots" + ], + "summary": "Get bot event logs", + "description": "Retrieve event logs for a specific bot", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "bot_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "from_index": { + "type": "integer", + "default": -1, + "description": "Starting index for logs (-1 for latest)" + }, + "max_count": { + "type": "integer", + "default": 10, + "description": "Maximum number of logs to retrieve" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object" + } + }, + "total_count": { + "type": "integer" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "List all pipelines", + "description": "Retrieve a list of all configured pipelines", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "sort_by", + "in": "query", + "schema": { + "type": "string", + "default": "created_at" + }, + "description": "Field to sort by" + }, + { + "name": "sort_order", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + }, + "description": "Sort order" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "pipelines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + }, + "post": { + "tags": [ + "Pipelines" + ], + "summary": "Create a new pipeline", + "description": "Create and configure a new pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineCreate" + } + } + } + }, + "responses": { + "200": { + "description": "Pipeline created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines/_/metadata": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get pipeline metadata", + "description": "Retrieve metadata and configuration options for pipelines", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "configs": { + "type": "object" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/v1/pipelines/{pipeline_uuid}": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get a specific pipeline", + "description": "Retrieve details of a specific pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "properties": { + "pipeline": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Pipelines" + ], + "summary": "Update a pipeline", + "description": "Update the configuration of an existing pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Pipeline updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "delete": { + "tags": [ + "Pipelines" + ], + "summary": "Delete a pipeline", + "description": "Remove a pipeline from the system", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Pipeline deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + }, + "/api/v1/pipelines/{pipeline_uuid}/extensions": { + "get": { + "tags": [ + "Pipelines" + ], + "summary": "Get pipeline extensions", + "description": "Retrieve extensions and plugins configured for a pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": [ + "Pipelines" + ], + "summary": "Update pipeline extensions", + "description": "Update the extensions configuration for a pipeline", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Extensions updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API Key authentication using X-API-Key header.\nExample: `X-API-Key: lbk_your_api_key_here`\n" + }, + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Bearer token authentication. Can be either a user JWT token or an API key.\nExample: `Authorization: Bearer `\n" + } + }, + "parameters": { + "ModelUUID": { + "name": "model_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Model UUID" + } + }, + "schemas": { + "LLMModel": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "GPT-4" + }, + "description": { + "type": "string", + "example": "OpenAI GPT-4 model" + }, + "requester": { + "type": "string", + "example": "openai-chat-completions" + }, + "requester_config": { + "type": "object", + "properties": { + "model": { + "type": "string", + "example": "gpt-4" + }, + "args": { + "type": "object" + } + } + }, + "api_keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "chat", + "vision" + ] + }, + "extra_args": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "LLMModelCreate": { + "type": "object", + "required": [ + "name", + "requester", + "requester_config", + "api_keys" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "LLMModelUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "abilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "EmbeddingModel": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "EmbeddingModelCreate": { + "type": "object", + "required": [ + "name", + "requester", + "requester_config", + "api_keys" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "EmbeddingModelUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "requester_config": { + "type": "object" + }, + "api_keys": { + "type": "array", + "items": { + "type": "object" + } + }, + "extra_args": { + "type": "object" + } + } + }, + "Bot": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "adapter": { + "type": "string", + "example": "telegram" + }, + "config": { + "type": "object" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "BotCreate": { + "type": "object", + "required": [ + "name", + "adapter", + "config" + ], + "properties": { + "name": { + "type": "string" + }, + "adapter": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "BotUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "Pipeline": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object" + }, + "is_default": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "PipelineCreate": { + "type": "object", + "required": [ + "name", + "config" + ], + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "PipelineUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "SuccessResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 0 + }, + "msg": { + "type": "string", + "example": "ok" + }, + "data": { + "type": "object", + "nullable": true + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": -1 + }, + "msg": { + "type": "string", + "example": "Error message" + } + } + } + }, + "responses": { + "UnauthorizedError": { + "description": "Authentication required or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "no_auth": { + "value": { + "code": -1, + "msg": "No valid authentication provided (user token or API key required)" + } + }, + "invalid_key": { + "value": { + "code": -1, + "msg": "Invalid API key" + } + } + } + } + } + }, + "NotFoundError": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": -1, + "msg": "Resource not found" + } + } + } + }, + "InternalServerError": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": -2, + "msg": "Internal server error" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/pkg/api/http/controller/group.py b/pkg/api/http/controller/group.py index 0665a1d6..8a61cefc 100644 --- a/pkg/api/http/controller/group.py +++ b/pkg/api/http/controller/group.py @@ -34,6 +34,8 @@ class AuthType(enum.Enum): NONE = 'none' USER_TOKEN = 'user-token' + API_KEY = 'api-key' + USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' class RouterGroup(abc.ABC): @@ -87,6 +89,63 @@ class RouterGroup(abc.ABC): except Exception as e: return self.http_status(401, -1, str(e)) + elif auth_type == AuthType.API_KEY: + # get API key from Authorization header or X-API-Key header + api_key = quart.request.headers.get('X-API-Key', '') + if not api_key: + auth_header = quart.request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + api_key = auth_header.replace('Bearer ', '') + + if not api_key: + return self.http_status(401, -1, 'No valid API key provided') + + try: + is_valid = await self.ap.apikey_service.verify_api_key(api_key) + if not is_valid: + return self.http_status(401, -1, 'Invalid API key') + except Exception as e: + return self.http_status(401, -1, str(e)) + + elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: + # Try API key first (check X-API-Key header) + api_key = quart.request.headers.get('X-API-Key', '') + + if api_key: + # API key authentication + try: + is_valid = await self.ap.apikey_service.verify_api_key(api_key) + if not is_valid: + return self.http_status(401, -1, 'Invalid API key') + except Exception as e: + return self.http_status(401, -1, str(e)) + else: + # Try user token authentication (Authorization header) + token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') + + if not token: + return self.http_status(401, -1, 'No valid authentication provided (user token or API key required)') + + try: + user_email = await self.ap.user_service.verify_jwt_token(token) + + # check if this account exists + user = await self.ap.user_service.get_user_by_email(user_email) + if not user: + return self.http_status(401, -1, 'User not found') + + # check if f accepts user_email parameter + if 'user_email' in f.__code__.co_varnames: + kwargs['user_email'] = user_email + except Exception: + # If user token fails, maybe it's an API key in Authorization header + try: + is_valid = await self.ap.apikey_service.verify_api_key(token) + if not is_valid: + return self.http_status(401, -1, 'Invalid authentication credentials') + except Exception as e: + return self.http_status(401, -1, str(e)) + try: return await f(*args, **kwargs) diff --git a/pkg/api/http/controller/groups/apikeys.py b/pkg/api/http/controller/groups/apikeys.py new file mode 100644 index 00000000..f53728bf --- /dev/null +++ b/pkg/api/http/controller/groups/apikeys.py @@ -0,0 +1,43 @@ +import quart + +from .. import group + + +@group.group_class('apikeys', '/api/v1/apikeys') +class ApiKeysRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST']) + async def _() -> str: + if quart.request.method == 'GET': + keys = await self.ap.apikey_service.get_api_keys() + return self.success(data={'keys': keys}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + name = json_data.get('name', '') + description = json_data.get('description', '') + + if not name: + return self.http_status(400, -1, 'Name is required') + + key = await self.ap.apikey_service.create_api_key(name, description) + return self.success(data={'key': key}) + + @self.route('/', methods=['GET', 'PUT', 'DELETE']) + async def _(key_id: int) -> str: + if quart.request.method == 'GET': + key = await self.ap.apikey_service.get_api_key(key_id) + if key is None: + return self.http_status(404, -1, 'API key not found') + return self.success(data={'key': key}) + + elif quart.request.method == 'PUT': + json_data = await quart.request.json + name = json_data.get('name') + description = json_data.get('description') + + await self.ap.apikey_service.update_api_key(key_id, name, description) + return self.success() + + elif quart.request.method == 'DELETE': + await self.ap.apikey_service.delete_api_key(key_id) + return self.success() diff --git a/pkg/api/http/controller/groups/pipelines/pipelines.py b/pkg/api/http/controller/groups/pipelines/pipelines.py index 32b9fff7..8285fa85 100644 --- a/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -8,7 +8,7 @@ from ... import group @group.group_class('pipelines', '/api/v1/pipelines') class PipelinesRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': sort_by = quart.request.args.get('sort_by', 'created_at') @@ -23,11 +23,11 @@ class PipelinesRouterGroup(group.RouterGroup): return self.success(data={'uuid': pipeline_uuid}) - @self.route('/_/metadata', methods=['GET']) + @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()}) - @self.route('/', methods=['GET', 'PUT', 'DELETE']) + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(pipeline_uuid: str) -> str: if quart.request.method == 'GET': pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) @@ -47,7 +47,7 @@ class PipelinesRouterGroup(group.RouterGroup): return self.success() - @self.route('//extensions', methods=['GET', 'PUT']) + @self.route('//extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(pipeline_uuid: str) -> str: if quart.request.method == 'GET': # Get current extensions and available plugins diff --git a/pkg/api/http/controller/groups/platform/bots.py b/pkg/api/http/controller/groups/platform/bots.py index d6250ac4..d943e916 100644 --- a/pkg/api/http/controller/groups/platform/bots.py +++ b/pkg/api/http/controller/groups/platform/bots.py @@ -6,7 +6,7 @@ from ... import group @group.group_class('bots', '/api/v1/platform/bots') class BotsRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'bots': await self.ap.bot_service.get_bots()}) @@ -15,7 +15,7 @@ class BotsRouterGroup(group.RouterGroup): bot_uuid = await self.ap.bot_service.create_bot(json_data) return self.success(data={'uuid': bot_uuid}) - @self.route('/', methods=['GET', 'PUT', 'DELETE']) + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': bot = await self.ap.bot_service.get_bot(bot_uuid) @@ -30,7 +30,7 @@ class BotsRouterGroup(group.RouterGroup): await self.ap.bot_service.delete_bot(bot_uuid) return self.success() - @self.route('//logs', methods=['POST']) + @self.route('//logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: json_data = await quart.request.json from_index = json_data.get('from_index', -1) diff --git a/pkg/api/http/controller/groups/provider/models.py b/pkg/api/http/controller/groups/provider/models.py index 0de0c922..25f16995 100644 --- a/pkg/api/http/controller/groups/provider/models.py +++ b/pkg/api/http/controller/groups/provider/models.py @@ -6,7 +6,7 @@ from ... import group @group.group_class('models/llm', '/api/v1/provider/models/llm') class LLMModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()}) @@ -17,7 +17,7 @@ class LLMModelsRouterGroup(group.RouterGroup): return self.success(data={'uuid': model_uuid}) - @self.route('/', methods=['GET', 'PUT', 'DELETE']) + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: if quart.request.method == 'GET': model = await self.ap.llm_model_service.get_llm_model(model_uuid) @@ -37,7 +37,7 @@ class LLMModelsRouterGroup(group.RouterGroup): return self.success() - @self.route('//test', methods=['POST']) + @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: json_data = await quart.request.json @@ -49,7 +49,7 @@ class LLMModelsRouterGroup(group.RouterGroup): @group.group_class('models/embedding', '/api/v1/provider/models/embedding') class EmbeddingModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: if quart.request.method == 'GET': return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()}) @@ -60,7 +60,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): return self.success(data={'uuid': model_uuid}) - @self.route('/', methods=['GET', 'PUT', 'DELETE']) + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: if quart.request.method == 'GET': model = await self.ap.embedding_models_service.get_embedding_model(model_uuid) @@ -80,7 +80,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup): return self.success() - @self.route('//test', methods=['POST']) + @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(model_uuid: str) -> str: json_data = await quart.request.json diff --git a/pkg/api/http/service/apikey.py b/pkg/api/http/service/apikey.py new file mode 100644 index 00000000..1a8b2892 --- /dev/null +++ b/pkg/api/http/service/apikey.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import secrets +import sqlalchemy + +from ....core import app +from ....entity.persistence import apikey + + +class ApiKeyService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_api_keys(self) -> list[dict]: + """Get all API keys""" + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey)) + + keys = result.all() + return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys] + + async def create_api_key(self, name: str, description: str = '') -> dict: + """Create a new API key""" + # Generate a secure random API key + key = f'lbk_{secrets.token_urlsafe(32)}' + + key_data = {'name': name, 'key': key, 'description': description} + + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data)) + + # Retrieve the created key + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) + ) + created_key = result.first() + + return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key) + + async def get_api_key(self, key_id: int) -> dict | None: + """Get a specific API key by ID""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id) + ) + + key = result.first() + + if key is None: + return None + + return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) + + async def verify_api_key(self, key: str) -> bool: + """Verify if an API key is valid""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key) + ) + + key_obj = result.first() + return key_obj is not None + + async def delete_api_key(self, key_id: int) -> None: + """Delete an API key""" + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id) + ) + + async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None: + """Update an API key's metadata (name, description)""" + update_data = {} + if name is not None: + update_data['name'] = name + if description is not None: + update_data['description'] = description + + if update_data: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data) + ) diff --git a/pkg/core/app.py b/pkg/core/app.py index 62e47b74..9b29fdc7 100644 --- a/pkg/core/app.py +++ b/pkg/core/app.py @@ -23,6 +23,7 @@ from ..api.http.service import pipeline as pipeline_service from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service from ..api.http.service import mcp as mcp_service +from ..api.http.service import apikey as apikey_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -122,6 +123,8 @@ class Application: mcp_service: mcp_service.MCPService = None + apikey_service: apikey_service.ApiKeyService = None + def __init__(self): pass diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index 8df32755..d40ba2ab 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -20,6 +20,7 @@ from ...api.http.service import pipeline as pipeline_service from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service from ...api.http.service import mcp as mcp_service +from ...api.http.service import apikey as apikey_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache @@ -130,5 +131,8 @@ class BuildAppStage(stage.BootingStage): mcp_service_inst = mcp_service.MCPService(ap) ap.mcp_service = mcp_service_inst + apikey_service_inst = apikey_service.ApiKeyService(ap) + ap.apikey_service = apikey_service_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/entity/persistence/apikey.py b/pkg/entity/persistence/apikey.py new file mode 100644 index 00000000..488c0324 --- /dev/null +++ b/pkg/entity/persistence/apikey.py @@ -0,0 +1,21 @@ +import sqlalchemy + +from .base import Base + + +class ApiKey(Base): + """API Key for external service authentication""" + + __tablename__ = 'api_keys' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True) + description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='') + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx b/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx new file mode 100644 index 00000000..e3db9cd9 --- /dev/null +++ b/web/src/app/home/components/api-key-management-dialog/ApiKeyManagementDialog.tsx @@ -0,0 +1,353 @@ +'use client'; + +import * as React from 'react'; +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Copy, Trash2, Plus } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogPortal, + AlertDialogOverlay, +} from '@/components/ui/alert-dialog'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { backendClient } from '@/app/infra/http'; + +interface ApiKey { + id: number; + name: string; + key: string; + description: string; + created_at: string; +} + +interface ApiKeyManagementDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function ApiKeyManagementDialog({ + open, + onOpenChange, +}: ApiKeyManagementDialogProps) { + const { t } = useTranslation(); + const [apiKeys, setApiKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyDescription, setNewKeyDescription] = useState(''); + const [createdKey, setCreatedKey] = useState(null); + const [deleteKeyId, setDeleteKeyId] = useState(null); + + // 清理 body 样式,防止对话框关闭后页面无法交互 + useEffect(() => { + if (!deleteKeyId) { + const cleanup = () => { + document.body.style.removeProperty('pointer-events'); + }; + + cleanup(); + const timer = setTimeout(cleanup, 100); + return () => clearTimeout(timer); + } + }, [deleteKeyId]); + + useEffect(() => { + if (open) { + loadApiKeys(); + } + }, [open]); + + const loadApiKeys = async () => { + setLoading(true); + try { + const response = (await backendClient.get('/api/v1/apikeys')) as { + keys: ApiKey[]; + }; + setApiKeys(response.keys || []); + } catch (error) { + toast.error(`Failed to load API keys: ${error}`); + } finally { + setLoading(false); + } + }; + + const handleCreateApiKey = async () => { + if (!newKeyName.trim()) { + toast.error(t('common.apiKeyNameRequired')); + return; + } + + try { + const response = (await backendClient.post('/api/v1/apikeys', { + name: newKeyName, + description: newKeyDescription, + })) as { key: ApiKey }; + + setCreatedKey(response.key); + toast.success(t('common.apiKeyCreated')); + setNewKeyName(''); + setNewKeyDescription(''); + setShowCreateDialog(false); + loadApiKeys(); + } catch (error) { + toast.error(`Failed to create API key: ${error}`); + } + }; + + const handleDeleteApiKey = async (keyId: number) => { + try { + await backendClient.delete(`/api/v1/apikeys/${keyId}`); + toast.success(t('common.apiKeyDeleted')); + loadApiKeys(); + setDeleteKeyId(null); + } catch (error) { + toast.error(`Failed to delete API key: ${error}`); + } + }; + + const handleCopyKey = (key: string) => { + navigator.clipboard.writeText(key); + toast.success(t('common.apiKeyCopied')); + }; + + const maskApiKey = (key: string) => { + if (key.length <= 8) return key; + return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`; + }; + + return ( + <> + { + // 如果删除确认框是打开的,不允许关闭主对话框 + if (!newOpen && deleteKeyId) { + return; + } + onOpenChange(newOpen); + }} + > + + + {t('common.manageApiKeys')} + {t('common.apiKeyHint')} + + +
+
+ +
+ + {loading ? ( +
+ {t('common.loading')} +
+ ) : apiKeys.length === 0 ? ( +
+ {t('common.noApiKeys')} +
+ ) : ( +
+ + + + {t('common.name')} + {t('common.apiKeyValue')} + + {t('common.actions')} + + + + + {apiKeys.map((key) => ( + + +
+
{key.name}
+ {key.description && ( +
+ {key.description} +
+ )} +
+
+ + + {maskApiKey(key.key)} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + + + +
+
+ + {/* Create API Key Dialog */} + + + + {t('common.createApiKey')} + +
+
+ + setNewKeyName(e.target.value)} + placeholder={t('common.name')} + className="mt-1" + /> +
+
+ + setNewKeyDescription(e.target.value)} + placeholder={t('common.description')} + className="mt-1" + /> +
+
+ + + + +
+
+ + {/* Show Created Key Dialog */} + setCreatedKey(null)}> + + + {t('common.apiKeyCreated')} + + {t('common.apiKeyCreatedMessage')} + + +
+
+ +
+ + +
+
+
+ + + +
+
+ + {/* Delete Confirmation Dialog */} + + + setDeleteKeyId(null)} + /> + setDeleteKeyId(null)} + > + + {t('common.confirmDelete')} + + {t('common.apiKeyDeleteConfirm')} + + + + setDeleteKeyId(null)}> + {t('common.cancel')} + + deleteKeyId && handleDeleteApiKey(deleteKeyId)} + > + {t('common.delete')} + + + + + + + ); +} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 76f232e4..444268b4 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -25,6 +25,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { LanguageSelector } from '@/components/ui/language-selector'; import { Badge } from '@/components/ui/badge'; import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog'; +import ApiKeyManagementDialog from '@/app/home/components/api-key-management-dialog/ApiKeyManagementDialog'; // TODO 侧边导航栏要加动画 export default function HomeSidebar({ @@ -45,6 +46,7 @@ export default function HomeSidebar({ const { t } = useTranslation(); const [popoverOpen, setPopoverOpen] = useState(false); const [passwordChangeOpen, setPasswordChangeOpen] = useState(false); + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false); const [starCount, setStarCount] = useState(null); @@ -220,6 +222,23 @@ export default function HomeSidebar({ name={t('common.helpDocs')} /> + { + setApiKeyDialogOpen(true); + }} + isSelected={false} + icon={ + + + + } + name={t('common.apiKeys')} + /> + { @@ -326,6 +345,10 @@ export default function HomeSidebar({ open={passwordChangeOpen} onOpenChange={setPasswordChangeOpen} /> + ); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 496ca87c..a0747120 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -58,6 +58,23 @@ const enUS = { changePasswordSuccess: 'Password changed successfully', changePasswordFailed: 'Failed to change password, please check your current password', + apiKeys: 'API Keys', + manageApiKeys: 'Manage API Keys', + createApiKey: 'Create API Key', + apiKeyName: 'API Key Name', + apiKeyDescription: 'API Key Description', + apiKeyValue: 'API Key Value', + apiKeyCreated: 'API key created successfully', + apiKeyDeleted: 'API key deleted successfully', + apiKeyDeleteConfirm: 'Are you sure you want to delete this API key?', + apiKeyNameRequired: 'API key name is required', + copyApiKey: 'Copy API Key', + apiKeyCopied: 'API key copied to clipboard', + noApiKeys: 'No API keys configured', + apiKeyHint: + 'API keys allow external systems to access LangBot Service APIs', + actions: 'Actions', + apiKeyCreatedMessage: 'Please copy this API key.', }, notFound: { title: 'Page not found', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index fb4513eb..efc291ef 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -59,6 +59,23 @@ const jaJP = { changePasswordSuccess: 'パスワードの変更に成功しました', changePasswordFailed: 'パスワードの変更に失敗しました。現在のパスワードを確認してください', + apiKeys: 'API キー', + manageApiKeys: 'API キーの管理', + createApiKey: 'API キーを作成', + apiKeyName: 'API キー名', + apiKeyDescription: 'API キーの説明', + apiKeyValue: 'API キー値', + apiKeyCreated: 'API キーの作成に成功しました', + apiKeyDeleted: 'API キーの削除に成功しました', + apiKeyDeleteConfirm: 'この API キーを削除してもよろしいですか?', + apiKeyNameRequired: 'API キー名は必須です', + copyApiKey: 'API キーをコピー', + apiKeyCopied: 'API キーをクリップボードにコピーしました', + noApiKeys: 'API キーが設定されていません', + apiKeyHint: + 'API キーを使用すると、外部システムが LangBot Service API にアクセスできます', + actions: 'アクション', + apiKeyCreatedMessage: 'この API キーをコピーしてください。', }, notFound: { title: 'ページが見つかりません', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 17444438..88562d32 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -57,6 +57,22 @@ const zhHans = { passwordsDoNotMatch: '两次输入的密码不一致', changePasswordSuccess: '密码修改成功', changePasswordFailed: '密码修改失败,请检查当前密码是否正确', + apiKeys: 'API 密钥', + manageApiKeys: '管理 API 密钥', + createApiKey: '创建 API 密钥', + apiKeyName: 'API 密钥名称', + apiKeyDescription: 'API 密钥描述', + apiKeyValue: 'API 密钥值', + apiKeyCreated: 'API 密钥创建成功', + apiKeyDeleted: 'API 密钥删除成功', + apiKeyDeleteConfirm: '确定要删除此 API 密钥吗?', + apiKeyNameRequired: 'API 密钥名称不能为空', + copyApiKey: '复制 API 密钥', + apiKeyCopied: 'API 密钥已复制到剪贴板', + noApiKeys: '暂无 API 密钥', + apiKeyHint: 'API 密钥允许外部系统访问 LangBot 的 Service API', + actions: '操作', + apiKeyCreatedMessage: '请复制此 API 密钥。', }, notFound: { title: '页面不存在', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index b27a4511..ff587a8e 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -57,6 +57,22 @@ const zhHant = { passwordsDoNotMatch: '兩次輸入的密碼不一致', changePasswordSuccess: '密碼修改成功', changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確', + apiKeys: 'API 金鑰', + manageApiKeys: '管理 API 金鑰', + createApiKey: '建立 API 金鑰', + apiKeyName: 'API 金鑰名稱', + apiKeyDescription: 'API 金鑰描述', + apiKeyValue: 'API 金鑰值', + apiKeyCreated: 'API 金鑰建立成功', + apiKeyDeleted: 'API 金鑰刪除成功', + apiKeyDeleteConfirm: '確定要刪除此 API 金鑰嗎?', + apiKeyNameRequired: 'API 金鑰名稱不能為空', + copyApiKey: '複製 API 金鑰', + apiKeyCopied: 'API 金鑰已複製到剪貼簿', + noApiKeys: '暫無 API 金鑰', + apiKeyHint: 'API 金鑰允許外部系統訪問 LangBot 的 Service API', + actions: '操作', + apiKeyCreatedMessage: '請複製此 API 金鑰。', }, notFound: { title: '頁面不存在',