feat: add Space integration for user authentication and model management with OAuth support

This commit is contained in:
Junyan Qin
2025-12-26 00:35:47 +08:00
parent 7479545339
commit 8caab43b00
27 changed files with 5214 additions and 6156 deletions

View 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)}')

View File

@@ -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,
}
)

View 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}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'