chore: merge master into dev/4.11.x

This commit is contained in:
Junyan Qin
2026-08-14 16:04:52 +08:00
121 changed files with 9173 additions and 5846 deletions
@@ -15,7 +15,6 @@ import posixpath
import sqlalchemy
from .....core import taskmgr
from .....core.task_boundary import run_in_workspace_uow
from .....entity.persistence import plugin as persistence_plugin
from ...authz import Permission
from ...context import ExecutionContext, RequestContext
@@ -311,11 +310,13 @@ class PluginsRouterGroup(group.RouterGroup):
):
"""Revalidate a captured task context immediately before Runtime I/O."""
await run_in_workspace_uow(
self.ap,
execution_context.workspace_uuid,
lambda: self.ap.plugin_connector.require_workspace_context(execution_context),
)
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
if callable(tenant_scope):
async with tenant_scope(execution_context.workspace_uuid):
await self.ap.plugin_connector.require_workspace_context(execution_context)
return await operation()
await self.ap.plugin_connector.require_workspace_context(execution_context)
return await operation()
async def _require_authenticated_plugin_runtime_context(
@@ -392,17 +393,27 @@ class PluginsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
"""Get plugin debug information including debug URL and key"""
await self._require_authenticated_plugin_runtime_context(request_context)
debug_info = await self.ap.plugin_connector.get_debug_info()
execution_context = await self._require_authenticated_plugin_runtime_context(request_context)
debug_info = await self.ap.plugin_connector.get_debug_info(execution_context)
# Get debug URL from config
plugin_config = self.ap.instance_config.data.get('plugin', {})
debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
debug_url = plugin_config.get(
'display_plugin_debug_url',
'ws://localhost:5401/plugin/debug/ws',
)
parsed_debug_url = urlparse(debug_url)
if parsed_debug_url.scheme in {'http', 'https'}:
debug_url = parsed_debug_url._replace(
scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws',
path=parsed_debug_url.path or '/plugin/debug/ws',
).geturl()
return self.success(
data={
'debug_url': debug_url,
'plugin_debug_key': debug_info.get('plugin_debug_key', ''),
'expires_at': debug_info.get('expires_at', ''),
}
)
@@ -1,6 +1,7 @@
import quart
import argon2
import asyncio
import datetime
import uuid
from urllib.parse import parse_qs, urlsplit
@@ -13,13 +14,6 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
@staticmethod
def _origin(value: str) -> tuple[str, str, int | None] | None:
parsed = urlsplit(value)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
return None
return parsed.scheme, parsed.hostname.casefold(), parsed.port
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
parsed = urlsplit(redirect_uri)
if (
@@ -37,17 +31,8 @@ class UserRouterGroup(group.RouterGroup):
if query != {'mode': ['bind']}:
raise ValueError('Invalid Space binding redirect_uri')
elif query:
raise ValueError('Invalid Space login redirect_uri')
raise ValueError('Invalid LangBot Account login redirect_uri')
redirect_origin = self._origin(redirect_uri)
api_config = self.ap.instance_config.data.get('api', {})
trusted_origins = {
self._origin(str(api_config.get(config_key, '') or '').strip())
for config_key in ('webui_url', 'webhook_prefix')
}
trusted_origins.discard(None)
if redirect_origin not in trusted_origins:
raise ValueError('Untrusted redirect_uri origin')
return redirect_uri
async def initialize(self) -> None:
@@ -218,7 +203,22 @@ class UserRouterGroup(group.RouterGroup):
try:
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
launch_workspace_uuid = consumed_state.launch_workspace_uuid
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
workspace_created_ats: dict[str, int] = {}
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
binding = await self.ap.workspace_service.get_execution_binding()
workspace_uuids = [binding.workspace_uuid]
workspace_created_at = binding.workspace_created_at
if workspace_created_at is not None:
if workspace_created_at.tzinfo is None:
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
token_data = await self.ap.space_service.exchange_oauth_code(
code,
workspace_uuids,
workspace_created_ats,
)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
@@ -231,7 +231,6 @@ class UserRouterGroup(group.RouterGroup):
access_token, refresh_token, expires_in
)
launch_workspace_uuid = consumed_state.launch_workspace_uuid
if launch_workspace_uuid:
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
@@ -401,7 +400,7 @@ class UserRouterGroup(group.RouterGroup):
'Bind the LangBot Account with the same email as this local Account',
)
except ValueError:
return self.http_status(400, -1, 'Space account binding failed')
return self.http_status(400, -1, 'LangBot Account binding failed')
except Exception:
raise
+71 -16
View File
@@ -10,6 +10,7 @@ from ....core import app
from ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline
from ....provider.modelmgr import requester as model_requester
from ....provider.modelmgr import reasoning as model_reasoning
from ....workspace.errors import WorkspaceNotFoundError
from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
@@ -55,6 +56,53 @@ def _redact_model_secrets(model_data: dict) -> dict:
return redacted
def _normalize_llm_reasoning(model_data: dict) -> None:
model_data['reasoning_config'] = model_reasoning.validate_reasoning_config(
model_data.get('reasoning_config'),
model_data.get('abilities'),
model_data.get('extra_args'),
)
def _validate_llm_reasoning_capability(
model_entity: persistence_model.LLMModel,
runtime_provider: model_requester.RuntimeProvider,
) -> None:
config = model_reasoning.normalize_reasoning_config(model_entity.reasoning_config)
if config['level'] == 'provider_default':
return
runtime_model = model_requester.RuntimeLLMModel(
execution_context=runtime_provider.execution_context,
model_entity=model_entity,
provider=runtime_provider,
)
capabilities = runtime_provider.requester.get_reasoning_capabilities(runtime_model)
model_reasoning.validate_reasoning_capabilities(config, capabilities, model_entity.name)
def _reasoning_capabilities(ap: app.Application, model: persistence_model.LLMModel) -> dict:
model_mgr = getattr(ap, 'model_mgr', None)
runtime_models = getattr(model_mgr, 'llm_model_dict', {}) if model_mgr is not None else {}
for runtime_model in runtime_models.values():
if (
runtime_model.model_entity.uuid == model.uuid
and runtime_model.model_entity.workspace_uuid == model.workspace_uuid
):
return runtime_model.provider.requester.get_reasoning_capabilities(runtime_model)
return model_reasoning.default_reasoning_capabilities(
supported='reasoning' in (model.abilities or []),
source='manual' if 'reasoning' in (model.abilities or []) else 'unknown',
)
def _serialize_llm_model(ap: app.Application, model: persistence_model.LLMModel) -> dict:
model_dict = ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
model_dict['reasoning_config'] = model_reasoning.normalize_reasoning_config(model_dict.get('reasoning_config'))
model_dict['reasoning_capabilities'] = _reasoning_capabilities(ap, model)
return model_dict
async def _validate_provider_supports(
ap: app.Application,
context: TenantContext,
@@ -165,7 +213,7 @@ class LLMModelsService:
models_list = []
for model in models:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
model_dict = _serialize_llm_model(self.ap, model)
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
@@ -196,7 +244,7 @@ class LLMModelsService:
)
)
models = result.all()
serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
serialized = [_serialize_llm_model(self.ap, model) for model in models]
return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_llm_model(
@@ -233,13 +281,17 @@ class LLMModelsService:
await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
await _assert_cloud_managed_provider_mutable(self.ap, context, model_data['provider_uuid'])
await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
_normalize_llm_reasoning(model_data)
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
model_entity = persistence_model.LLMModel(**model_data)
_validate_llm_reasoning_capability(model_entity, runtime_provider)
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
context,
persistence_model.LLMModel(**model_data),
model_entity,
runtime_provider,
)
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
@@ -287,7 +339,7 @@ class LLMModelsService:
if model is None:
return None
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, model)
model_dict = _serialize_llm_model(self.ap, model)
# Get provider
provider_result = await self.ap.persistence_mgr.execute_async(
@@ -349,6 +401,18 @@ class LLMModelsService:
await _assert_cloud_managed_provider_mutable(self.ap, context, provider_uuid)
await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
merged_model_data = {
key: value
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
if key not in {'provider', 'created_at', 'updated_at', 'reasoning_capabilities'}
}
_normalize_llm_reasoning(merged_model_data)
model_data['reasoning_config'] = merged_model_data['reasoning_config']
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
model_entity = persistence_model.LLMModel(**_runtime_model_data(model_uuid, merged_model_data))
_validate_llm_reasoning_capability(model_entity, runtime_provider)
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_model.LLMModel)
@@ -362,19 +426,9 @@ class LLMModelsService:
raise WorkspaceNotFoundError('Model not found')
await self.ap.model_mgr.remove_llm_model(context, model_uuid)
runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
context,
persistence_model.LLMModel(
**_runtime_model_data(
model_uuid,
{
key: value
for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
if key not in {'provider', 'created_at', 'updated_at'}
},
)
),
model_entity,
runtime_provider,
)
await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
@@ -407,6 +461,7 @@ class LLMModelsService:
raise WorkspaceNotFoundError('Model not found')
runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid)
else:
_normalize_llm_reasoning(model_data)
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(context, model_data)
extra_args = model_data.get('extra_args', {})
+18 -2
View File
@@ -59,6 +59,10 @@ class SpaceService:
result_list = result.all()
return result_list[0] if result_list else None
async def get_valid_access_token(self, user_email: str) -> str | None:
"""Return a current Space bearer, refreshing and persisting it when needed."""
return await self._ensure_valid_token(user_email)
async def _ensure_valid_token(self, user_email: str) -> str | None:
"""Ensure access token is valid, refresh if expired. Returns valid access_token or None."""
user_obj = await self._get_user_by_email(user_email)
@@ -117,7 +121,12 @@ class SpaceService:
params['state'] = state
return f'{authorize_url}?{urlencode(params)}'
async def exchange_oauth_code(self, code: str) -> typing.Dict:
async def exchange_oauth_code(
self,
code: str,
workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None,
) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants
@@ -127,7 +136,14 @@ class SpaceService:
session = httpclient.get_session()
async with session.post(
f'{space_url}/api/v1/accounts/oauth/token',
json={'code': code, 'instance_id': constants.instance_id},
json={
'code': code,
'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding.
'workspace_uuids': workspace_uuids if workspace_uuids is not None else [],
'workspace_created_ats': workspace_created_ats or {},
},
) as response:
if response.status != 200:
error = await httpclient.read_text_limited(response)
+25 -6
View File
@@ -114,7 +114,7 @@ class UserService:
if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account')
if purpose != 'login' and launch_workspace_uuid is not None:
raise ValueError('Launch Workspace state is only valid for Space login')
raise ValueError('Launch Workspace state is only valid for LangBot Account login')
if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive')
@@ -327,7 +327,7 @@ class UserService:
normalized_email = normalize_email(user_email)
if self._uses_control_plane_directory():
raise ControlPlaneDirectoryRequiredError(
'Cloud invitation registration must use a Space account to preserve control-plane identity'
'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
)
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email:
@@ -394,7 +394,7 @@ class UserService:
# Check if this user has a local password set
if not user_obj.password:
raise ValueError('请使用 Space登录')
raise ValueError('请使用 LangBot登录')
await self._verify_password(user_obj.password, password)
@@ -779,8 +779,27 @@ class UserService:
local_account = await self.get_user_by_email(user_email)
if local_account is None:
raise ValueError('User not found')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
# Exchange code for tokens and bind both installation and the active
# OSS Workspace as independent identities.
workspace_service = getattr(self.ap, 'workspace_service', None)
if workspace_service is not None:
binding = await workspace_service.get_execution_binding()
created_at = binding.workspace_created_at
created_ts = (
int(created_at.replace(tzinfo=datetime.timezone.utc).timestamp())
if created_at.tzinfo is None
else int(created_at.timestamp())
)
token_data = await self.ap.space_service.exchange_oauth_code(
code,
[binding.workspace_uuid],
{binding.workspace_uuid: created_ts},
)
else:
# Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0)
@@ -806,7 +825,7 @@ class UserService:
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
raise ValueError('This Space account is already bound to another user')
raise ValueError('This LangBot Account is already bound to another user')
# Update local account to Space account
normalized_email = normalize_email(user_email)
+12 -6
View File
@@ -369,6 +369,12 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
def _ensure_control_token(self, *, allow_generate: bool) -> str:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
raise BoxRuntimeUnavailableError(
f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for a Cloud Box runtime'
)
return ''
try:
self._control_token = validate_control_token(self._control_token)
except ValueError as exc:
@@ -378,19 +384,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
return self._control_token
def get_control_headers(self) -> dict[str, str]:
"""Headers for the instance-authenticated RPC control handshake."""
"""Return instance-scoped RPC headers and the optional shared secret."""
self._ensure_control_token(allow_generate=False)
return {
BOX_CONTROL_TOKEN_HEADER: self._control_token,
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
}
headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid}
if self._control_token:
headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token
return headers
def get_relay_headers(
self,
action_context: ActionContext,
) -> dict[str, str]:
"""Return authenticated, placement-scoped relay handshake headers."""
"""Return instance- and placement-scoped relay handshake headers."""
context = ActionContext.model_validate(action_context).without_installation()
if context.instance_uuid != self._trusted_instance_uuid:
+1 -1
View File
@@ -18,7 +18,7 @@ from .model_catalog import CloudModelCatalogProvider
CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
REQUIRED_TENANT_ISOLATION_VERSION = 2
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536})
SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536, 3072})
class CloudBootstrapError(RuntimeError):
+17 -5
View File
@@ -15,6 +15,7 @@ from ..entity.persistence.cloud_directory import DirectoryProjectionInbox, Direc
from ..entity.persistence.user import AccountSource, AccountStatus, User
from ..entity.persistence.workspace import (
MembershipRole,
MembershipSource,
MembershipStatus,
Workspace,
WorkspaceExecutionSource,
@@ -358,6 +359,7 @@ class DirectoryProjectionService:
await self._reconcile_entitlement_snapshot_set(snapshot)
self._publish_runtime_execution_projection(snapshot.workspaces)
self._request_model_catalog_sync()
self._record_batch_cardinality(
active_workspaces=active_workspace_count,
workspaces=workspace_count,
@@ -466,6 +468,7 @@ class DirectoryProjectionService:
returned.values(),
affected_workspace_uuids=requested,
)
self._request_model_catalog_sync()
self._record_batch_cardinality(
active_workspaces=active_workspace_count,
workspaces=workspace_count,
@@ -475,6 +478,14 @@ class DirectoryProjectionService:
self._record_success()
self._consumer_cursor = batch.cursor
def _request_model_catalog_sync(self) -> None:
"""Wake model provisioning after a committed directory change."""
service = getattr(self.ap, 'cloud_model_catalog_service', None)
request_sync = getattr(service, 'request_sync', None)
if callable(request_sync):
request_sync()
def _publish_runtime_execution_projection(
self,
workspaces: Iterable[DirectoryWorkspace],
@@ -876,15 +887,15 @@ class DirectoryProjectionService:
account_uuid=member.account_uuid,
role=role,
status=status,
source=MembershipSource.CLOUD_PROJECTION.value,
joined_at=joined_at,
projection_revision=member.projection_revision,
)
)
continue
if membership.projection_revision == 0:
# Revision zero is Core-owned collaboration state. Directory
# projection seeds memberships, but must not overwrite later
# invitation, role, or removal decisions made by Core.
if membership.source != MembershipSource.CLOUD_PROJECTION.value:
# Core-owned collaboration state is never adopted based on
# account provenance, revision, or matching account identity.
continue
if membership.uuid != member.membership_uuid:
raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
@@ -896,11 +907,12 @@ class DirectoryProjectionService:
raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
membership.role = role
membership.status = status
membership.source = MembershipSource.CLOUD_PROJECTION.value
membership.joined_at = joined_at
membership.projection_revision = member.projection_revision
for account_uuid, membership in existing.items():
if account_uuid not in included_accounts and membership.projection_revision != 0:
if account_uuid not in included_accounts and membership.source == MembershipSource.CLOUD_PROJECTION.value:
membership.status = MembershipStatus.REMOVED.value
membership.projection_revision = max(
int(membership.projection_revision),
+11 -1
View File
@@ -151,6 +151,7 @@ class CloudModelCatalogSyncService:
# following database reconciliation is a no-op.
self._runtime_reload_pending = False
self._workspace_credits: dict[str, int | None] = {}
self._sync_requested = asyncio.Event()
def get_workspace_credits(self, workspace_uuid: str) -> int | None:
"""Return the latest signed owner-credit projection for a Workspace."""
@@ -159,9 +160,18 @@ class CloudModelCatalogSyncService:
async def initialize(self) -> None:
await self.sync_once(reload_runtime=False)
def request_sync(self) -> None:
"""Wake the catalog loop after a directory Workspace change."""
self._sync_requested.set()
async def run(self) -> None:
while True:
await asyncio.sleep(self.sync_interval_seconds)
try:
await asyncio.wait_for(self._sync_requested.wait(), timeout=self.sync_interval_seconds)
except TimeoutError:
pass
self._sync_requested.clear()
try:
await self.sync_once(reload_runtime=True)
except asyncio.CancelledError:
+4
View File
@@ -263,6 +263,10 @@ class Application:
{},
)
),
'plugin_runtime_connected': bool(
self.plugin_connector is not None
and getattr(self.plugin_connector, '_runtime_available', lambda: False)()
),
}
mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None)
runtime_stats.update(
+2 -1
View File
@@ -41,6 +41,7 @@ _RUNTIME_POLICY_DEFAULTS = {
}
},
'plugin': {
'connect_timeout_seconds': 180.0,
'worker': {
'max_cpus': 1.0,
'max_memory_mb': 512,
@@ -56,7 +57,7 @@ _RUNTIME_POLICY_DEFAULTS = {
'restart_failure_window_seconds': 30.0,
'restart_circuit_open_seconds': 60.0,
'require_hard_limits': False,
}
},
},
'mcp': {'stdio': {'enabled': True}},
'monitoring': {
+1 -1
View File
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
code = 'space_account_binding_required'
def __str__(self) -> str:
return 'This local Account must bind Space from Account settings before Space login'
return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'
@@ -48,6 +48,12 @@ class LLMModel(Base):
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
context_length = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
reasoning_config = sqlalchemy.Column(
sqlalchemy.JSON,
nullable=False,
default=lambda: {'level': 'provider_default'},
server_default=sqlalchemy.text('\'{"level":"provider_default"}\''),
)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
@@ -40,6 +40,11 @@ class MembershipStatus(enum.StrEnum):
REMOVED = 'removed'
class MembershipSource(enum.StrEnum):
LOCAL = 'local'
CLOUD_PROJECTION = 'cloud_projection'
class InvitationStatus(enum.StrEnum):
PENDING = 'pending'
ACCEPTED = 'accepted'
@@ -151,6 +156,11 @@ class WorkspaceMembership(Base):
nullable=True,
)
joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
source = sqlalchemy.Column(
sqlalchemy.String(32),
nullable=False,
server_default=MembershipSource.LOCAL.value,
)
projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
@@ -178,6 +188,10 @@ class WorkspaceMembership(Base):
"status IN ('active', 'disabled', 'removed')",
name='ck_workspace_memberships_status',
),
sqlalchemy.CheckConstraint(
"source IN ('local', 'cloud_projection')",
name='ck_workspace_memberships_source',
),
)
@@ -0,0 +1,57 @@
"""add llm reasoning config
Revision ID: 0018_llm_reasoning_config
Revises: 0017_oss_workspace_identity
Create Date: 2026-07-27
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0018_llm_reasoning_config'
down_revision = '0017_oss_workspace_identity'
branch_labels = None
depends_on = None
_LLM_MODELS = sa.table(
'llm_models',
sa.column('reasoning_config', sa.JSON()),
)
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'llm_models' not in inspector.get_table_names():
return
columns = {column['name'] for column in inspector.get_columns('llm_models')}
if 'reasoning_config' in columns:
return
op.add_column(
'llm_models',
sa.Column(
'reasoning_config',
sa.JSON(),
nullable=True,
server_default=sa.text('\'{"level":"provider_default"}\''),
),
)
conn.execute(_LLM_MODELS.update().values(reasoning_config={'level': 'provider_default'}))
with op.batch_alter_table('llm_models') as batch_op:
batch_op.alter_column('reasoning_config', existing_type=sa.JSON(), nullable=False)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'llm_models' not in inspector.get_table_names():
return
columns = {column['name'] for column in inspector.get_columns('llm_models')}
if 'reasoning_config' in columns:
with op.batch_alter_table('llm_models') as batch_op:
batch_op.drop_column('reasoning_config')
@@ -0,0 +1,43 @@
"""enable 3072-dimensional pgvector embeddings
Revision ID: 001a_pgvector_dimension_3072
Revises: 0019_single_workspace_owner
Create Date: 2026-08-05
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '001a_pgvector_dimension_3072'
down_revision = '0019_single_workspace_owner'
branch_labels = None
depends_on = None
_TABLE = 'langbot_vectors'
_CHECK = 'ck_langbot_vectors_embedding_dimension_enabled'
_INDEX = 'ix_langbot_vectors_hnsw_cosine_3072'
def upgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
return
op.drop_constraint(_CHECK, _TABLE, type_='check')
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536, 3072)')
op.execute(
sa.text(
f'CREATE INDEX {_INDEX} ON {_TABLE} USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops) WHERE embedding_dimension = 3072'
)
)
def downgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql' or _TABLE not in sa.inspect(conn).get_table_names():
return
count = conn.scalar(sa.text(f'SELECT COUNT(*) FROM {_TABLE} WHERE embedding_dimension = 3072'))
if count:
raise RuntimeError('Cannot disable 3072-dimensional pgvector while matching embeddings exist')
op.drop_index(_INDEX, table_name=_TABLE)
op.drop_constraint(_CHECK, _TABLE, type_='check')
op.create_check_constraint(_CHECK, _TABLE, 'embedding_dimension IN (384, 512, 768, 1024, 1536)')
@@ -0,0 +1,49 @@
"""add explicit Workspace membership source
Revision ID: 0020_membership_source
Revises: 001a_pgvector_dimension_3072
Create Date: 2026-08-06
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0020_membership_source'
down_revision = '001a_pgvector_dimension_3072'
branch_labels = None
depends_on = None
_CONSTRAINT_NAME = 'ck_workspace_memberships_source'
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'workspace_memberships' not in inspector.get_table_names():
return
if 'source' in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
return
# No durable historical field distinguishes Directory-created revision-zero
# rows from Core invitations. Protect every existing row; production can
# reclassify separately after UUIDs have been verified against Space.
with op.batch_alter_table('workspace_memberships') as batch_op:
batch_op.add_column(sa.Column('source', sa.String(length=32), nullable=False, server_default='local'))
batch_op.create_check_constraint(
_CONSTRAINT_NAME,
"source IN ('local', 'cloud_projection')",
)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'workspace_memberships' not in inspector.get_table_names():
return
if 'source' not in {column['name'] for column in inspector.get_columns('workspace_memberships')}:
return
with op.batch_alter_table('workspace_memberships') as batch_op:
batch_op.drop_constraint(_CONSTRAINT_NAME, type_='check')
batch_op.drop_column('source')
@@ -0,0 +1,21 @@
"""merge reasoning config with the main migration branch
Revision ID: 0021_merge_reasoning_config
Revises: 0020_membership_source, 0018_llm_reasoning_config
Create Date: 2026-08-09
"""
from __future__ import annotations
revision = '0021_merge_reasoning_config'
down_revision = ('0020_membership_source', '0018_llm_reasoning_config')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,21 @@
"""merge AgentRunner and model reasoning migration heads
Revision ID: 0022_merge_agent_reasoning_heads
Revises: 0020_merge_agent_cloud_heads, 0021_merge_reasoning_config
Create Date: 2026-08-14
"""
from __future__ import annotations
revision = '0022_merge_agent_reasoning_heads'
down_revision = ('0020_merge_agent_cloud_heads', '0021_merge_reasoning_config')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+6 -4
View File
@@ -98,7 +98,7 @@ _WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
_RUNTIME_SCHEMA = 'public'
_ALEMBIC_RUNTIME_TABLE = 'alembic_version'
_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
@@ -1311,14 +1311,16 @@ class PersistenceManager:
index = by_index.get(index_name)
index_definition = normalized(None if index is None else index['definition'])
predicate = normalized(None if index is None else index['predicate'])
vector_type = 'halfvec' if dimension > 2000 else 'vector'
operator_class = f'{vector_type}_cosine_ops'
if (
index is None
or index['access_method'] != 'hnsw'
or index['is_valid'] is not True
or index['is_ready'] is not True
or f'vector({dimension})' not in index_definition
or f'(embedding)::vector({dimension})' not in index_definition
or 'vector_cosine_ops' not in index_definition
or f'{vector_type}({dimension})' not in index_definition
or f'(embedding)::{vector_type}({dimension})' not in index_definition
or operator_class not in index_definition
or predicate.strip('() ') != f'embedding_dimension = {dimension}'
):
raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
+3 -3
View File
@@ -13,7 +13,7 @@ import typing
import sqlalchemy
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy.orm as sqlalchemy_orm
from pgvector.sqlalchemy import Vector
from pgvector.sqlalchemy import HALFVEC, Vector
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
@@ -282,7 +282,7 @@ def _validate_scoped_sql_type(
return
seen.add(identity)
if type(sql_type) is Vector:
if type(sql_type) in {Vector, HALFVEC}:
return
if not type(sql_type).__module__.startswith('sqlalchemy.'):
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
@@ -463,7 +463,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector:
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
raise ScopedSessionTransactionError(
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
)
+4 -12
View File
@@ -58,12 +58,8 @@ class Controller:
query.session = await self.ap.sess_mgr.get_session(query)
query.pipeline_config = pipeline.pipeline_entity.config
query.variables['_pipeline_bound_plugins'] = pipeline.bound_plugins
query.variables['_pipeline_bound_mcp_servers'] = (
pipeline.bound_mcp_servers
)
return await self.ap.agent_run_orchestrator.try_claim_steering_from_query(
query
)
query.variables['_pipeline_bound_mcp_servers'] = pipeline.bound_mcp_servers
return await self.ap.agent_run_orchestrator.try_claim_steering_from_query(query)
except Exception as exc:
self.ap.logger.warning(
f'Failed to claim query {query.query_id} as steering input: {exc}',
@@ -157,9 +153,7 @@ class Controller:
# that can cause memory overflow in high-traffic scenarios
if session._semaphore.locked():
if await self._try_claim_steering_before_session_slot(
query
):
if await self._try_claim_steering_before_session_slot(query):
claimed_steering_query = query
break
continue
@@ -175,9 +169,7 @@ class Controller:
break
if claimed_steering_query is not None:
self.ap.query_pool.remove_query_locked(
claimed_steering_query
)
self.ap.query_pool.remove_query_locked(claimed_steering_query)
self.ap.query_pool.condition.notify_all()
continue
if selected_query is None: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
+5 -15
View File
@@ -70,24 +70,18 @@ class PreProcessor(stage.PipelineStage):
if primary_uuid in config_schema.NONE_SENTINELS:
return None
try:
return await self.ap.model_mgr.get_model_by_uuid(
get_query_execution_context(query), primary_uuid
)
return await self.ap.model_mgr.get_model_by_uuid(get_query_execution_context(query), primary_uuid)
except ValueError:
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
return None
async def _resolve_fallback_models(
self, query: pipeline_query.Query, fallback_uuids: list[str]
) -> list[str]:
async def _resolve_fallback_models(self, query: pipeline_query.Query, fallback_uuids: list[str]) -> list[str]:
valid_fallbacks = []
for fallback_uuid in fallback_uuids:
if fallback_uuid in config_schema.NONE_SENTINELS:
continue
try:
await self.ap.model_mgr.get_model_by_uuid(
get_query_execution_context(query), fallback_uuid
)
await self.ap.model_mgr.get_model_by_uuid(get_query_execution_context(query), fallback_uuid)
valid_fallbacks.append(fallback_uuid)
except ValueError:
self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping')
@@ -225,9 +219,7 @@ class PreProcessor(stage.PipelineStage):
if uses_host_models:
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
llm_model = await self._resolve_llm_model(query, primary_uuid)
valid_fallbacks = await self._resolve_fallback_models(
query, fallback_uuids
)
valid_fallbacks = await self._resolve_fallback_models(query, fallback_uuids)
if valid_fallbacks:
query.variables['_fallback_model_uuids'] = valid_fallbacks
@@ -426,9 +418,7 @@ class PreProcessor(stage.PipelineStage):
query.pipeline_uuid,
include_secret=True,
)
extensions_prefs = normalize_extension_preferences(
(pipeline_data or {}).get('extensions_preferences')
)
extensions_prefs = normalize_extension_preferences((pipeline_data or {}).get('extensions_preferences'))
enable_all_skills = extensions_prefs['enable_all_skills']
if enable_all_skills:
@@ -5,6 +5,7 @@ import contextvars
import logging
import time
import typing
from dataclasses import dataclass
from datetime import datetime
import pydantic
@@ -25,6 +26,15 @@ _current_pipeline_uuid: contextvars.ContextVar[str | None] = contextvars.Context
)
@dataclass(frozen=True)
class WebSocketReplyContext:
"""Trusted routing context retained when the originating socket reconnects."""
scope: WebSocketScope
pipeline_uuid: str
session_id: str | None
class WebSocketMessage(pydantic.BaseModel):
"""WebSocket消息格式"""
@@ -265,10 +275,14 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
embed_target = self._parse_embed_target(sender_id)
if embed_target is not None:
return embed_target
reply_context = getattr(message_source, '_websocket_reply_context', None)
if isinstance(reply_context, WebSocketReplyContext):
if reply_context.scope != self._scope():
raise ValueError('WebSocket reply context does not match this adapter scope')
return reply_context.pipeline_uuid, reply_context.session_id
pipeline_uuid = getattr(message_source, '_langbot_pipeline_uuid', None)
if isinstance(pipeline_uuid, str) and pipeline_uuid:
return pipeline_uuid, None
raise ValueError('WebSocket reply target is not bound to this adapter scope')
async def send_message(
@@ -439,9 +453,9 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
# 更新历史记录中的对应消息
message_list[existing_index] = message_data
# Keep the index for the lifetime of the history entry. Some runners
# emit a final delta followed by message.completed/run.completed. They
# all share one Host response id and must update one UI message.
# Keep the index for the lifetime of the history entry. AgentRunner can
# emit a final delta followed by message.completed/run.completed; all
# events with the same Host response id must update one UI message.
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
@@ -537,8 +551,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
Image / Voice / File components uploaded from the web client carry a
storage key in ``path``. Resolve it to a base64 data URI so downstream
stages (multimodal LLM input and the Box sandbox inbox) have a usable
payload. Keep the storage key for browser history; the configured
storage-retention cleanup removes expired uploads.
payload, then drop the now-consumed storage object.
Args:
message_chain_obj: 消息链对象列表
@@ -593,6 +606,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.delete_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
component['path'] = ''
except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
raise
@@ -683,10 +702,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
object.__setattr__(event, '_langbot_pipeline_uuid', pipeline_uuid)
# 异步触发事件处理
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
object.__setattr__(
event,
'_websocket_reply_context',
WebSocketReplyContext(
scope=connection.scope,
pipeline_uuid=pipeline_uuid,
session_id=connection.session_id,
),
)
object.__setattr__(event, '_langbot_pipeline_uuid', pipeline_uuid)
listeners = (
owner_bot.adapter.listeners
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
+43 -19
View File
@@ -6,6 +6,7 @@ import contextlib
import contextvars
import hashlib
import json
import math
import time
import uuid
from typing import Any
@@ -76,7 +77,7 @@ _GITHUB_ASSET_HOSTS = frozenset(
}
)
_HTTP_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
_CONNECT_TIMEOUT_SEC = 30.0
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 180.0
_HEARTBEAT_INTERVAL_SEC = 20.0
_HEARTBEAT_FAILURE_THRESHOLD = 3
_RECONNECT_MAX_DELAY_SEC = 60.0
@@ -206,6 +207,17 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return f'{constants.instance_id}:plugin-runtime'
@staticmethod
def _runtime_connect_timeout(plugin_config: dict[str, Any]) -> float:
value = plugin_config.get('connect_timeout_seconds', _DEFAULT_CONNECT_TIMEOUT_SECONDS)
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
raise ValueError('plugin.connect_timeout_seconds must be a positive number')
return float(value)
@staticmethod
def _runtime_connect_timeout_error(timeout_seconds: float) -> str:
return f'Plugin runtime did not become ready within {timeout_seconds:g} seconds'
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
@@ -251,6 +263,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
if self.runtime_profile == 'shared':
raise PluginRuntimeNotConnectedError(
f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
'for a Cloud Plugin Runtime'
)
return {}
try:
self._control_token = validate_runtime_secret(
self._control_token,
@@ -699,10 +718,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
"""
runtime_handler = self._runtime_handler()
started_at = time.monotonic()
async with self._state_lock:
all_states: dict[str, PluginInstallationDesiredState] = {}
workspace_installations: dict[str, set[str]] = {}
workspace_count = 0
for context in contexts:
workspace_count += 1
execution_context = await self._validate_execution_context(context)
states = await self._load_workspace_desired_states(execution_context)
installation_ids = {state.binding.installation_uuid for state in states}
@@ -722,6 +744,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
runtime_handler.unregister_installation_binding(previous.binding)
self._known_desired_states = all_states
self._workspace_installations = workspace_installations
self.ap.logger.info(
'Shared plugin runtime reconcile completed: workspaces=%d desired_installations=%d '
'elapsed_seconds=%.3f',
workspace_count,
len(all_states),
time.monotonic() - started_at,
)
return result
async def _validate_execution_context(self, context: TenantContext) -> ExecutionContext:
@@ -817,6 +846,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
runtime_id=self._runtime_id,
)
self.worker_policy = self._load_worker_policy()
plugin_config = self.ap.instance_config.data.get('plugin', {})
connect_timeout_seconds = self._runtime_connect_timeout(plugin_config)
async with self._lifecycle_lock:
if self._closing:
@@ -958,10 +989,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
self._transport_task = asyncio.create_task(task_coro)
try:
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
await asyncio.wait_for(self._connected.wait(), timeout=connect_timeout_seconds)
except asyncio.TimeoutError as exc:
await self._stop_transport()
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
raise PluginRuntimeNotConnectedError(
self._runtime_connect_timeout_error(connect_timeout_seconds)
) from exc
if connect_errors:
await self._stop_transport()
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
@@ -1989,11 +2022,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
with runtime_handler.installation_scope(binding):
return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
async def get_debug_info(self) -> dict[str, Any]:
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
if not self.is_enable_plugin or not self._runtime_available():
return {}
return await self._runtime_handler().get_debug_info()
return await self._runtime_handler().get_debug_info(execution_context)
async def emit_event(
self,
@@ -2164,15 +2197,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return []
runtime_handler = self._runtime_handler()
runners: list[dict[str, Any]] = []
for binding in await self._operation_bindings(
include_plugins=bound_plugins
):
for binding in await self._operation_bindings(include_plugins=bound_plugins):
with runtime_handler.installation_scope(binding):
runners.extend(
await runtime_handler.list_agent_runners(
include_plugins=bound_plugins
)
)
runners.extend(await runtime_handler.list_agent_runners(include_plugins=bound_plugins))
return runners
async def run_agent(
@@ -2205,12 +2232,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
}
return
workspace_id = (
(context.get('conversation') or {}).get('workspace_id')
or (context.get('runtime') or {}).get('metadata', {}).get(
'workspace_id'
)
)
workspace_id = (context.get('conversation') or {}).get('workspace_id') or (context.get('runtime') or {}).get(
'metadata', {}
).get('workspace_id')
if not isinstance(workspace_id, str) or not workspace_id.strip():
raise ValueError('AgentRunner execution requires a Workspace')
execution_context = await self._current_execution_context()
+24 -33
View File
@@ -81,6 +81,7 @@ def _is_host_reserved_query_var(key: str) -> bool:
"""Return whether a Query variable controls Host authorization or runtime state."""
return key in _HOST_RESERVED_QUERY_VAR_KEYS or key.startswith(_HOST_RESERVED_QUERY_VAR_PREFIXES)
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
@@ -322,9 +323,7 @@ def _get_cached_query(
try:
if isinstance(query_id, str):
return ap.query_pool.cached_queries.get((workspace_uuid, query_id))
query_uuid = ap.query_pool.legacy_query_index.get(
(workspace_uuid, query_id)
)
query_uuid = ap.query_pool.legacy_query_index.get((workspace_uuid, query_id))
if query_uuid is None:
return None
return ap.query_pool.cached_queries.get((workspace_uuid, query_uuid))
@@ -669,20 +668,14 @@ class RuntimeConnectionHandler(handler.Handler):
trusted_plugin_identity = None
if not _runtime_scoped:
action_context, identity = await self._require_plugin_action_context()
trusted_plugin_identity = (
f'{identity.plugin_author}/{identity.plugin_name}'
)
trusted_plugin_identity = f'{identity.plugin_author}/{identity.plugin_name}'
await self._require_active_action_context(action_context)
safe_data = {
key: value
for key, value in data.items()
if key not in _UNTRUSTED_SCOPE_FIELDS
}
safe_data = {key: value for key, value in data.items() if key not in _UNTRUSTED_SCOPE_FIELDS}
claimed_identity = safe_data.get('caller_plugin_identity')
if (
trusted_plugin_identity is not None
and claimed_identity not in {None, trusted_plugin_identity}
):
if trusted_plugin_identity is not None and claimed_identity not in {
None,
trusted_plugin_identity,
}:
yield handler.ActionResponse.error(
message='Caller plugin identity does not match the installation binding'
)
@@ -706,16 +699,11 @@ class RuntimeConnectionHandler(handler.Handler):
trusted_plugin_identity = None
if not _runtime_scoped:
action_context, identity = await self._require_plugin_action_context()
trusted_plugin_identity = (
f'{identity.plugin_author}/{identity.plugin_name}'
)
trusted_plugin_identity = f'{identity.plugin_author}/{identity.plugin_name}'
await self._require_active_action_context(action_context)
safe_data = {key: value for key, value in data.items() if key not in _UNTRUSTED_SCOPE_FIELDS}
claimed_identity = safe_data.get('caller_plugin_identity')
if (
trusted_plugin_identity is not None
and claimed_identity not in {None, trusted_plugin_identity}
):
if trusted_plugin_identity is not None and claimed_identity not in {None, trusted_plugin_identity}:
return handler.ActionResponse.error(
message='Caller plugin identity does not match the installation binding'
)
@@ -874,9 +862,7 @@ class RuntimeConnectionHandler(handler.Handler):
):
super().__init__(connection, disconnect_callback)
self.ap = ap
self._outbound_installation_context: contextvars.ContextVar[
InstallationBinding | None | object
] = (
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None | object] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=_OUTBOUND_INSTALLATION_CONTEXT_UNSET,
@@ -2497,7 +2483,7 @@ class RuntimeConnectionHandler(handler.Handler):
return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(),
timeout=120,
timeout=300,
)
async def apply_plugin_installation(
@@ -2938,14 +2924,19 @@ class RuntimeConnectionHandler(handler.Handler):
)
return result
async def get_debug_info(self) -> dict[str, Any]:
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
with self.installation_scope(None):
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
)
action_context = ActionContext(
instance_uuid=execution_context.instance_uuid,
workspace_uuid=execution_context.workspace_uuid,
placement_generation=execution_context.placement_generation,
)
result = await self.call_action(
LangBotToRuntimeAction.GET_DEBUG_INFO,
{},
timeout=10,
action_context=action_context,
)
return result
# ================= RAG Capability Callers (LangBot -> Runtime) =================
@@ -649,6 +649,7 @@ class ModelManager:
provider_uuid=runtime_provider.provider_entity.uuid,
abilities=model_info.get('abilities', []),
context_length=model_info.get('context_length'),
reasoning_config=model_info.get('reasoning_config', {'level': 'provider_default'}),
extra_args=model_info.get('extra_args', {}),
)
return self._build_llm_model(execution_context, model_entity, runtime_provider)
@@ -717,7 +718,10 @@ class ModelManager:
provider_entity = self._coerce_provider(provider_info, context)
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
config = {'base_url': provider_entity.base_url}
config = {
'base_url': provider_entity.base_url,
'requester_name': provider_entity.requester,
}
if litellm_provider:
from .requesters import litellmchat
@@ -0,0 +1,125 @@
from __future__ import annotations
import typing
ReasoningLevel = typing.Literal[
'provider_default',
'disabled',
'enabled',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
]
REASONING_LEVELS: tuple[str, ...] = (
'provider_default',
'disabled',
'enabled',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
)
DEFAULT_REASONING_CONFIG: dict[str, str] = {'level': 'provider_default'}
_CONFLICTING_TOP_LEVEL_ARGS = {
'reasoning_effort',
'thinking',
'enable_thinking',
'thinking_budget',
'reasoning',
}
_CONFLICTING_EXTRA_BODY_ARGS = {
'reasoning_effort',
'thinking',
'enable_thinking',
'thinking_budget',
'reasoning',
}
def normalize_reasoning_config(value: typing.Any) -> dict[str, str]:
"""Return the canonical model reasoning configuration."""
if value is None:
return dict(DEFAULT_REASONING_CONFIG)
if not isinstance(value, dict):
raise ValueError('reasoning_config must be an object')
unknown_fields = set(value) - {'level'}
if unknown_fields:
raise ValueError(f'Unsupported reasoning_config fields: {", ".join(sorted(unknown_fields))}')
level = value.get('level', 'provider_default')
if level not in REASONING_LEVELS:
raise ValueError(f'Unsupported reasoning level: {level}')
return {'level': typing.cast(str, level)}
def validate_reasoning_config(
value: typing.Any,
abilities: typing.Iterable[str] | None,
extra_args: typing.Any,
) -> dict[str, str]:
"""Validate a model-facing reasoning config and conflicting raw arguments."""
config = normalize_reasoning_config(value)
if config['level'] == 'provider_default':
return config
if 'reasoning' not in set(abilities or []):
raise ValueError('The reasoning ability must be enabled before selecting a reasoning level')
conflicts = find_reasoning_arg_conflicts(extra_args)
if conflicts:
raise ValueError('reasoning_config conflicts with advanced parameters: ' + ', '.join(conflicts))
return config
def find_reasoning_arg_conflicts(extra_args: typing.Any) -> list[str]:
if not isinstance(extra_args, dict):
return []
conflicts = [key for key in sorted(_CONFLICTING_TOP_LEVEL_ARGS) if key in extra_args]
extra_body = extra_args.get('extra_body')
if isinstance(extra_body, dict):
conflicts.extend(f'extra_body.{key}' for key in sorted(_CONFLICTING_EXTRA_BODY_ARGS) if key in extra_body)
return conflicts
def validate_reasoning_capabilities(
config: typing.Any,
capabilities: typing.Mapping[str, typing.Any],
model_name: str,
) -> None:
"""Ensure an explicit reasoning level can be honored by the requester."""
level = normalize_reasoning_config(config)['level']
if level == 'provider_default':
return
available_levels = capabilities.get('levels')
if not isinstance(available_levels, list):
available_levels = []
legacy_levels = capabilities.get('legacy_levels')
if not isinstance(legacy_levels, list):
legacy_levels = []
if capabilities.get('supported') is not True or (level not in available_levels and level not in legacy_levels):
available_text = ', '.join(str(item) for item in available_levels) or 'provider_default'
raise ValueError(
f'Reasoning level "{level}" is not supported by model {model_name}. Available levels: {available_text}'
)
def default_reasoning_capabilities(
supported: bool = False,
source: str = 'unknown',
) -> dict[str, typing.Any]:
return {
'supported': supported,
'levels': ['provider_default'],
'source': source,
}
@@ -10,6 +10,7 @@ from ...entity.persistence import model as persistence_model
from ...workspace.errors import WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from . import token
from . import reasoning
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
@@ -377,11 +378,15 @@ class RuntimeLLMModel:
provider: RuntimeProvider
"""提供商实例"""
reasoning_config_override: dict[str, str] | None
"""Request-scoped reasoning policy supplied by the active pipeline."""
def __init__(
self,
execution_context: ExecutionContext,
model_entity: persistence_model.LLMModel,
provider: RuntimeProvider,
reasoning_config_override: dict[str, str] | None = None,
):
_ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
if model_entity.workspace_uuid != execution_context.workspace_uuid:
@@ -391,6 +396,7 @@ class RuntimeLLMModel:
self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
self.reasoning_config_override = reasoning_config_override
class RuntimeEmbeddingModel:
@@ -482,6 +488,13 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
"""
raise NotImplementedError('This provider does not support model scanning')
def get_reasoning_capabilities(self, model: RuntimeLLMModel) -> dict[str, typing.Any]:
"""Return normalized reasoning controls supported by a model."""
return reasoning.default_reasoning_capabilities(
supported='reasoning' in (model.model_entity.abilities or []),
source='manual' if 'reasoning' in (model.model_entity.abilities or []) else 'unknown',
)
@abc.abstractmethod
async def invoke_llm(
self,
@@ -7,7 +7,7 @@ import typing
import litellm
from litellm import acompletion, aembedding, arerank
from .. import errors, requester
from .. import errors, reasoning, requester
from ....utils import httpclient
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -164,6 +164,39 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
_EMBEDDING_MODEL_HINTS = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
_RERANK_MODEL_HINTS = ('rerank', 're-rank', 're_rank')
_QWEN_DEDICATED_THINKING_MODELS = frozenset(
{
'qwen3.7-max-preview',
'qwen3.7-max-2026-05-17',
}
)
_QWEN_REASONING_BUDGETS = {
'low': 1024,
'medium': 4096,
'high': 8192,
}
_INFERRED_EFFORT_PROVIDERS = frozenset(
{
'anthropic',
'gemini',
'groq',
'mistral',
'openai',
'openrouter',
'together_ai',
'xai',
}
)
_REQUESTER_REASONING_FAMILIES = {
'openai-chat-completions': 'openai',
'anthropic-messages': 'anthropic',
'deepseek-chat-completions': 'deepseek',
'moonshot-chat-completions': 'kimi',
'moonshot-cn-chat-completions': 'kimi',
'bailian-chat-completions': 'qwen',
'doubao-chat-completions': 'doubao',
'mimo-chat-completions': 'mimo',
}
default_config: dict[str, typing.Any] = {
'base_url': '',
@@ -172,6 +205,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
'drop_params': False,
'num_retries': 0,
'api_version': '',
'requester_name': '',
}
async def initialize(self):
@@ -201,7 +235,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return False
provider = self._get_custom_llm_provider()
candidates: list[tuple[str, str | None]] = [(model_name, provider)]
candidates: list[tuple[str, str | None]] = [
(candidate, None) for candidate in self._metadata_model_candidates(model_name)
]
candidates.append((model_name, provider))
litellm_model_name = self._build_litellm_model_name(model_name)
if litellm_model_name != model_name:
candidates.append((litellm_model_name, None))
@@ -268,6 +305,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
deduped_candidates.append(candidate)
return deduped_candidates
@staticmethod
def _metadata_model_candidates(model_name: str) -> list[str]:
"""Return known equivalent model IDs used only for LiteLLM metadata lookup."""
normalized_model_name = (model_name or '').lower()
if normalized_model_name.startswith('mimo-v2.5'):
return [f'openrouter/xiaomi/{normalized_model_name}']
return []
def _known_context_length_fallback(self, model_name: str) -> int | None:
normalized_model_name = (model_name or '').lower()
if normalized_model_name.startswith('deepseek-v4-'):
@@ -287,7 +332,8 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if not callable(helper):
return self._known_context_length_fallback(model_name)
candidates = [model_name]
candidates = self._metadata_model_candidates(model_name)
candidates.append(model_name)
litellm_model_name = self._build_litellm_model_name(model_name)
if litellm_model_name != model_name:
candidates.append(litellm_model_name)
@@ -314,6 +360,297 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
def _supports_vision(self, model_name: str) -> bool:
return self._safe_litellm_bool_helper('supports_vision', model_name)
def _supports_reasoning(self, model_name: str) -> bool:
return self._safe_litellm_bool_helper('supports_reasoning', model_name)
def _requester_name(self, model: requester.RuntimeLLMModel | None = None) -> str:
if model is not None:
provider_entity = getattr(getattr(model, 'provider', None), 'provider_entity', None)
name = getattr(provider_entity, 'requester', None)
if isinstance(name, str) and name:
return name.lower()
return str(self.requester_cfg.get('requester_name') or '').lower()
@staticmethod
def _infer_reasoning_family_from_model_name(model_name: str) -> str:
normalized_name = (model_name or '').lower()
basename = normalized_name.rsplit('/', 1)[-1]
if basename.startswith(('gpt-', 'chatgpt-', 'o1', 'o3', 'o4')):
return 'openai'
if basename.startswith('claude-'):
return 'anthropic'
if basename.startswith('deepseek-'):
return 'deepseek'
if basename.startswith(('kimi-', 'moonshot-')):
return 'kimi'
if basename.startswith(('qwen-', 'qwen3', 'qwq')):
return 'qwen'
if basename.startswith(('doubao-', 'seed-')):
return 'doubao'
if basename.startswith('mimo-'):
return 'mimo'
return ''
def _reasoning_family(
self,
model_name: str,
model: requester.RuntimeLLMModel | None = None,
) -> str:
requester_name = self._requester_name(model)
if requester_name in {'new-api-chat-completions', 'volcark-chat-completions'}:
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
if inferred_family:
return inferred_family
return 'volcengine' if requester_name == 'volcark-chat-completions' else ''
# Bailian's compatible endpoint also hosts Kimi models. Keep those
# models on Kimi's ``thinking`` protocol instead of Qwen's
# ``enable_thinking`` protocol.
if requester_name == 'bailian-chat-completions':
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
if inferred_family == 'kimi':
return inferred_family
requester_family = self._REQUESTER_REASONING_FAMILIES.get(requester_name)
if requester_family:
return requester_family
inferred_family = self._infer_reasoning_family_from_model_name(model_name)
provider = (self._get_custom_llm_provider() or '').lower()
if provider == 'openai':
return inferred_family or ('openai' if requester_name in {'', 'openai'} else '')
if provider:
return provider
return inferred_family
@staticmethod
def _is_anthropic_adaptive_model(model_name: str) -> bool:
basename = model_name.lower().rsplit('/', 1)[-1]
if 'mythos-preview' in basename:
return True
parts = basename.split('-')
if len(parts) < 3 or parts[0] != 'claude':
return False
model_families = {'opus', 'sonnet', 'fable', 'mythos'}
if parts[1] in model_families:
if parts[2] == '5':
return True
return len(parts) >= 4 and parts[2] == '4' and parts[3] in {'6', '7', '8'}
return parts[1] == '5' and parts[2] in model_families
@staticmethod
def _is_anthropic_always_thinking_model(model_name: str) -> bool:
normalized_name = model_name.lower()
return any(marker in normalized_name for marker in ('fable-5', 'mythos-5', 'mythos-preview'))
@staticmethod
def _is_dedicated_qwen_thinking_model(model_name: str) -> bool:
normalized_name = model_name.lower().rsplit('/', 1)[-1]
return (
normalized_name in LiteLLMRequester._QWEN_DEDICATED_THINKING_MODELS
or normalized_name.startswith('qwq')
or '-thinking' in normalized_name
)
@staticmethod
def _supports_qwen_thinking_budget(model_name: str) -> bool:
"""Return whether the documented Qwen3 family supports thinking_budget."""
normalized_name = model_name.lower().rsplit('/', 1)[-1]
return normalized_name.startswith('qwen3')
def _known_reasoning_levels(self, model_name: str, family: str) -> list[str] | None:
normalized_name = model_name.lower().rsplit('/', 1)[-1]
if family == 'deepseek' and normalized_name.startswith('deepseek-'):
if normalized_name.startswith('deepseek-v4-'):
return ['provider_default', 'disabled', 'low', 'high', 'xhigh', 'max']
if 'reasoner' in normalized_name or '-r1' in normalized_name:
return ['provider_default']
return ['provider_default', 'disabled', 'enabled']
if family == 'kimi':
if normalized_name.startswith('kimi-k3'):
return ['provider_default', 'low', 'high', 'max']
if normalized_name.startswith('kimi-k2.7-code'):
return ['provider_default']
if normalized_name.startswith(('kimi-k2.5', 'kimi-k2.6')):
return ['provider_default', 'disabled', 'enabled']
if 'thinking' in normalized_name:
return ['provider_default']
if family == 'qwen' and normalized_name.startswith(('qwen-', 'qwen3', 'qwq')):
if self._is_dedicated_qwen_thinking_model(normalized_name):
if self._supports_qwen_thinking_budget(normalized_name):
return ['provider_default', 'low', 'medium', 'high']
return ['provider_default']
if self._supports_qwen_thinking_budget(normalized_name):
return ['provider_default', 'disabled', 'low', 'medium', 'high']
return ['provider_default', 'disabled', 'enabled']
if family == 'doubao' and normalized_name.startswith(('doubao-', 'seed-')):
return ['provider_default', 'disabled', 'low', 'medium', 'high']
if family == 'mimo' and normalized_name.startswith(('mimo-v2.5',)):
return ['provider_default', 'disabled', 'enabled']
if family == 'anthropic' and normalized_name.startswith('claude-'):
levels = ['provider_default']
adaptive = self._is_anthropic_adaptive_model(normalized_name)
if adaptive and not self._is_anthropic_always_thinking_model(normalized_name):
levels.append('disabled')
levels.extend(['low', 'medium', 'high'])
if adaptive:
levels.extend(['xhigh', 'max'])
return levels
if family == 'openai' and normalized_name.startswith(('gpt-5', 'o1', 'o3', 'o4')):
return ['provider_default', 'low', 'medium', 'high']
return None
def _openai_reasoning_levels(self, model_name: str) -> list[str]:
model_info = self._safe_model_info(model_name)
levels = ['provider_default']
if model_info.get('supports_none_reasoning_effort') is True:
levels.append('disabled')
if model_info.get('supports_minimal_reasoning_effort') is True:
levels.append('minimal')
for level in ('low', 'medium', 'high'):
if model_info.get(f'supports_{level}_reasoning_effort') is not False:
levels.append(level)
for level in ('xhigh', 'max'):
if model_info.get(f'supports_{level}_reasoning_effort') is True:
levels.append(level)
return levels
def _safe_model_info(self, model_name: str) -> dict[str, typing.Any]:
helper = getattr(litellm, 'get_model_info', None)
if not callable(helper):
return {}
candidates = [
*self._metadata_model_candidates(model_name),
model_name,
self._build_litellm_model_name(model_name),
]
for candidate in candidates:
try:
info = helper(candidate)
except Exception:
continue
if isinstance(info, dict):
return info
model_dump = getattr(info, 'model_dump', None)
if callable(model_dump):
try:
dumped = model_dump()
if isinstance(dumped, dict):
return dumped
except Exception:
continue
return {}
def get_reasoning_capabilities(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]:
model_name = model.model_entity.name
abilities = model.model_entity.abilities or []
detected = self._supports_reasoning(model_name)
declared = 'reasoning' in abilities
family = self._reasoning_family(model_name, model)
known_levels = self._known_reasoning_levels(model_name, family)
supported = detected or declared or known_levels is not None
if not supported:
return reasoning.default_reasoning_capabilities()
normalized_name = model_name.lower()
if family == 'openai':
levels = self._openai_reasoning_levels(model_name)
elif known_levels is not None:
levels = known_levels
elif family == 'anthropic':
levels = ['provider_default', 'low', 'medium', 'high']
elif family in {'deepseek', 'qwen', 'mimo', 'volcengine'}:
levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family == 'ollama':
levels = ['provider_default']
levels.append('disabled')
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
levels.extend(['low', 'medium', 'high'])
else:
levels.append('enabled')
elif family in self._INFERRED_EFFORT_PROVIDERS:
levels = ['provider_default', 'low', 'medium', 'high']
else:
levels = ['provider_default']
capabilities = {
'supported': True,
'levels': list(dict.fromkeys(levels)),
'source': 'litellm' if detected else ('provider' if known_levels is not None else 'manual'),
}
if family == 'qwen' and 'disabled' in capabilities['levels'] and 'enabled' not in capabilities['levels']:
capabilities['legacy_levels'] = ['enabled']
return capabilities
def _build_reasoning_args(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]:
level = self._reasoning_level(model)
if level == 'provider_default':
return {}
config = {'level': level}
capabilities = self.get_reasoning_capabilities(model)
try:
reasoning.validate_reasoning_capabilities(config, capabilities, model.model_entity.name)
except ValueError as exc:
raise errors.RequesterError(str(exc)) from exc
family = self._reasoning_family(model.model_entity.name, model)
if level == 'disabled':
if family in {'deepseek', 'kimi', 'mimo', 'doubao'}:
return {'extra_body': {'thinking': {'type': 'disabled'}}}
if family == 'qwen':
return {'extra_body': {'enable_thinking': False}}
if family == 'volcengine':
return {'extra_body': {'thinking': {'type': 'disabled'}}}
if family == 'anthropic':
return {'thinking': {'type': 'disabled'}}
return {'reasoning_effort': 'none'}
if level == 'enabled':
if family in {'deepseek', 'kimi', 'mimo', 'volcengine'}:
return {'extra_body': {'thinking': {'type': 'enabled'}}}
if family == 'qwen':
return {'extra_body': {'enable_thinking': True}}
return {'reasoning_effort': 'low'}
if family == 'qwen' and level in self._QWEN_REASONING_BUDGETS:
return {
'extra_body': {
'enable_thinking': True,
'thinking_budget': self._QWEN_REASONING_BUDGETS[level],
}
}
if family == 'deepseek':
return {
'extra_body': {
'thinking': {'type': 'enabled'},
'reasoning_effort': level,
}
}
return {'reasoning_effort': level}
@staticmethod
def _reasoning_config_value(model: requester.RuntimeLLMModel) -> typing.Any:
raw_config = getattr(model, 'reasoning_config_override', None)
if raw_config is None:
raw_config = getattr(model.model_entity, 'reasoning_config', None)
if not isinstance(raw_config, dict):
return None
return raw_config
def _reasoning_level(self, model: requester.RuntimeLLMModel) -> str:
return reasoning.normalize_reasoning_config(self._reasoning_config_value(model))['level']
def _infer_model_type(self, model_id: str) -> str:
normalized_id = (model_id or '').lower()
if any(kw in normalized_id for kw in self._RERANK_MODEL_HINTS):
@@ -344,6 +681,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
)
if supports_provider_reported_vision or self._supports_vision(model_id):
abilities.append('vision')
supports_provider_reported_reasoning = bool(
model_payload and model_payload.get('supports_reasoning') is True
)
family = self._reasoning_family(model_id)
supports_known_reasoning = self._known_reasoning_levels(model_id, family) is not None
if supports_provider_reported_reasoning or supports_known_reasoning or self._supports_reasoning(model_id):
abilities.append('reasoning')
scanned_model['abilities'] = abilities
context_length = self._context_length_from_scan_payload(model_payload)
@@ -354,13 +698,51 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return scanned_model
def _convert_messages(self, messages: typing.List[provider_message.Message]) -> list[dict]:
def _convert_messages(
self,
messages: typing.List[provider_message.Message],
reasoning_family: str = '',
include_reasoning_context: bool = True,
) -> list[dict]:
"""Convert LangBot messages to LiteLLM/OpenAI format."""
req_messages = []
for m in messages:
msg_dict = m.dict(exclude_none=True)
content = msg_dict.get('content')
if msg_dict.get('role') == 'assistant' and reasoning_family:
provider_fields = msg_dict.get('provider_specific_fields')
if isinstance(provider_fields, dict):
cleaned_provider_fields = dict(provider_fields)
reasoning_content = cleaned_provider_fields.pop('reasoning_content', None)
thinking_blocks = cleaned_provider_fields.pop('thinking_blocks', None)
# ``content`` is also used for the user-facing rendering.
# Do not replay that rendered <think> wrapper alongside the
# structured provider reasoning on the next request.
if reasoning_content or thinking_blocks:
content = msg_dict.get('content')
if isinstance(content, str):
msg_dict['content'] = self._strip_think(content)
if include_reasoning_context:
if reasoning_family == 'anthropic' and thinking_blocks:
msg_dict['thinking_blocks'] = thinking_blocks
elif reasoning_family in {
'deepseek',
'kimi',
'qwen',
'doubao',
'mimo',
'volcengine',
} and isinstance(reasoning_content, str):
msg_dict['reasoning_content'] = reasoning_content
if cleaned_provider_fields:
msg_dict['provider_specific_fields'] = cleaned_provider_fields
else:
msg_dict.pop('provider_specific_fields', None)
if isinstance(content, list):
converted_parts = []
for part in content:
@@ -421,6 +803,52 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return content or ''
@staticmethod
def _thinking_blocks_text(thinking_blocks: typing.Any) -> str:
if not isinstance(thinking_blocks, list):
return ''
parts = []
for block in thinking_blocks:
if isinstance(block, dict):
text = block.get('thinking')
else:
text = getattr(block, 'thinking', None)
if isinstance(text, str) and text:
parts.append(text)
return ''.join(parts)
@classmethod
def _merge_thinking_blocks(
cls,
current: list[dict[str, typing.Any]],
incoming: typing.Any,
) -> list[dict[str, typing.Any]]:
"""Merge Anthropic thinking block fragments emitted by a stream."""
if not isinstance(incoming, list):
return current
merged = [dict(block) for block in current]
for raw_block in incoming:
block = cls._as_dict(raw_block)
if not block:
continue
block_type = block.get('type')
if block_type == 'redacted_thinking':
merged.append(block)
continue
text = block.get('thinking') if isinstance(block.get('thinking'), str) else ''
signature = block.get('signature')
if merged and merged[-1].get('type') == 'thinking' and not merged[-1].get('signature'):
merged[-1]['thinking'] = f'{merged[-1].get("thinking", "")}{text}'
if signature:
merged[-1]['signature'] = signature
elif merged and signature and merged[-1].get('signature') == signature:
if text and text != merged[-1].get('thinking', ''):
merged[-1]['thinking'] = f'{merged[-1].get("thinking", "")}{text}'
else:
merged.append(block)
return merged
@staticmethod
def _normalize_usage(usage: typing.Any) -> dict:
"""Normalize a LiteLLM/OpenAI usage object into a plain token dict.
@@ -651,7 +1079,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
stream: bool = False,
) -> dict:
"""Build common completion arguments for invoke_llm and invoke_llm_stream."""
req_messages = self._convert_messages(messages)
reasoning_family = self._reasoning_family(model.model_entity.name, model)
reasoning_level = self._reasoning_level(model)
req_messages = self._convert_messages(
messages,
reasoning_family=reasoning_family,
include_reasoning_context=reasoning_level != 'disabled',
)
model_name = self._build_litellm_model_name(model.model_entity.name)
api_key = model.provider.token_mgr.get_token()
@@ -670,6 +1104,29 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
args.update(model.model_entity.extra_args)
args.update(extra_args)
reasoning_args = self._build_reasoning_args(model)
if reasoning_args:
conflicts = reasoning.find_reasoning_arg_conflicts(model.model_entity.extra_args)
conflicts.extend(reasoning.find_reasoning_arg_conflicts(extra_args))
if conflicts:
raise errors.RequesterError(
'reasoning_config conflicts with advanced parameters: ' + ', '.join(dict.fromkeys(conflicts))
)
reasoning_extra_body = reasoning_args.get('extra_body')
if isinstance(reasoning_extra_body, dict):
existing_extra_body = args.get('extra_body') or {}
if not isinstance(existing_extra_body, dict):
raise errors.RequesterError('extra_body must be an object')
args.update({key: value for key, value in reasoning_args.items() if key != 'extra_body'})
args['extra_body'] = {**existing_extra_body, **reasoning_extra_body}
else:
args.update(reasoning_args)
if 'reasoning_effort' in reasoning_args and self._get_custom_llm_provider() == 'openai':
allowed_openai_params = args.get('allowed_openai_params') or []
if not isinstance(allowed_openai_params, (list, tuple, set)):
raise errors.RequesterError('allowed_openai_params must be an array')
args['allowed_openai_params'] = list(dict.fromkeys([*allowed_openai_params, 'reasoning_effort']))
if funcs:
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
if tools:
@@ -730,10 +1187,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
content = message_data.get('content', '')
reasoning_content = message_data.get('reasoning_content', None)
message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think)
thinking_blocks = message_data.get('thinking_blocks')
if reasoning_content or thinking_blocks:
provider_fields = dict(message_data.get('provider_specific_fields') or {})
if reasoning_content:
provider_fields['reasoning_content'] = reasoning_content
if thinking_blocks:
provider_fields['thinking_blocks'] = thinking_blocks
message_data['provider_specific_fields'] = provider_fields
display_reasoning = reasoning_content or self._thinking_blocks_text(thinking_blocks) or None
message_data['content'] = self._process_thinking_content(content, display_reasoning, remove_think)
if 'reasoning_content' in message_data:
del message_data['reasoning_content']
if 'thinking_blocks' in message_data:
del message_data['thinking_blocks']
message = provider_message.Message(**message_data)
usage_info = self._extract_usage(response)
@@ -759,6 +1227,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
role = 'assistant'
tool_call_state: dict[int, dict[str, typing.Any]] = {}
think_state = _ThinkStripState() if remove_think else None
reasoning_started = False
reasoning_closed = False
thinking_blocks_state: list[dict[str, typing.Any]] = []
try:
response = await acompletion(**args)
@@ -789,28 +1260,63 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if 'role' in delta and delta['role']:
role = delta['role']
delta_content = delta.get('content', '')
reasoning_content = delta.get('reasoning_content', '')
delta_content = delta.get('content') or ''
reasoning_content = delta.get('reasoning_content') or ''
provider_fields = dict(delta.get('provider_specific_fields') or {})
raw_thinking_blocks = delta.get('thinking_blocks')
if raw_thinking_blocks:
thinking_blocks_state = self._merge_thinking_blocks(thinking_blocks_state, raw_thinking_blocks)
provider_fields['thinking_blocks'] = thinking_blocks_state
thinking_blocks_text = self._thinking_blocks_text(raw_thinking_blocks)
display_reasoning_content = reasoning_content or thinking_blocks_text
# Handle reasoning_content based on remove_think flag
if reasoning_content:
provider_fields['reasoning_content'] = reasoning_content
if remove_think:
# Skip reasoning content when remove_think is True
chunk_idx += 1
continue
delta_content = delta_content or None
else:
# Use reasoning_content as the displayed content
delta_content = reasoning_content
# Stream explicit markers so downstream adapters and
# the debug page see the same format as non-streaming
# responses.
if not reasoning_started:
delta_content = '<think>\n'
reasoning_started = True
else:
delta_content = ''
delta_content += display_reasoning_content
if delta.get('content'):
delta_content += f'\n</think>\n{delta.get("content")}'
reasoning_closed = True
elif display_reasoning_content:
if remove_think:
delta_content = delta_content or None
else:
if not reasoning_started:
delta_content = '<think>\n'
reasoning_started = True
else:
delta_content = ''
delta_content += display_reasoning_content
if delta.get('content'):
delta_content += f'\n</think>\n{delta.get("content")}'
reasoning_closed = True
elif delta_content and not remove_think and reasoning_started and not reasoning_closed:
delta_content = f'\n</think>\n{delta_content}'
reasoning_closed = True
if finish_reason and not remove_think and reasoning_started and not reasoning_closed:
delta_content = f'{delta_content}\n</think>\n'
reasoning_closed = True
if think_state is not None and delta_content:
delta_content = think_state.feed(delta_content)
if not delta_content:
chunk_idx += 1
continue
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
if chunk_idx == 0 and not delta_content and not tool_calls:
if not delta_content and not tool_calls and not provider_fields and not finish_reason:
chunk_idx += 1
continue
@@ -822,13 +1328,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
}
# Preserve provider_specific_fields from delta (e.g., Gemini thought_signatures)
if delta.get('provider_specific_fields'):
chunk_data['provider_specific_fields'] = delta['provider_specific_fields']
if provider_fields:
chunk_data['provider_specific_fields'] = provider_fields
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
if reasoning_started and not reasoning_closed:
yield provider_message.MessageChunk(
role=role,
content='\n</think>\n',
is_final=True,
)
if think_state is not None:
pending_content = think_state.flush()
if pending_content:
+18
View File
@@ -120,6 +120,7 @@ async def build_heartbeat_payload(
ap: core_app.Application,
*,
workspace_uuid: str,
workspace_create_ts: int = 0,
workspace_resource: WorkspaceResourceSnapshot | None = None,
) -> dict:
"""Collect one anonymous Workspace profile snapshot."""
@@ -212,7 +213,9 @@ async def build_heartbeat_payload(
'event_type': 'instance_heartbeat',
'query_id': '',
'version': constants.semantic_version,
'instance_id': constants.instance_id,
'workspace_uuid': workspace_uuid,
'workspace_create_ts': workspace_create_ts,
'instance_create_ts': constants.instance_create_ts,
'edition': constants.edition,
'features': features,
@@ -220,10 +223,24 @@ async def build_heartbeat_payload(
}
def _workspace_created_timestamp(created_at: datetime | None) -> int:
if created_at is None:
return 0
if created_at.tzinfo is None:
# SQLAlchemy may return persisted UTC values without tzinfo. Never
# reinterpret them in the host's local timezone.
created_at = created_at.replace(tzinfo=timezone.utc)
return int(created_at.timestamp())
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
"""Build one heartbeat per active Workspace."""
bindings = await ap.workspace_service.list_active_execution_bindings()
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
workspace_create_ts = {
binding.workspace_uuid: _workspace_created_timestamp(getattr(binding, 'workspace_created_at', None))
for binding in bindings
}
resources = {
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
}
@@ -231,6 +248,7 @@ async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
await build_heartbeat_payload(
ap,
workspace_uuid=workspace_uuid,
workspace_create_ts=workspace_create_ts.get(workspace_uuid, 0),
workspace_resource=resources.get(workspace_uuid),
)
for workspace_uuid in workspace_uuids
+8 -2
View File
@@ -4,13 +4,19 @@ import typing
class WorkspaceExecutionContext(typing.Protocol):
@property
def instance_uuid(self) -> str: ...
@property
def workspace_uuid(self) -> str: ...
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
"""Build the canonical telemetry identity for one Workspace execution."""
"""Build both first-class telemetry identities for one execution."""
instance_id = execution_context.instance_uuid.strip()
workspace_uuid = execution_context.workspace_uuid.strip()
if not instance_id:
raise ValueError('Telemetry execution instance ID is empty')
if not workspace_uuid:
raise ValueError('Telemetry execution Workspace UUID is empty')
return {'workspace_uuid': workspace_uuid}
return {'instance_id': instance_id, 'workspace_uuid': workspace_uuid}
+24 -5
View File
@@ -136,12 +136,31 @@ class TelemetryManager:
try:
# Use asyncio.wait_for to ensure we always bound the total time
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
headers: dict[str, str] = {}
if telemetry_token:
request = client.post(
url,
json=sanitized,
headers={'X-LangBot-Telemetry-Token': telemetry_token},
)
headers['X-LangBot-Telemetry-Token'] = telemetry_token
else:
workspace_uuid = str(sanitized.get('workspace_uuid', '')).strip()
user_service = getattr(self.ap, 'user_service', None)
if workspace_uuid and user_service is not None:
try:
owner = await user_service.get_workspace_owner(workspace_uuid)
owner_email = str(getattr(owner, 'user', '') or '').strip()
space_service = getattr(self.ap, 'space_service', None)
access_token = (
await space_service.get_valid_access_token(owner_email)
if owner_email and space_service is not None
else None
)
access_token = str(access_token or '').strip()
if access_token:
headers['Authorization'] = f'Bearer {access_token}'
except Exception:
self.ap.logger.debug(
'Could not resolve authenticated telemetry reporter', exc_info=True
)
if headers:
request = client.post(url, json=sanitized, headers=headers)
else:
request = client.post(url, json=sanitized)
resp = await asyncio.wait_for(request, timeout=10 + 1)
+1 -1
View File
@@ -67,7 +67,7 @@ class VectorDBManager:
use_business_database = pgvector_config.get('use_business_database', False)
allowed_dimensions = pgvector_config.get(
'allowed_dimensions',
[384, 512, 768, 1024, 1536],
[384, 512, 768, 1024, 1536, 3072],
)
common_options = {
'use_business_database': use_business_database,
+8 -3
View File
@@ -6,7 +6,7 @@ from collections.abc import AsyncIterator
from typing import Any
import sqlalchemy
from pgvector.sqlalchemy import Vector
from pgvector.sqlalchemy import HALFVEC, Vector
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
@@ -18,7 +18,7 @@ from langbot.pkg.vector.vdb import VectorDatabase
Base = declarative_base()
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536, 3072)
# pgvector schema only stores these metadata fields.
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
@@ -321,7 +321,12 @@ class PgVectorDatabase(VectorDatabase):
if len(query_embedding) != scope.embedding_dimension:
raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}')
typed_embedding = sqlalchemy.cast(PgVectorEntry.embedding, Vector(scope.embedding_dimension))
typed_embedding = sqlalchemy.cast(
PgVectorEntry.embedding,
HALFVEC(scope.embedding_dimension)
if scope.embedding_dimension > 2000
else Vector(scope.embedding_dimension),
)
distance = typed_embedding.cosine_distance(query_embedding)
statement = (
sqlalchemy.select(
@@ -17,6 +17,7 @@ from ..entity.persistence.user import AccountStatus, User
from ..entity.persistence.workspace import (
InvitationStatus,
MembershipRole,
MembershipSource,
MembershipStatus,
Workspace,
WorkspaceInvitation,
@@ -483,6 +484,7 @@ class WorkspaceCollaborationService:
account_uuid=account_uuid,
role=invitation.role,
status=MembershipStatus.ACTIVE.value,
source=MembershipSource.LOCAL.value,
invited_by_account_uuid=invitation.created_by_account_uuid,
joined_at=now,
projection_revision=0,
@@ -491,6 +493,7 @@ class WorkspaceCollaborationService:
elif membership.status != MembershipStatus.ACTIVE.value:
membership.role = invitation.role
membership.status = MembershipStatus.ACTIVE.value
membership.source = MembershipSource.LOCAL.value
membership.invited_by_account_uuid = invitation.created_by_account_uuid
membership.joined_at = now
+2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import datetime
from dataclasses import dataclass
@@ -12,3 +13,4 @@ class WorkspaceExecutionBinding:
placement_generation: int
write_fenced: bool
state: str
workspace_created_at: datetime.datetime | None = None
+4
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ..entity.persistence.workspace import (
MembershipRole,
MembershipSource,
MembershipStatus,
Workspace,
WorkspaceExecutionSource,
@@ -283,6 +284,7 @@ class WorkspaceService:
placement_generation=execution_state.active_generation,
write_fenced=execution_state.write_fenced,
state=execution_state.state,
workspace_created_at=workspace.created_at,
)
binding = await self._run(operation, session=session)
@@ -450,6 +452,7 @@ class WorkspaceService:
account_uuid=account_uuid,
role=MembershipRole.OWNER.value,
status=MembershipStatus.ACTIVE.value,
source=MembershipSource.LOCAL.value,
joined_at=joined_at,
projection_revision=0,
)
@@ -457,6 +460,7 @@ class WorkspaceService:
else:
membership.role = MembershipRole.OWNER.value
membership.status = MembershipStatus.ACTIVE.value
membership.source = MembershipSource.LOCAL.value
membership.joined_at = membership.joined_at or joined_at
if workspace.created_by_account_uuid is None:
+6 -3
View File
@@ -201,7 +201,7 @@ vdb:
# keep this false when deliberately using an external pgvector DB.
use_business_database: false
# Release migrations create one partial ANN index per enabled value.
allowed_dimensions: [384, 512, 768, 1024, 1536]
allowed_dimensions: [384, 512, 768, 1024, 1536, 3072]
host: '127.0.0.1'
port: 5433
database: 'langbot'
@@ -245,6 +245,8 @@ storage:
max_concurrency: 16
plugin:
enable: true
# Maximum time for the Runtime transport, handshake, and desired-state replay.
connect_timeout_seconds: 180.0
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
@@ -339,8 +341,9 @@ box:
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
# External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
# both LangBot and Box. Keep the shared secret out of this config file.
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret
# in both LangBot and Box. Keep it out of this config file.
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
max_sessions: 64