mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 22:36:02 +00:00
feat: add Space integration for user authentication and model management with OAuth support
This commit is contained in:
52
src/langbot/pkg/api/http/controller/groups/space.py
Normal file
52
src/langbot/pkg/api/http/controller/groups/space.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
DEFAULT_SPACE_URL = 'https://space.langbot.app'
|
||||
|
||||
|
||||
@group.group_class('space', '/api/v1/space')
|
||||
class SpaceRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/models/sync', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Sync models from Space MaaS to local database"""
|
||||
json_data = await quart.request.json or {}
|
||||
space_url = json_data.get('space_url', DEFAULT_SPACE_URL)
|
||||
|
||||
try:
|
||||
stats = await self.ap.space_models_service.sync_models_from_space(user_email, space_url)
|
||||
return self.success(data=stats)
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
except Exception as e:
|
||||
return self.fail(2, f'Failed to sync models: {str(e)}')
|
||||
|
||||
@self.route('/models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Get all synced Space models"""
|
||||
if quart.request.method == 'GET':
|
||||
try:
|
||||
models = await self.ap.space_models_service.get_space_models()
|
||||
return self.success(data=models)
|
||||
except Exception as e:
|
||||
return self.fail(1, f'Failed to get Space models: {str(e)}')
|
||||
elif quart.request.method == 'DELETE':
|
||||
try:
|
||||
stats = await self.ap.space_models_service.delete_space_models()
|
||||
return self.success(data=stats)
|
||||
except Exception as e:
|
||||
return self.fail(1, f'Failed to delete Space models: {str(e)}')
|
||||
|
||||
@self.route('/models/available', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Get available models from Space (preview before sync)"""
|
||||
try:
|
||||
space_url = quart.request.args.get('space_url', DEFAULT_SPACE_URL)
|
||||
models_data = await self.ap.space_models_service.fetch_space_models(space_url)
|
||||
return self.success(data=models_data)
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
except Exception as e:
|
||||
return self.fail(2, f'Failed to fetch available models: {str(e)}')
|
||||
@@ -33,6 +33,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
|
||||
except argon2.exceptions.VerifyMismatchError:
|
||||
return self.fail(1, 'Invalid username or password')
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
return self.success(data={'token': token})
|
||||
|
||||
@@ -71,9 +73,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
# Check if password change is allowed
|
||||
allow_change_password = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_change_password', True
|
||||
)
|
||||
allow_change_password = self.ap.instance_config.data.get('system', {}).get('allow_change_password', True)
|
||||
if not allow_change_password:
|
||||
return self.http_status(403, -1, 'Password change is disabled')
|
||||
|
||||
@@ -90,3 +90,67 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
return self.success(data={'user': user_email})
|
||||
|
||||
# Space OAuth endpoints (redirect flow)
|
||||
|
||||
@self.route('/space/authorize-url', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Get Space OAuth authorization URL for redirect"""
|
||||
redirect_uri = quart.request.args.get('redirect_uri', '')
|
||||
state = quart.request.args.get('state', '')
|
||||
|
||||
if not redirect_uri:
|
||||
return self.fail(1, 'Missing redirect_uri parameter')
|
||||
|
||||
try:
|
||||
authorize_url = self.ap.user_service.get_space_oauth_authorize_url(redirect_uri, state)
|
||||
return self.success(data={'authorize_url': authorize_url})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Handle OAuth callback - exchange code for tokens and authenticate"""
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
|
||||
if not code:
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
|
||||
try:
|
||||
# Exchange code for tokens
|
||||
token_data = await self.ap.user_service.exchange_space_oauth_code(code)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
# Authenticate and create/update local user
|
||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(access_token, refresh_token)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'token': jwt_token,
|
||||
'user': user_obj.user,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
except Exception as e:
|
||||
return self.fail(2, f'OAuth callback failed: {str(e)}')
|
||||
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Get current user information including account type"""
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'user': user_obj.user,
|
||||
'account_type': user_obj.account_type,
|
||||
}
|
||||
)
|
||||
|
||||
247
src/langbot/pkg/api/http/service/space_models.py
Normal file
247
src/langbot/pkg/api/http/service/space_models.py
Normal file
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import uuid as uuid_lib
|
||||
import aiohttp
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....entity.persistence import user as persistence_user
|
||||
|
||||
|
||||
DEFAULT_SPACE_URL = 'http://localhost:8383'
|
||||
|
||||
# Space's base URL for model API requests (used for requester_config)
|
||||
SPACE_API_BASE_URL = 'http://localhost:8383'
|
||||
|
||||
|
||||
class SpaceModelsService:
|
||||
"""Service for syncing models from Space MaaS"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_space_user_info(self, user_email: str) -> persistence_user.User | None:
|
||||
"""Get Space user info for sync operations"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_user.User).where(persistence_user.User.user == user_email)
|
||||
)
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list else None
|
||||
|
||||
async def fetch_space_models(self, space_url: str = DEFAULT_SPACE_URL) -> typing.Dict:
|
||||
"""Fetch available models from Space API"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to fetch models from Space: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to fetch models from Space: {data.get("msg")}')
|
||||
return data.get('data', {})
|
||||
|
||||
async def sync_models_from_space(
|
||||
self, user_email: str, space_url: str = DEFAULT_SPACE_URL
|
||||
) -> typing.Dict[str, typing.Any]:
|
||||
"""
|
||||
Sync models from Space to local database.
|
||||
Returns statistics about the sync operation.
|
||||
"""
|
||||
# Get user info for API key
|
||||
user_obj = await self.get_space_user_info(user_email)
|
||||
if user_obj is None:
|
||||
raise ValueError('User not found')
|
||||
|
||||
if user_obj.account_type != 'space':
|
||||
raise ValueError('User is not a Space account')
|
||||
|
||||
if not user_obj.space_api_key:
|
||||
raise ValueError('User does not have a Space API key configured')
|
||||
|
||||
# Fetch models from Space
|
||||
models_data = await self.fetch_space_models(space_url)
|
||||
space_models = models_data.get('models', [])
|
||||
|
||||
# Get existing Space models in local database
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
|
||||
)
|
||||
existing_space_models = {m.space_model_id: m for m in result.all()}
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.source == 'space'
|
||||
)
|
||||
)
|
||||
existing_space_embedding_models = {m.space_model_id: m for m in result.all()}
|
||||
|
||||
stats = {'created_llm': 0, 'updated_llm': 0, 'created_embedding': 0, 'updated_embedding': 0, 'skipped': 0}
|
||||
|
||||
for model in space_models:
|
||||
model_id = model.get('model_id')
|
||||
category = model.get('category', '')
|
||||
|
||||
if not model_id:
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
if category == 'embedding':
|
||||
# Handle embedding model
|
||||
await self._sync_embedding_model(model, user_obj.space_api_key, existing_space_embedding_models, stats)
|
||||
else:
|
||||
# Handle LLM model (chat, completion, etc.)
|
||||
await self._sync_llm_model(model, user_obj.space_api_key, existing_space_models, stats)
|
||||
|
||||
return stats
|
||||
|
||||
async def _sync_llm_model(
|
||||
self,
|
||||
model: typing.Dict,
|
||||
api_key: str,
|
||||
existing_models: typing.Dict[str, persistence_model.LLMModel],
|
||||
stats: typing.Dict,
|
||||
) -> None:
|
||||
"""Sync a single LLM model from Space"""
|
||||
model_id = model.get('model_id')
|
||||
display_name = model.get('display_name', {})
|
||||
name = display_name.get('zh_Hans', display_name.get('en_US', model_id))
|
||||
description_obj = model.get('description', {})
|
||||
description = description_obj.get('zh_Hans', description_obj.get('en_US', '')) if description_obj else ''
|
||||
|
||||
# Infer abilities from model capabilities
|
||||
abilities = []
|
||||
supported_endpoints = model.get('supported_endpoints', [])
|
||||
if 'vision' in str(supported_endpoints).lower() or 'vision' in model_id.lower():
|
||||
abilities.append('vision')
|
||||
if 'function' in str(supported_endpoints).lower() or 'tool' in str(supported_endpoints).lower():
|
||||
abilities.append('function_call')
|
||||
|
||||
model_data = {
|
||||
'name': name,
|
||||
'description': description[:255] if description else 'Model from Space MaaS',
|
||||
'requester': 'openai-chat-completions', # Space uses OpenAI-compatible API
|
||||
'requester_config': {
|
||||
'base-url': SPACE_API_BASE_URL,
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
},
|
||||
'api_keys': [api_key],
|
||||
'abilities': abilities,
|
||||
'extra_args': {'model': model_id},
|
||||
'source': 'space',
|
||||
'space_model_id': model_id,
|
||||
}
|
||||
|
||||
if model_id in existing_models:
|
||||
# Update existing model
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.LLMModel)
|
||||
.where(persistence_model.LLMModel.space_model_id == model_id)
|
||||
.values(**model_data)
|
||||
)
|
||||
stats['updated_llm'] += 1
|
||||
else:
|
||||
# Create new model
|
||||
model_data['uuid'] = str(uuid_lib.uuid4())
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.LLMModel).values(**model_data)
|
||||
)
|
||||
stats['created_llm'] += 1
|
||||
|
||||
async def _sync_embedding_model(
|
||||
self,
|
||||
model: typing.Dict,
|
||||
api_key: str,
|
||||
existing_models: typing.Dict[str, persistence_model.EmbeddingModel],
|
||||
stats: typing.Dict,
|
||||
) -> None:
|
||||
"""Sync a single embedding model from Space"""
|
||||
model_id = model.get('model_id')
|
||||
display_name = model.get('display_name', {})
|
||||
name = display_name.get('zh_Hans', display_name.get('en_US', model_id))
|
||||
description_obj = model.get('description', {})
|
||||
description = description_obj.get('zh_Hans', description_obj.get('en_US', '')) if description_obj else ''
|
||||
|
||||
model_data = {
|
||||
'name': name,
|
||||
'description': description[:255] if description else 'Embedding model from Space MaaS',
|
||||
'requester': 'openai-embedding', # Space uses OpenAI-compatible API
|
||||
'requester_config': {
|
||||
'base-url': SPACE_API_BASE_URL,
|
||||
'args': {},
|
||||
'timeout': 120,
|
||||
},
|
||||
'api_keys': [api_key],
|
||||
'extra_args': {'model': model_id},
|
||||
'source': 'space',
|
||||
'space_model_id': model_id,
|
||||
}
|
||||
|
||||
if model_id in existing_models:
|
||||
# Update existing model
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_model.EmbeddingModel)
|
||||
.where(persistence_model.EmbeddingModel.space_model_id == model_id)
|
||||
.values(**model_data)
|
||||
)
|
||||
stats['updated_embedding'] += 1
|
||||
else:
|
||||
# Create new model
|
||||
model_data['uuid'] = str(uuid_lib.uuid4())
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
|
||||
)
|
||||
stats['created_embedding'] += 1
|
||||
|
||||
async def get_space_models(self) -> typing.Dict[str, typing.List]:
|
||||
"""Get all synced Space models"""
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
|
||||
)
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.source == 'space'
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
'llm_models': [
|
||||
self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in llm_result.all()
|
||||
],
|
||||
'embedding_models': [
|
||||
self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m)
|
||||
for m in embedding_result.all()
|
||||
],
|
||||
}
|
||||
|
||||
async def delete_space_models(self) -> typing.Dict[str, int]:
|
||||
"""Delete all synced Space models"""
|
||||
# Remove from model manager first
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
|
||||
)
|
||||
for model in llm_result.all():
|
||||
await self.ap.model_mgr.remove_llm_model(model.uuid)
|
||||
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.source == 'space'
|
||||
)
|
||||
)
|
||||
for model in embedding_result.all():
|
||||
await self.ap.model_mgr.remove_embedding_model(model.uuid)
|
||||
|
||||
# Delete from database
|
||||
llm_delete = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.source == 'space')
|
||||
)
|
||||
embedding_delete = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.source == 'space'
|
||||
)
|
||||
)
|
||||
|
||||
return {'deleted_llm': llm_delete.rowcount, 'deleted_embedding': embedding_delete.rowcount}
|
||||
@@ -4,6 +4,8 @@ import sqlalchemy
|
||||
import argon2
|
||||
import jwt
|
||||
import datetime
|
||||
import aiohttp
|
||||
import typing
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
@@ -16,6 +18,15 @@ class UserService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def _get_space_config(self) -> typing.Dict[str, str]:
|
||||
"""Get Space configuration from config file"""
|
||||
space_config = self.ap.instance_config.data.get('space', {})
|
||||
return {
|
||||
'url': space_config.get('url', 'https://space.langbot.app'),
|
||||
'api_url': space_config.get('api_url', 'https://api.langbot.app'),
|
||||
'oauth_authorize_url': space_config.get('oauth_authorize_url', 'https://space.langbot.app/auth/authorize'),
|
||||
}
|
||||
|
||||
async def is_initialized(self) -> bool:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
|
||||
|
||||
@@ -28,7 +39,7 @@ class UserService:
|
||||
hashed_password = ph.hash(password)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password)
|
||||
sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
|
||||
)
|
||||
|
||||
async def get_user_by_email(self, user_email: str) -> user.User | None:
|
||||
@@ -39,6 +50,15 @@ class UserService:
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list is not None and len(result_list) > 0 else None
|
||||
|
||||
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
|
||||
"""Get user by Space account UUID"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid)
|
||||
)
|
||||
|
||||
result_list = result.all()
|
||||
return result_list[0] if result_list is not None and len(result_list) > 0 else None
|
||||
|
||||
async def authenticate(self, user_email: str, password: str) -> str | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(user.User).where(user.User.user == user_email)
|
||||
@@ -51,6 +71,10 @@ class UserService:
|
||||
|
||||
user_obj = result_list[0]
|
||||
|
||||
# Check if this is a Space account
|
||||
if user_obj.account_type == 'space':
|
||||
raise ValueError('请使用 Space 账户登录')
|
||||
|
||||
ph = argon2.PasswordHasher()
|
||||
|
||||
ph.verify(user_obj.password, password)
|
||||
@@ -90,6 +114,10 @@ class UserService:
|
||||
if user_obj is None:
|
||||
raise ValueError('User not found')
|
||||
|
||||
# Space accounts cannot change password locally
|
||||
if user_obj.account_type == 'space':
|
||||
raise ValueError('Space account cannot change password locally')
|
||||
|
||||
ph.verify(user_obj.password, current_password)
|
||||
|
||||
hashed_password = ph.hash(new_password)
|
||||
@@ -97,3 +125,139 @@ class UserService:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
||||
)
|
||||
|
||||
# Space OAuth methods (redirect flow)
|
||||
|
||||
def get_space_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
|
||||
"""Get the Space OAuth authorization URL for redirect"""
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
|
||||
# Build the authorization URL with redirect_uri
|
||||
params = f'redirect_uri={redirect_uri}'
|
||||
if state:
|
||||
params += f'&state={state}'
|
||||
|
||||
return f'{authorize_url}?{params}'
|
||||
|
||||
async def exchange_space_oauth_code(self, code: str) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
space_config = self._get_space_config()
|
||||
space_url = space_config['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f'{space_url}/api/v1/accounts/oauth/token',
|
||||
json={'code': code},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}')
|
||||
return data.get('data', {})
|
||||
|
||||
async def get_space_user_info(self, access_token: str) -> typing.Dict:
|
||||
"""Get user info from Space using access token"""
|
||||
space_config = self._get_space_config()
|
||||
space_url = space_config['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to get user info: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to get user info: {data.get("msg")}')
|
||||
return data.get('data', {})
|
||||
|
||||
async def refresh_space_token(self, refresh_token: str) -> typing.Dict:
|
||||
"""Refresh Space access token"""
|
||||
space_config = self._get_space_config()
|
||||
space_url = space_config['url']
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f'Failed to refresh token: {await response.text()}')
|
||||
data = await response.json()
|
||||
if data.get('code') != 0:
|
||||
raise ValueError(f'Failed to refresh token: {data.get("msg")}')
|
||||
return data.get('data', {})
|
||||
|
||||
async def create_or_update_space_user(
|
||||
self,
|
||||
space_account_uuid: str,
|
||||
email: str,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
api_key: str,
|
||||
) -> user.User:
|
||||
"""Create or update a Space user account"""
|
||||
# Check if user with this Space UUID already exists
|
||||
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
if existing_user:
|
||||
# Update existing user's tokens
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.space_account_uuid == space_account_uuid)
|
||||
.values(
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
)
|
||||
)
|
||||
return await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
# Check if user with same email exists as local account
|
||||
existing_email_user = await self.get_user_by_email(email)
|
||||
if existing_email_user and existing_email_user.account_type == 'local':
|
||||
raise ValueError('A local account with this email already exists. Please use a different email.')
|
||||
|
||||
# Create new Space user
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(user.User).values(
|
||||
user=email,
|
||||
password='', # Space users don't have local password
|
||||
account_type='space',
|
||||
space_account_uuid=space_account_uuid,
|
||||
space_access_token=access_token,
|
||||
space_refresh_token=refresh_token,
|
||||
space_api_key=api_key,
|
||||
)
|
||||
)
|
||||
|
||||
return await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
|
||||
async def authenticate_space_user(self, access_token: str, refresh_token: str) -> typing.Tuple[str, user.User]:
|
||||
"""Authenticate with Space and return JWT token"""
|
||||
# Get user info from Space
|
||||
user_info = await self.get_space_user_info(access_token)
|
||||
|
||||
account = user_info.get('account', {})
|
||||
api_key = user_info.get('api_key', '')
|
||||
|
||||
space_account_uuid = account.get('uuid')
|
||||
email = account.get('email')
|
||||
|
||||
if not space_account_uuid or not email:
|
||||
raise ValueError('Invalid Space user info')
|
||||
|
||||
# Create or update Space user in local database
|
||||
user_obj = await self.create_or_update_space_user(
|
||||
space_account_uuid=space_account_uuid,
|
||||
email=email,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Generate JWT token
|
||||
jwt_token = await self.generate_jwt_token(email)
|
||||
|
||||
return jwt_token, user_obj
|
||||
|
||||
@@ -27,6 +27,7 @@ from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import external_kb as external_kb_service
|
||||
from ..api.http.service import space_models as space_models_service
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
@@ -132,6 +133,8 @@ class Application:
|
||||
|
||||
webhook_service: webhook_service.WebhookService = None
|
||||
|
||||
space_models_service: space_models_service.SpaceModelsService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import external_kb as external_kb_service
|
||||
from ...api.http.service import space_models as space_models_service
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -135,6 +136,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||
ap.webhook_service = webhook_service_inst
|
||||
|
||||
space_models_service_inst = space_models_service.SpaceModelsService(ap)
|
||||
ap.space_models_service = space_models_service_inst
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
@@ -16,6 +16,10 @@ class LLMModel(Base):
|
||||
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
# Source tracking for Space integration: 'local' or 'space'
|
||||
source = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
|
||||
# Space model ID for synced models (used to track and update synced models)
|
||||
space_model_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
@@ -37,6 +41,10 @@ class EmbeddingModel(Base):
|
||||
requester_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
api_keys = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||
# Source tracking for Space integration: 'local' or 'space'
|
||||
source = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
|
||||
# Space model ID for synced models (used to track and update synced models)
|
||||
space_model_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -9,6 +9,16 @@ class User(Base):
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
|
||||
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
|
||||
# Account type: 'local' (default) or 'space'
|
||||
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
|
||||
|
||||
# Space account fields (nullable, only used when account_type='space')
|
||||
space_account_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
space_access_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
space_refresh_token = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
space_api_key = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(14)
|
||||
class DBMigrateSpaceAccountSupport(migration.DBMigration):
|
||||
"""Add Space account support fields to users table"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# Get all column names from the users table
|
||||
columns = []
|
||||
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';")
|
||||
)
|
||||
all_result = result.fetchall()
|
||||
columns = [row[0] for row in all_result]
|
||||
else:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);'))
|
||||
all_result = result.fetchall()
|
||||
columns = [row[1] for row in all_result]
|
||||
|
||||
# Add account_type column
|
||||
if 'account_type' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL")
|
||||
)
|
||||
|
||||
# Add space_account_uuid column
|
||||
if 'space_account_uuid' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)')
|
||||
)
|
||||
|
||||
# Add space_access_token column
|
||||
if 'space_access_token' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT')
|
||||
)
|
||||
|
||||
# Add space_refresh_token column
|
||||
if 'space_refresh_token' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT')
|
||||
)
|
||||
|
||||
# Add space_api_key column
|
||||
if 'space_api_key' not in columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)')
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -0,0 +1,78 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(15)
|
||||
class DBMigrateModelSourceTracking(migration.DBMigration):
|
||||
"""Add source tracking fields to models tables for Space integration"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# Add source column to llm_models table
|
||||
llm_columns = await self._get_columns('llm_models')
|
||||
|
||||
if 'source' not in llm_columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("ALTER TABLE llm_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL")
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("ALTER TABLE llm_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL")
|
||||
)
|
||||
|
||||
if 'space_model_id' not in llm_columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN space_model_id VARCHAR(255)')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN space_model_id VARCHAR(255)')
|
||||
)
|
||||
|
||||
# Add source column to embedding_models table
|
||||
embedding_columns = await self._get_columns('embedding_models')
|
||||
|
||||
if 'source' not in embedding_columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
"ALTER TABLE embedding_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL"
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
"ALTER TABLE embedding_models ADD COLUMN source VARCHAR(32) DEFAULT 'local' NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
if 'space_model_id' not in embedding_columns:
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN space_model_id VARCHAR(255)')
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN space_model_id VARCHAR(255)')
|
||||
)
|
||||
|
||||
async def _get_columns(self, table_name: str) -> list:
|
||||
"""Get column names for a table"""
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';"
|
||||
)
|
||||
)
|
||||
all_result = result.fetchall()
|
||||
return [row[0] for row in all_result]
|
||||
else:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
|
||||
all_result = result.fetchall()
|
||||
return [row[1] for row in all_result]
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
pass
|
||||
@@ -2,7 +2,7 @@ import langbot
|
||||
|
||||
semantic_version = f'v{langbot.__version__}'
|
||||
|
||||
required_database_version = 13
|
||||
required_database_version = 15
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -71,3 +71,10 @@ plugin:
|
||||
enable_marketplace: true
|
||||
cloud_service_url: 'https://space.langbot.app'
|
||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||
space:
|
||||
# Space service URL for OAuth and API
|
||||
url: 'https://space.langbot.app'
|
||||
# Space API URL for model requests (MaaS)
|
||||
api_url: 'https://api.langbot.app'
|
||||
# OAuth authorization page URL (user will be redirected here)
|
||||
oauth_authorize_url: 'https://space.langbot.app/auth/authorize'
|
||||
|
||||
Reference in New Issue
Block a user