mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-26 18:35:38 +08:00
Merge remote-tracking branch 'origin/dev/4.11.x' into dev/4.11.x
# Conflicts: # uv.lock # web/src/i18n/locales/en-US.ts # web/src/i18n/locales/ja-JP.ts # web/src/i18n/locales/zh-Hans.ts # web/src/i18n/locales/zh-Hant.ts
This commit is contained in:
@@ -16,7 +16,7 @@ asciiart = r"""
|
||||
|___/
|
||||
|
||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||
📖 Documentation 文档地址: https://docs.langbot.app
|
||||
📖 Documentation 文档地址: https://langbot.app/docs
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -249,6 +249,20 @@ class RunnerContextBuilder:
|
||||
'timestamp': event.event_time or int(time.time()),
|
||||
}
|
||||
|
||||
# A legacy Tbox resume must use the persisted original bot, not a
|
||||
# newly reconstructed callback envelope. Ordinary sender mode is unchanged.
|
||||
bot_id = event.bot_id
|
||||
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') == 'legacy-bot':
|
||||
identity = event.legacy_identity
|
||||
bot_id = (
|
||||
identity.bot_id
|
||||
if identity is not None
|
||||
and identity.workspace_id == event.workspace_id
|
||||
and identity.bot_id == event.bot_id
|
||||
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
|
||||
else None
|
||||
)
|
||||
|
||||
# Build conversation context from event
|
||||
conversation: ConversationContext | None = None
|
||||
if event.conversation_id:
|
||||
@@ -256,13 +270,39 @@ class RunnerContextBuilder:
|
||||
'session_id': None,
|
||||
'conversation_id': event.conversation_id,
|
||||
'thread_id': event.thread_id,
|
||||
'launcher_type': None, # Will be filled from actor/subject if needed
|
||||
'launcher_type': None,
|
||||
'launcher_id': None,
|
||||
'sender_id': event.actor.actor_id if event.actor else None,
|
||||
'bot_id': event.bot_id,
|
||||
'bot_id': bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
}
|
||||
|
||||
identity = event.legacy_identity
|
||||
if (
|
||||
binding.runner_config.get('user-id-source') == 'legacy-session'
|
||||
and identity is not None
|
||||
and identity.workspace_id == event.workspace_id
|
||||
and identity.bot_id == event.bot_id
|
||||
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
|
||||
and binding.processor_type == 'pipeline'
|
||||
):
|
||||
# Coze used Query itself; Dify/n8n used Query.session, which may
|
||||
# deliberately differ (e.g. group per-member session isolation).
|
||||
if binding.runner_id == 'plugin:langbot-team/CozeAgent/default':
|
||||
launcher_type = identity.query_launcher_type
|
||||
launcher_id = identity.query_launcher_id
|
||||
elif binding.runner_id in {
|
||||
'plugin:langbot-team/DifyAgent/default',
|
||||
'plugin:langbot-team/N8nAgent/default',
|
||||
}:
|
||||
launcher_type = identity.session_launcher_type
|
||||
launcher_id = identity.session_launcher_id
|
||||
else:
|
||||
launcher_type = launcher_id = None
|
||||
if launcher_type in ('group', 'person') and launcher_id:
|
||||
conversation['launcher_type'] = launcher_type
|
||||
conversation['launcher_id'] = launcher_id
|
||||
|
||||
# Build event context (Protocol v1 event-first)
|
||||
event_context = {
|
||||
'event_id': event.event_id,
|
||||
@@ -322,7 +362,7 @@ class RunnerContextBuilder:
|
||||
'trace_id': run_id,
|
||||
'deadline_at': self._build_deadline_from_binding(binding),
|
||||
'metadata': {
|
||||
'bot_id': event.bot_id,
|
||||
'bot_id': bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
'streaming_supported': event.delivery.supports_streaming,
|
||||
'model_context_window_tokens': model_context_window_tokens,
|
||||
@@ -331,6 +371,7 @@ class RunnerContextBuilder:
|
||||
|
||||
# Build delivery context
|
||||
delivery_context = {
|
||||
'automatic_reply': binding.processor_type == 'pipeline',
|
||||
'surface': event.delivery.surface,
|
||||
'reply_target': event.delivery.reply_target,
|
||||
'supports_streaming': event.delivery.supports_streaming,
|
||||
@@ -480,6 +521,7 @@ class RunnerContextBuilder:
|
||||
'reason': 'current_event_only',
|
||||
},
|
||||
'available_apis': {
|
||||
'box': True,
|
||||
'prompt_get': False,
|
||||
'history_page': history_page_enabled,
|
||||
'history_search': history_search_enabled,
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
@@ -13,11 +12,9 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
from langbot_plugin.api.entities.builtin.provider import session as provider_session
|
||||
|
||||
from ...utils import constants
|
||||
from .host_models import AgentEventEnvelope
|
||||
|
||||
|
||||
HOST_BOX_SCOPE_VARIABLE = '_host_box_scope'
|
||||
AUTHORIZED_SKILLS_VARIABLE = '_pipeline_bound_skills'
|
||||
MCP_RESOURCE_ATTACHMENTS_VARIABLE = '_pipeline_mcp_resource_attachments'
|
||||
MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE = '_pipeline_mcp_resource_agent_read_enabled'
|
||||
@@ -102,29 +99,12 @@ def prepare_execution_query(
|
||||
authorized_skill_names: list[str],
|
||||
) -> pipeline_query.Query:
|
||||
"""Attach Host-owned execution metadata without changing Query identity."""
|
||||
variables = prepare_box_scope(query, event, preserve_existing=True)
|
||||
variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def prepare_box_scope(
|
||||
query: pipeline_query.Query,
|
||||
event: AgentEventEnvelope,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Attach the Host Box scope before any Query-side file staging."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
variables = query.variables
|
||||
if not isinstance(variables, dict):
|
||||
variables = {}
|
||||
query.variables = variables
|
||||
|
||||
existing_scope = variables.get(HOST_BOX_SCOPE_VARIABLE)
|
||||
if preserve_existing and isinstance(existing_scope, str) and existing_scope.strip():
|
||||
return variables
|
||||
|
||||
variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event, query=query)
|
||||
return variables
|
||||
variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def build_execution_query(
|
||||
@@ -168,50 +148,10 @@ def build_execution_query(
|
||||
variables={},
|
||||
resp_messages=[],
|
||||
)
|
||||
query.variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event)
|
||||
query.variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def build_host_box_scope(
|
||||
event: AgentEventEnvelope,
|
||||
*,
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> str | None:
|
||||
"""Return a stable Host scope for a Query session or event."""
|
||||
target_type, target_id = _resolve_box_target(event, query)
|
||||
if target_type is None or target_id is None:
|
||||
return None
|
||||
|
||||
capabilities = event.delivery.platform_capabilities or {}
|
||||
adapter_identity = None
|
||||
if query is not None:
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
if adapter is not None:
|
||||
adapter_identity = adapter.__class__.__name__
|
||||
adapter_identity = _first_present(
|
||||
adapter_identity,
|
||||
capabilities.get('adapter'),
|
||||
capabilities.get('source'),
|
||||
event.source if query is None else None,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'instance_id': _nonempty(constants.instance_id),
|
||||
'workspace_id': _nonempty(event.workspace_id),
|
||||
'bot_id': _nonempty(event.bot_id),
|
||||
'platform_adapter': adapter_identity,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'thread_id': _nonempty(event.thread_id),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_session_identity(
|
||||
event: AgentEventEnvelope,
|
||||
) -> tuple[provider_session.LauncherTypes, str, str]:
|
||||
@@ -249,42 +189,6 @@ def _resolve_session_identity(
|
||||
return provider_session.LauncherTypes(launcher_type_value), launcher_id, sender_id
|
||||
|
||||
|
||||
def _resolve_box_target(
|
||||
event: AgentEventEnvelope,
|
||||
query: pipeline_query.Query | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if query is not None:
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
launcher_type = launcher_type.value
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
normalized_type = _nonempty(launcher_type)
|
||||
normalized_id = _nonempty(launcher_id)
|
||||
if normalized_type is not None and normalized_id is not None:
|
||||
return normalized_type, normalized_id
|
||||
|
||||
reply_target = event.delivery.reply_target or {}
|
||||
target_type = _first_present(
|
||||
reply_target.get('target_type'),
|
||||
reply_target.get('launcher_type'),
|
||||
)
|
||||
target_id = _first_present(
|
||||
reply_target.get('target_id'),
|
||||
reply_target.get('launcher_id'),
|
||||
)
|
||||
if target_type is not None and target_id is not None:
|
||||
return target_type, target_id
|
||||
|
||||
conversation_id = _nonempty(event.conversation_id)
|
||||
if conversation_id is not None:
|
||||
return 'conversation', conversation_id
|
||||
|
||||
event_id = _nonempty(event.event_id)
|
||||
if event_id is not None:
|
||||
return 'event', event_id
|
||||
return None, None
|
||||
|
||||
|
||||
def _build_message_chain(event: AgentEventEnvelope) -> platform_message.MessageChain:
|
||||
text = event.input.to_text()
|
||||
if not text:
|
||||
|
||||
@@ -17,6 +17,18 @@ from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
|
||||
|
||||
class LegacyRunnerIdentity(pydantic.BaseModel):
|
||||
"""Host-captured native Query identity; never populated from event data/params."""
|
||||
|
||||
workspace_id: str | None = None
|
||||
bot_id: str | None = None
|
||||
pipeline_id: str | None = None
|
||||
query_launcher_type: str | None = None
|
||||
query_launcher_id: str | None = None
|
||||
session_launcher_type: str | None = None
|
||||
session_launcher_id: str | None = None
|
||||
|
||||
|
||||
class AgentEventEnvelope(pydantic.BaseModel):
|
||||
"""Event envelope for LangBot Host event gateway.
|
||||
|
||||
@@ -69,6 +81,9 @@ class AgentEventEnvelope(pydantic.BaseModel):
|
||||
data: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||
"""Small structured event payload. Large payloads should be referenced via raw_ref."""
|
||||
|
||||
legacy_identity: LegacyRunnerIdentity | None = None
|
||||
"""Host-only provenance, separate from untrusted event payload and adapter params."""
|
||||
|
||||
|
||||
# Binding scope types
|
||||
class BindingScope(pydantic.BaseModel):
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics
|
||||
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
import typing
|
||||
@@ -14,7 +15,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
|
||||
from .descriptor import RunnerDescriptor
|
||||
from .errors import RunnerProtocolError
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
from .host_models import AgentBinding, AgentEventEnvelope, LegacyRunnerIdentity
|
||||
from .interaction_store import InteractionStore
|
||||
|
||||
|
||||
@@ -152,7 +153,12 @@ class InteractionManager:
|
||||
runner_id=descriptor.id,
|
||||
processor_type=binding.processor_type,
|
||||
processor_id=processor_id,
|
||||
request=request_data,
|
||||
request={
|
||||
**request_data,
|
||||
'_host_legacy_identity': event.legacy_identity.model_dump(mode='json')
|
||||
if event.legacy_identity is not None
|
||||
else None,
|
||||
},
|
||||
delivery_target=reply_target,
|
||||
replaces_interaction_id=(update_target or {}).get('interaction_id'),
|
||||
bot_id=event.bot_id,
|
||||
@@ -161,6 +167,7 @@ class InteractionManager:
|
||||
thread_id=event.thread_id,
|
||||
actor_id=event.actor.actor_id if event.actor else None,
|
||||
expires_at=expires_at,
|
||||
**self._pipeline_admission(binding, descriptor, adapter_context),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -204,6 +211,50 @@ class InteractionManager:
|
||||
diagnostics.set_outcome('waiting', reason_code='waiting')
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _pipeline_admission(binding, descriptor, adapter_context):
|
||||
if binding.processor_type != 'pipeline':
|
||||
return {}
|
||||
query = (adapter_context or {}).get('_query')
|
||||
expected = copy.deepcopy((adapter_context or {}).get('_pipeline_expected_config'))
|
||||
session = getattr(query, 'session', None)
|
||||
conversation = (adapter_context or {}).get('_pipeline_conversation')
|
||||
|
||||
def authority():
|
||||
from .config_resolver import RunnerConfigResolver
|
||||
|
||||
return (
|
||||
query is not None
|
||||
and session is not None
|
||||
and conversation is not None
|
||||
and isinstance(expected, dict)
|
||||
and session.using_conversation is conversation
|
||||
and getattr(query, 'pipeline_uuid', None) == binding.processor_id
|
||||
and RunnerConfigResolver.resolve_runner_id(expected) == descriptor.id == binding.runner_id
|
||||
and RunnerConfigResolver.resolve_runner_config(expected, descriptor.id) == binding.runner_config
|
||||
)
|
||||
|
||||
return {'expected_config': expected, 'authority_check': authority}
|
||||
|
||||
async def restore_legacy_identity(self, event: AgentEventEnvelope, binding: AgentBinding) -> None:
|
||||
"""Resume only from the scoped, consumed Host record, never callback fields."""
|
||||
event.legacy_identity = None
|
||||
submission = getattr(event.input, 'interaction', None)
|
||||
if submission is None:
|
||||
return
|
||||
if hasattr(submission, 'model_dump'):
|
||||
submission = submission.model_dump(mode='json')
|
||||
record = await self.store.find_resume_request(event=event, binding=binding, submission=submission)
|
||||
if record is None:
|
||||
return
|
||||
identity = record['request'].get('_host_legacy_identity')
|
||||
if not isinstance(identity, dict):
|
||||
return
|
||||
try:
|
||||
event.legacy_identity = LegacyRunnerIdentity.model_validate(identity)
|
||||
except pydantic.ValidationError:
|
||||
return
|
||||
|
||||
@diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack')
|
||||
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
|
||||
"""Best-effort transition of submitted controls into a read-only state."""
|
||||
|
||||
@@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ...entity.persistence.agent_interaction import AgentInteraction
|
||||
from ...persistence.tenant_uow import TenantUnitOfWork
|
||||
from ...persistence.pipeline_admission import lock_pipeline_admission
|
||||
|
||||
|
||||
UTC = datetime.timezone.utc
|
||||
@@ -49,6 +51,12 @@ def _utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(UTC)
|
||||
|
||||
|
||||
def _db_datetime(value):
|
||||
# Existing schema is TIMESTAMP WITHOUT TIME ZONE; persist UTC naive on
|
||||
# both backends and normalize to aware UTC only for application comparisons.
|
||||
return _as_utc(value).replace(tzinfo=None) if value is not None else None
|
||||
|
||||
|
||||
def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -113,6 +121,8 @@ class InteractionStore:
|
||||
thread_id: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
expires_at: int | float | None = None,
|
||||
expected_config: dict | None = None,
|
||||
authority_check: typing.Callable[[], bool] | None = None,
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
"""Persist a request and return its record plus one-time callback token."""
|
||||
if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id:
|
||||
@@ -145,11 +155,32 @@ class InteractionStore:
|
||||
delivery_target_json=delivery_target_json,
|
||||
replaces_interaction_id=replaces_interaction_id,
|
||||
callback_token_hash=_token_hash(callback_token),
|
||||
expires_at=expires_at_dt,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
expires_at=_db_datetime(expires_at_dt),
|
||||
created_at=_db_datetime(now),
|
||||
updated_at=_db_datetime(now),
|
||||
)
|
||||
|
||||
if processor_type == 'pipeline':
|
||||
# Require Host-captured configuration and live conversation authority;
|
||||
# runner output must never supply either of these admission inputs.
|
||||
if not workspace_id or not isinstance(expected_config, dict) or authority_check is None:
|
||||
raise InteractionScopeError('Pipeline interaction authority unavailable')
|
||||
try:
|
||||
async with TenantUnitOfWork(self.engine, workspace_id) as uow:
|
||||
current = await lock_pipeline_admission(uow.session, workspace_id, processor_id)
|
||||
if current != expected_config or authority_check() is not True:
|
||||
raise InteractionScopeError('Pipeline interaction authority changed')
|
||||
existing = await self._get_by_run_interaction(uow.session, run_id, interaction_id)
|
||||
if existing is not None:
|
||||
raise DuplicateInteractionError('Interaction already exists')
|
||||
uow.session.add(row)
|
||||
await uow.session.flush()
|
||||
return self._to_dict(row), callback_token
|
||||
except ValueError as exc:
|
||||
raise InteractionScopeError('Pipeline interaction authority unavailable') from exc
|
||||
except IntegrityError as exc:
|
||||
raise DuplicateInteractionError('Interaction already exists') from exc
|
||||
|
||||
async with self._session_factory() as session:
|
||||
existing = await self._get_by_run_interaction(session, run_id, interaction_id)
|
||||
if existing is not None:
|
||||
@@ -182,7 +213,7 @@ class InteractionStore:
|
||||
AgentInteraction.interaction_id == interaction_id,
|
||||
AgentInteraction.status.in_(['pending', 'submitted']),
|
||||
)
|
||||
.values(delivery_result_json=payload, updated_at=_utc_now())
|
||||
.values(delivery_result_json=payload, updated_at=_db_datetime(_utc_now()))
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
@@ -228,6 +259,38 @@ class InteractionStore:
|
||||
row = result.scalar_one_or_none()
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
async def find_resume_request(self, *, event, binding, submission) -> dict[str, typing.Any] | None:
|
||||
"""Resolve persisted provenance with exact tenant/processor/callback scope.
|
||||
|
||||
Ambiguous identities fail closed instead of picking the newest run.
|
||||
"""
|
||||
if not isinstance(submission, dict) or not submission.get('interaction_id'):
|
||||
return None
|
||||
target = event.delivery.reply_target or {}
|
||||
target_type, target_id = target.get('target_type'), target.get('target_id')
|
||||
conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id
|
||||
conditions = [
|
||||
AgentInteraction.interaction_id == submission['interaction_id'],
|
||||
AgentInteraction.status == 'submitted',
|
||||
AgentInteraction.binding_id == binding.binding_id,
|
||||
AgentInteraction.runner_id == binding.runner_id,
|
||||
AgentInteraction.processor_type == binding.processor_type,
|
||||
AgentInteraction.processor_id == (binding.processor_id or binding.agent_id),
|
||||
]
|
||||
for column, value in (
|
||||
(AgentInteraction.workspace_id, event.workspace_id),
|
||||
(AgentInteraction.bot_id, event.bot_id),
|
||||
(AgentInteraction.conversation_id, conversation_id),
|
||||
(AgentInteraction.thread_id, event.thread_id),
|
||||
(AgentInteraction.actor_id, event.actor.actor_id if event.actor else None),
|
||||
):
|
||||
conditions.append(column.is_(None) if value is None else column == value)
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(sqlalchemy.select(AgentInteraction).where(*conditions))
|
||||
records = [self._to_dict(row) for row in result.scalars()]
|
||||
matches = [record for record in records if record['submission'] == submission]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None:
|
||||
"""Return a request by the runner-visible identity."""
|
||||
async with self._session_factory() as session:
|
||||
@@ -283,7 +346,7 @@ class InteractionStore:
|
||||
if expires_at is not None and expires_at <= now:
|
||||
row.status = 'expired'
|
||||
row.status_reason = 'interaction expired before submission'
|
||||
row.updated_at = now
|
||||
row.updated_at = _db_datetime(now)
|
||||
await session.commit()
|
||||
raise InteractionExpiredError('Interaction has expired')
|
||||
|
||||
@@ -297,9 +360,9 @@ class InteractionStore:
|
||||
)
|
||||
.values(
|
||||
status='submitted',
|
||||
submitted_at=submission_time,
|
||||
submitted_at=_db_datetime(submission_time),
|
||||
submission_json=_json_dumps(submission),
|
||||
updated_at=now,
|
||||
updated_at=_db_datetime(now),
|
||||
)
|
||||
)
|
||||
if update_result.rowcount != 1:
|
||||
@@ -323,7 +386,7 @@ class InteractionStore:
|
||||
AgentInteraction.interaction_id == interaction_id,
|
||||
AgentInteraction.status == 'pending',
|
||||
)
|
||||
.values(status='delivery_failed', status_reason=reason, updated_at=now)
|
||||
.values(status='delivery_failed', status_reason=reason, updated_at=_db_datetime(now))
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
@@ -337,12 +400,12 @@ class InteractionStore:
|
||||
.where(
|
||||
AgentInteraction.status == 'pending',
|
||||
AgentInteraction.expires_at.is_not(None),
|
||||
AgentInteraction.expires_at <= cutoff,
|
||||
AgentInteraction.expires_at <= _db_datetime(cutoff),
|
||||
)
|
||||
.values(
|
||||
status='expired',
|
||||
status_reason='interaction expired',
|
||||
updated_at=cutoff,
|
||||
updated_at=_db_datetime(cutoff),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -23,7 +23,6 @@ from .execution_context import (
|
||||
append_mcp_resource_context_to_event,
|
||||
build_mcp_resource_context_addition,
|
||||
build_execution_query,
|
||||
prepare_box_scope,
|
||||
prepare_execution_query,
|
||||
project_mcp_resource_config,
|
||||
)
|
||||
@@ -121,6 +120,16 @@ class AgentRunOrchestrator:
|
||||
project_mcp_resource_config(execution_query, binding.runner_config)
|
||||
object.__setattr__(execution_query, '_execution_context', execution_context)
|
||||
|
||||
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') in (
|
||||
'legacy-session',
|
||||
'legacy-bot',
|
||||
):
|
||||
await self.interaction_manager.restore_legacy_identity(event, binding)
|
||||
|
||||
from ...box.runner import prepare_input_files
|
||||
|
||||
event = event.model_copy(deep=True)
|
||||
prepare_input_files(execution_query, event.input)
|
||||
execution_event = event
|
||||
resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query)
|
||||
if resource_addition:
|
||||
@@ -159,6 +168,12 @@ class AgentRunOrchestrator:
|
||||
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
|
||||
]
|
||||
prepare_execution_query(execution_query, event, authorized_skill_names)
|
||||
context['variables'] = {
|
||||
key: value
|
||||
for key, value in (execution_query.variables or {}).items()
|
||||
if isinstance(key, str) and not key.startswith('_') and isinstance(value, (str, int, float, bool))
|
||||
}
|
||||
context['variables']['query_id'] = execution_query.query_id
|
||||
|
||||
state_context = build_state_context(event, binding, descriptor)
|
||||
run_id = context['run_id']
|
||||
@@ -356,6 +371,17 @@ class AgentRunOrchestrator:
|
||||
assistant_transcript_written = True
|
||||
|
||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
file_ids = result_dict.get('data', {}).get('file_ids', [])
|
||||
if file_ids:
|
||||
if result_type != 'message.completed' or result is None:
|
||||
raise ValueError('Files must be attached to a completed message')
|
||||
if binding.processor_type != 'pipeline':
|
||||
raise ValueError('Agent files must be sent explicitly through the reply API')
|
||||
from ...box.runner import exported_message, binding_for
|
||||
|
||||
async with binding_for(execution_query).lock:
|
||||
result.attachments = exported_message(execution_query, file_ids, consume=True)
|
||||
|
||||
if result is not None:
|
||||
yield result
|
||||
|
||||
@@ -387,6 +413,9 @@ class AgentRunOrchestrator:
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
binding_box = getattr(execution_query, '_box_binding', None)
|
||||
if binding_box is not None and binding_box.run_id == run_id:
|
||||
object.__delattr__(execution_query, '_box_binding')
|
||||
session = await self._session_registry.unregister(run_id)
|
||||
await reply_streams.close()
|
||||
pending_steering = session.get('steering_queue', []) if session else []
|
||||
@@ -412,14 +441,12 @@ class AgentRunOrchestrator:
|
||||
plan = self.query_bridge.build_plan(query)
|
||||
adapter_context = dict(plan.adapter_context)
|
||||
adapter_context['_query'] = query
|
||||
import copy
|
||||
|
||||
adapter_context['_pipeline_expected_config'] = copy.deepcopy(query.pipeline_config)
|
||||
adapter_context['_pipeline_conversation'] = getattr(getattr(query, 'session', None), 'using_conversation', None)
|
||||
adapter_context['_execution_context'] = get_query_execution_context(query)
|
||||
|
||||
# Inbound files and subsequent runner tools must share one Host scope.
|
||||
prepare_box_scope(query, plan.event)
|
||||
|
||||
# Materialize inbound attachments into sandbox before running
|
||||
await self._materialize_inbound_attachments(query, plan.event)
|
||||
|
||||
async with contextlib.aclosing(
|
||||
self.run(
|
||||
plan.event,
|
||||
@@ -431,48 +458,6 @@ class AgentRunOrchestrator:
|
||||
async for result in results:
|
||||
yield result
|
||||
|
||||
async def _materialize_inbound_attachments(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
event: AgentEventEnvelope,
|
||||
) -> None:
|
||||
"""Persist inbound attachments into the sandbox and update event.input.attachments.
|
||||
|
||||
No-op when the box service is unavailable or there are no attachments.
|
||||
On success, updates each attachment in event.input.attachments with the
|
||||
sandbox path so runners can tell the model where to find the files.
|
||||
"""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
return
|
||||
|
||||
try:
|
||||
materialized = await box_service.materialize_inbound_attachments(query)
|
||||
except Exception as e:
|
||||
# Never break the chat turn over attachment IO
|
||||
self.ap.logger.warning(f'Inbound attachment materialization failed: {e}')
|
||||
return
|
||||
|
||||
if not materialized:
|
||||
return
|
||||
|
||||
# Build a lookup by name for matching
|
||||
materialized_by_name = {att.get('name'): att for att in materialized if att.get('name')}
|
||||
|
||||
# Update event.input.attachments with sandbox paths
|
||||
if event.input and event.input.attachments:
|
||||
for attachment in event.input.attachments:
|
||||
name = attachment.name
|
||||
if name and name in materialized_by_name:
|
||||
mat = materialized_by_name[name]
|
||||
# Update the attachment with sandbox path
|
||||
attachment.path = mat.get('path')
|
||||
attachment.size = mat.get('size') or attachment.size
|
||||
attachment.mime_type = attachment.mime_type or mat.get('mime_type')
|
||||
|
||||
# Store materialized descriptors in query variables for downstream use
|
||||
query.variables['_sandbox_inbound_attachments'] = materialized
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
||||
"""Resolve runner ID for telemetry/logging without full execution."""
|
||||
return self.query_bridge.resolve_runner_id_for_telemetry(query)
|
||||
|
||||
@@ -25,6 +25,7 @@ from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||||
from .host_models import (
|
||||
AgentConfig,
|
||||
AgentEventEnvelope,
|
||||
LegacyRunnerIdentity,
|
||||
StatePolicy,
|
||||
DeliveryPolicy,
|
||||
)
|
||||
@@ -100,6 +101,24 @@ class QueryEntryAdapter:
|
||||
delivery=delivery,
|
||||
raw_ref=raw_ref,
|
||||
data=event.data,
|
||||
legacy_identity=cls._build_legacy_identity(query, execution_context.workspace_uuid),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_legacy_identity(query, workspace_id: str) -> LegacyRunnerIdentity:
|
||||
def exact(value):
|
||||
value = getattr(value, 'value', value)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
session = getattr(query, 'session', None)
|
||||
return LegacyRunnerIdentity(
|
||||
workspace_id=workspace_id,
|
||||
bot_id=exact(getattr(query, 'bot_uuid', None)),
|
||||
pipeline_id=exact(getattr(query, 'pipeline_uuid', None)),
|
||||
query_launcher_type=exact(getattr(query, 'launcher_type', None)),
|
||||
query_launcher_id=exact(getattr(query, 'launcher_id', None)),
|
||||
session_launcher_type=exact(getattr(session, 'launcher_type', None)),
|
||||
session_launcher_id=exact(getattr(session, 'launcher_id', None)),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -83,6 +83,7 @@ class AgentRunJournal:
|
||||
run_id=context['run_id'],
|
||||
event_id=event.event_id,
|
||||
binding_id=binding.binding_id,
|
||||
agent_id=binding.agent_id,
|
||||
runner_id=descriptor.id,
|
||||
conversation_id=event.conversation_id,
|
||||
thread_id=event.thread_id,
|
||||
@@ -100,6 +101,19 @@ class AgentRunJournal:
|
||||
if binding.processor_type == 'event_processor'
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
**({'input_event': event.data} if event.event_type != 'message.received' else {}),
|
||||
'input': {
|
||||
'text': context.get('input', {}).get('text', ''),
|
||||
'contents': self._sanitize_contents(context.get('input', {}).get('contents', [])),
|
||||
'attachments': self._sanitize_attachments(context.get('input', {}).get('attachments', [])),
|
||||
},
|
||||
'delivery': event.delivery.model_dump(mode='json'),
|
||||
}
|
||||
if binding.processor_type == 'agent'
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -636,6 +636,7 @@ class RunLedgerStore:
|
||||
strict_thread: bool = False,
|
||||
runner_id: str | None = None,
|
||||
binding_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[list[dict[str, typing.Any]], int | None, bool, int]:
|
||||
"""Page runs by scope.
|
||||
|
||||
@@ -643,9 +644,21 @@ class RunLedgerStore:
|
||||
Tuple of (items, next_cursor, has_more, total_count).
|
||||
"""
|
||||
limit = min(max(int(limit), 1), 100)
|
||||
agent_filter = sqlalchemy.or_(
|
||||
AgentRun.agent_id == agent_id,
|
||||
sqlalchemy.and_(
|
||||
AgentRun.agent_id.is_(None),
|
||||
sqlalchemy.or_(
|
||||
AgentRun.binding_id.startswith(f'agent_{agent_id}_', autoescape=True),
|
||||
AgentRun.binding_id.startswith(f'debug:{agent_id}:', autoescape=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
# First get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(AgentRun.id))
|
||||
if agent_id is not None:
|
||||
count_query = count_query.where(agent_filter)
|
||||
if conversation_id is not None:
|
||||
count_query = count_query.where(AgentRun.conversation_id == conversation_id)
|
||||
if statuses:
|
||||
@@ -660,6 +673,8 @@ class RunLedgerStore:
|
||||
|
||||
# Then get items
|
||||
query = sqlalchemy.select(AgentRun)
|
||||
if agent_id is not None:
|
||||
query = query.where(agent_filter)
|
||||
if conversation_id is not None:
|
||||
query = query.where(AgentRun.conversation_id == conversation_id)
|
||||
if statuses:
|
||||
|
||||
@@ -15,6 +15,7 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
from ....core.errors import TaskCapacityError
|
||||
from ....provider.modelmgr.codex_errors import CodexProviderError
|
||||
from ..authz import (
|
||||
AuthenticationDeniedError,
|
||||
AuthorizationError,
|
||||
@@ -249,6 +250,8 @@ class RouterGroup(abc.ABC):
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
except Exception as e: # 自动 500
|
||||
if isinstance(e, CodexProviderError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, AuthorizationError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, WorkspaceNotFoundError):
|
||||
|
||||
@@ -47,6 +47,21 @@ class FilesRouterGroup(group.RouterGroup):
|
||||
|
||||
return quart.Response(image_bytes, mimetype=mime_type)
|
||||
|
||||
@self.route(
|
||||
'/media/<filename>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.NONE,
|
||||
)
|
||||
async def get_media_file(filename: str) -> quart.Response:
|
||||
media = await self.ap.storage_mgr.media_cache.get_media(filename)
|
||||
if media is None:
|
||||
return quart.Response('Media not found or expired', status=404)
|
||||
media_bytes, mime_type = media
|
||||
headers = {
|
||||
'Cache-Control': 'public, max-age=2592000, immutable',
|
||||
}
|
||||
return quart.Response(media_bytes, mimetype=mime_type, headers=headers)
|
||||
|
||||
@self.route(
|
||||
'/images',
|
||||
methods=['POST'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import quart
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...service.monitoring_traffic import get_traffic_series
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -218,6 +219,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
user_query = quart.request.args.get('userQuery')
|
||||
is_active_str = quart.request.args.get('isActive')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
@@ -237,6 +239,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
user_query=user_query,
|
||||
is_active=is_active,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -375,6 +378,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'traffic': await get_traffic_series(
|
||||
self.ap,
|
||||
request_context,
|
||||
bot_ids=bot_ids or None,
|
||||
pipeline_ids=pipeline_ids or None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
),
|
||||
'overview': overview,
|
||||
'messages': messages,
|
||||
'llmCalls': llm_calls,
|
||||
@@ -396,7 +407,15 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Get detailed analysis for a specific session"""
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
||||
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||
request_context,
|
||||
session_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
bot_id=quart.request.args.get('botId'),
|
||||
)
|
||||
|
||||
# Always return success with the analysis data
|
||||
# The frontend will handle the 'found: false' case
|
||||
|
||||
@@ -199,7 +199,9 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
|
||||
base_url = quart.request.host_url.rstrip('/')
|
||||
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
||||
if webhook_prefix:
|
||||
# The wizard previews the currently connected backend, which can
|
||||
# differ from the public address used by external website embeds.
|
||||
if webhook_prefix and quart.request.args.get('preview') != 'wizard':
|
||||
base_url = webhook_prefix.rstrip('/')
|
||||
|
||||
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""User-confirmed migration only; deliberately not API-key/MCP accessible."""
|
||||
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ....authz import Permission
|
||||
from ....context import RequestContext
|
||||
from ....service.pipeline_migration import PipelineMigrationService, MigrationError
|
||||
|
||||
|
||||
@group.group_class('pipeline_migration', '/api/v1/pipelines/_/migration')
|
||||
class PipelineMigrationRouterGroup(group.RouterGroup):
|
||||
async def initialize(self):
|
||||
self.service = PipelineMigrationService(self.ap)
|
||||
|
||||
@self.route(
|
||||
'/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_VIEW
|
||||
)
|
||||
async def preview(request_context: RequestContext):
|
||||
try:
|
||||
return self.success(data=await self.service.preview(request_context))
|
||||
except MigrationError as exc:
|
||||
return self.http_status(exc.status_code, -1, exc.code)
|
||||
|
||||
@self.route(
|
||||
'/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_MANAGE
|
||||
)
|
||||
async def execute(request_context: RequestContext):
|
||||
try:
|
||||
body = await quart.request.get_json(silent=True)
|
||||
return self.success(data=await self.service.execute(request_context, body))
|
||||
except MigrationError as exc:
|
||||
return self.http_status(exc.status_code, -1, exc.code)
|
||||
@@ -4,6 +4,7 @@ from langbot.pkg.provider.modelmgr import errors as provider_errors
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
|
||||
MODEL_TEST_ERROR_CODE = 'model_test_failed'
|
||||
@@ -20,7 +21,12 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
if provider_uuid:
|
||||
models = await self.ap.llm_model_service.get_llm_models_by_provider(
|
||||
request_context,
|
||||
@@ -57,10 +63,16 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
model = await self.ap.llm_model_service.get_llm_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
@@ -106,7 +118,7 @@ class LLMModelsRouterGroup(group.RouterGroup):
|
||||
model_uuid,
|
||||
await quart.request.json,
|
||||
)
|
||||
except (provider_errors.RequesterError, ValueError) as exc:
|
||||
except provider_errors.RequesterError as exc:
|
||||
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
|
||||
return self.success()
|
||||
|
||||
@@ -122,7 +134,12 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
if provider_uuid:
|
||||
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
|
||||
request_context,
|
||||
@@ -159,10 +176,16 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
model = await self.ap.embedding_models_service.get_embedding_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
@@ -208,7 +231,7 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
|
||||
model_uuid,
|
||||
await quart.request.json,
|
||||
)
|
||||
except (provider_errors.RequesterError, ValueError) as exc:
|
||||
except provider_errors.RequesterError as exc:
|
||||
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
|
||||
return self.success()
|
||||
|
||||
@@ -224,7 +247,12 @@ class RerankModelsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
provider_uuid = quart.request.args.get('provider_uuid')
|
||||
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
if provider_uuid:
|
||||
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
|
||||
request_context,
|
||||
@@ -261,10 +289,16 @@ class RerankModelsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(model_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
model = await self.ap.rerank_models_service.get_rerank_model(
|
||||
request_context,
|
||||
model_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if model is None:
|
||||
return self.http_status(404, -1, 'model not found')
|
||||
@@ -310,6 +344,6 @@ class RerankModelsRouterGroup(group.RouterGroup):
|
||||
model_uuid,
|
||||
await quart.request.json,
|
||||
)
|
||||
except (provider_errors.RequesterError, ValueError) as exc:
|
||||
except provider_errors.RequesterError as exc:
|
||||
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
|
||||
return self.success()
|
||||
|
||||
@@ -3,11 +3,86 @@ import quart
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
from .query import resolve_include_secret
|
||||
|
||||
|
||||
@group.group_class('models/providers', '/api/v1/provider/providers')
|
||||
class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
# Subscription authorization is an interactive, browser-user-only surface.
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/status',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_status(provider_uuid: str, request_context: RequestContext):
|
||||
try:
|
||||
return self.success(
|
||||
data=await self.ap.provider_service.codex_auth.status(request_context, provider_uuid)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/device',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_device(provider_uuid: str, request_context: RequestContext):
|
||||
try:
|
||||
return self.success(
|
||||
data=await self.ap.provider_service.codex_auth.start(request_context, provider_uuid)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/device/poll',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_poll(provider_uuid: str, request_context: RequestContext):
|
||||
body = await quart.request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return self.http_status(400, -1, 'JSON object required')
|
||||
try:
|
||||
return self.success(
|
||||
data=await self.ap.provider_service.codex_auth.poll(
|
||||
request_context, provider_uuid, body.get('authorization_id')
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/auth',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_disconnect(provider_uuid: str, request_context: RequestContext):
|
||||
try:
|
||||
await self.ap.provider_service.codex_auth.disconnect(request_context, provider_uuid)
|
||||
return self.success()
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'/<provider_uuid>/codex/device/<authorization_id>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.PROVIDER_SECRET_MANAGE,
|
||||
)
|
||||
async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext):
|
||||
try:
|
||||
await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id)
|
||||
return self.success()
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
@@ -15,9 +90,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
providers = await self.ap.provider_service.get_providers(
|
||||
request_context,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
for provider in providers:
|
||||
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
|
||||
@@ -47,10 +128,16 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret, error = resolve_include_secret(
|
||||
quart.request.args.get('include_secret'),
|
||||
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
)
|
||||
if error:
|
||||
return self.http_status(400, -1, error)
|
||||
provider = await self.ap.provider_service.get_provider(
|
||||
request_context,
|
||||
provider_uuid,
|
||||
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if provider is None:
|
||||
return self.http_status(404, -1, 'provider not found')
|
||||
@@ -82,7 +169,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
|
||||
cascade_values = quart.request.args.getlist('cascade')
|
||||
if cascade_values:
|
||||
if len(cascade_values) != 1 or cascade_values[0] not in ('true', 'false'):
|
||||
return self.http_status(400, -1, 'cascade must be a single true or false value')
|
||||
await self.ap.provider_service.delete_provider(
|
||||
request_context, provider_uuid, cascade=cascade_values[0] == 'true'
|
||||
)
|
||||
else:
|
||||
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
|
||||
return self.success()
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def resolve_include_secret(raw_value: str | None, *, permitted: bool) -> tuple[bool, str | None]:
|
||||
"""Resolve the optional secret projection query parameter."""
|
||||
|
||||
if raw_value is None:
|
||||
return permitted, None
|
||||
|
||||
value = raw_value.strip().lower()
|
||||
if value == 'false':
|
||||
return False, None
|
||||
if value == 'true':
|
||||
return permitted, None
|
||||
return False, 'include_secret must be either true or false'
|
||||
@@ -7,14 +7,116 @@ from .. import group
|
||||
from .....utils import constants
|
||||
from .....entity.persistence.metadata import WorkspaceMetadata
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from ...context import PrincipalType, RequestContext
|
||||
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
|
||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
||||
|
||||
|
||||
SYSTEM_CAPABILITY_OPERATIONS = (
|
||||
'bot.list',
|
||||
'bot.get',
|
||||
'bot.create',
|
||||
'bot.update',
|
||||
'bot.delete',
|
||||
'pipeline.list',
|
||||
'pipeline.get',
|
||||
'pipeline.create',
|
||||
'pipeline.update',
|
||||
'pipeline.delete',
|
||||
'pipeline.copy',
|
||||
'task.list',
|
||||
'task.get',
|
||||
'knowledge_base.list',
|
||||
'knowledge_base.get',
|
||||
'knowledge_base.create',
|
||||
'knowledge_base.update',
|
||||
'knowledge_base.delete',
|
||||
'knowledge_base.file.list',
|
||||
'knowledge_base.file.store',
|
||||
'knowledge_base.file.delete',
|
||||
'knowledge_base.retrieve',
|
||||
'file.document.upload',
|
||||
'plugin.install.github',
|
||||
'plugin.install.marketplace',
|
||||
'plugin.install.local',
|
||||
'plugin.upgrade',
|
||||
'plugin.get',
|
||||
'plugin.list',
|
||||
'plugin.config.get',
|
||||
'plugin.config.update',
|
||||
'plugin.logs',
|
||||
'plugin.delete',
|
||||
'provider.list',
|
||||
'provider.get',
|
||||
'provider.create',
|
||||
'provider.update',
|
||||
'provider.delete',
|
||||
'provider.scan_models',
|
||||
'model.llm.list',
|
||||
'model.llm.get',
|
||||
'model.llm.create',
|
||||
'model.llm.update',
|
||||
'model.llm.delete',
|
||||
'model.llm.test',
|
||||
'model.embedding.list',
|
||||
'model.embedding.get',
|
||||
'model.embedding.create',
|
||||
'model.embedding.update',
|
||||
'model.embedding.delete',
|
||||
'model.embedding.test',
|
||||
'model.rerank.list',
|
||||
'model.rerank.get',
|
||||
'model.rerank.create',
|
||||
'model.rerank.update',
|
||||
'model.rerank.delete',
|
||||
'model.rerank.test',
|
||||
'skill.list',
|
||||
'skill.get',
|
||||
'skill.create',
|
||||
'skill.update',
|
||||
'skill.delete',
|
||||
'skill.files.list',
|
||||
'skill.files.read',
|
||||
'skill.files.write',
|
||||
'skill.preview',
|
||||
'skill.install.github',
|
||||
'skill.install.upload',
|
||||
'mcp_server.list',
|
||||
'mcp_server.get',
|
||||
'mcp_server.create',
|
||||
'mcp_server.update',
|
||||
'mcp_server.delete',
|
||||
'mcp_server.resources',
|
||||
'mcp_server.resource_templates',
|
||||
'mcp_server.resource_read',
|
||||
'mcp_server.logs',
|
||||
'mcp_server.test',
|
||||
)
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
class SystemRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
return self.success(
|
||||
data={
|
||||
'instance_uuid': request_context.instance_uuid,
|
||||
'workspace_uuid': request_context.workspace_uuid,
|
||||
'api_key_id': request_context.principal.api_key_uuid,
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY)
|
||||
async def _() -> str:
|
||||
return self.success(
|
||||
data={
|
||||
'schema_version': 1,
|
||||
'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS},
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
# Read wizard_status and wizard_progress from metadata table
|
||||
@@ -238,7 +340,7 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
@@ -257,18 +359,23 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
public=request_context.principal.principal_type == PrincipalType.API_KEY,
|
||||
)
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/tasks/<task_id>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(task_id: str, request_context: RequestContext) -> str:
|
||||
try:
|
||||
task_index = int(task_id)
|
||||
except (TypeError, ValueError):
|
||||
return self.http_status(404, 404, 'Task not found')
|
||||
task = self.ap.task_mgr.get_task_by_id(
|
||||
int(task_id),
|
||||
task_index,
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
@@ -277,6 +384,8 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
if task is None:
|
||||
return self.http_status(404, 404, 'Task not found')
|
||||
|
||||
if request_context.principal.principal_type == PrincipalType.API_KEY:
|
||||
return self.success(data=task.to_public_dict())
|
||||
return self.success(data=task.to_dict())
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import datetime
|
||||
import hmac
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -11,6 +16,33 @@ from ...context import RequestContext
|
||||
from .....cloud.launch import SpaceLaunchError
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
|
||||
# The admission check and slot bump share ONE synchronous critical section with no await
|
||||
# points, so concurrent bursts within a single event loop cannot slip past accounting.
|
||||
# Every admitted attempt consumes quota (regardless of success), which throttles both the
|
||||
# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys.
|
||||
# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream.
|
||||
_MAX_RESET_ATTEMPTS_PER_WINDOW = 5
|
||||
_RESET_WINDOW_SECONDS = 15 * 60
|
||||
|
||||
_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0}
|
||||
|
||||
|
||||
def _admit_reset_attempt(now: float) -> bool:
|
||||
"""Atomically reserve one reset-password admission slot.
|
||||
|
||||
Must stay await-free: running to completion without suspension makes the
|
||||
check-and-increment atomic under the single-threaded event loop.
|
||||
"""
|
||||
st = _reset_password_state
|
||||
if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS:
|
||||
st['window_started_at'] = now
|
||||
st['attempts'] = 0
|
||||
if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW:
|
||||
return False
|
||||
st['attempts'] += 1
|
||||
return True
|
||||
|
||||
|
||||
@group.group_class('user', '/api/v1/user')
|
||||
class UserRouterGroup(group.RouterGroup):
|
||||
@@ -35,6 +67,22 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
return redirect_uri
|
||||
|
||||
def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]:
|
||||
origin = ''
|
||||
if json_data and isinstance(json_data, dict):
|
||||
origin = json_data.get('origin', '')
|
||||
if not origin:
|
||||
origin = quart.request.headers.get('Origin', '')
|
||||
if not origin:
|
||||
origin = quart.request.headers.get('Referer', '')
|
||||
if not origin:
|
||||
origin = quart.request.url_root.rstrip('/')
|
||||
|
||||
parsed = urlsplit(origin)
|
||||
rp_id = parsed.hostname or 'localhost'
|
||||
clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/')
|
||||
return clean_origin, rp_id
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
@@ -81,6 +129,12 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
# Admit (or reject) BEFORE touching the body or any service call (#2392):
|
||||
# rejecting requests never reach the slow path, and quota accounting happens
|
||||
# synchronously at entry, closing the post-await race of burst requests.
|
||||
if not _admit_reset_attempt(time.monotonic()):
|
||||
return self.http_status(429, -1, 'Too many attempts, try again later')
|
||||
|
||||
json_data = await quart.request.json
|
||||
|
||||
user_email = json_data['user']
|
||||
@@ -98,7 +152,18 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if user_obj is None:
|
||||
return self.http_status(400, -1, 'User not found')
|
||||
|
||||
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
|
||||
stored_key = self.ap.instance_config.data['system']['recovery_key']
|
||||
try:
|
||||
key_matches = (
|
||||
isinstance(recovery_key, str)
|
||||
and isinstance(stored_key, str)
|
||||
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
|
||||
)
|
||||
except UnicodeEncodeError:
|
||||
# JSON can contain lone surrogates, which are not valid UTF-8.
|
||||
key_matches = False
|
||||
|
||||
if not key_matches:
|
||||
return self.http_status(403, -1, 'Invalid recovery key')
|
||||
|
||||
await self.ap.user_service.reset_password(user_email, new_password)
|
||||
@@ -186,6 +251,9 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback'
|
||||
)
|
||||
launch_assertion = json_data.get('launch_assertion')
|
||||
workspace_uuid = json_data.get('workspace_uuid')
|
||||
|
||||
@@ -199,8 +267,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.fail(1, 'Missing authorization code')
|
||||
if not state:
|
||||
return self.fail(1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||
# Exchange code for tokens
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
@@ -218,24 +289,36 @@ class UserRouterGroup(group.RouterGroup):
|
||||
code,
|
||||
workspace_uuids,
|
||||
workspace_created_ats,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
# Authenticate and create/update local user
|
||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth Workspace binding mismatch')
|
||||
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
|
||||
if cloud_mode:
|
||||
if not target_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
|
||||
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||
|
||||
# Authenticate only after the signed, exact Workspace delta has
|
||||
# established the Account and membership runtime shadow rows.
|
||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||
access_token, refresh_token, expires_in
|
||||
)
|
||||
|
||||
if launch_workspace_uuid:
|
||||
if target_workspace_uuid:
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
user_obj.uuid,
|
||||
launch_workspace_uuid,
|
||||
target_workspace_uuid,
|
||||
)
|
||||
except Exception:
|
||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||
@@ -323,6 +406,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
capabilities['password_login_enabled'] = False
|
||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||
capabilities['passkey_login_enabled'] = True
|
||||
capabilities['passkey_supported'] = True
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@@ -367,12 +452,17 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
state = json_data.get('state')
|
||||
redirect_uri = json_data.get('redirect_uri') or (
|
||||
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
|
||||
)
|
||||
|
||||
if not code:
|
||||
return self.http_status(400, -1, 'Missing authorization code')
|
||||
|
||||
if not state:
|
||||
return self.http_status(400, -1, 'Missing state parameter')
|
||||
if not str(code).startswith('v4_'):
|
||||
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
|
||||
|
||||
try:
|
||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||
@@ -385,7 +475,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||
|
||||
try:
|
||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
|
||||
updated_user = await self.ap.user_service.bind_space_account(
|
||||
user_obj.user, code, redirect_uri=redirect_uri
|
||||
)
|
||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||
return self.success(
|
||||
data={
|
||||
@@ -405,6 +498,182 @@ class UserRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Generate WebAuthn registration options for current account."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
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')
|
||||
|
||||
json_data = (await quart.request.json) or {}
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
|
||||
account_uuid=user_obj.uuid,
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
rp_name='LangBot',
|
||||
)
|
||||
return self.success(data={'options': options, 'challenge_token': challenge_token})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Verify WebAuthn registration response and save credential."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
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')
|
||||
|
||||
json_data = await quart.request.json
|
||||
challenge_token = json_data.get('challenge_token')
|
||||
credential = json_data.get('credential') or json_data.get('response')
|
||||
name = json_data.get('name')
|
||||
|
||||
if not challenge_token or not credential:
|
||||
return self.fail(1, 'Missing challenge_token or credential')
|
||||
|
||||
try:
|
||||
cred = await self.ap.user_service.verify_and_save_passkey_registration(
|
||||
challenge_token=challenge_token,
|
||||
credential_data=credential,
|
||||
name=name,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'uuid': cred.uuid,
|
||||
'name': cred.name,
|
||||
'created_at': cred.created_at.isoformat() if cred.created_at else None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Generate WebAuthn authentication options for passkey login."""
|
||||
json_data = (await quart.request.json) or {}
|
||||
email = json_data.get('email')
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
email=email,
|
||||
)
|
||||
return self.success(data={'options': options, 'challenge_token': challenge_token})
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Verify WebAuthn authentication response and log in."""
|
||||
json_data = await quart.request.json
|
||||
challenge_token = json_data.get('challenge_token')
|
||||
credential = json_data.get('credential') or json_data.get('response')
|
||||
|
||||
if not challenge_token or not credential:
|
||||
return self.fail(1, 'Missing challenge_token or credential')
|
||||
|
||||
try:
|
||||
token, user_obj = await self.ap.user_service.verify_passkey_authentication(
|
||||
challenge_token=challenge_token,
|
||||
credential_data=credential,
|
||||
)
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
'user': user_obj.user,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""List registered passkeys for the current user."""
|
||||
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')
|
||||
|
||||
passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
|
||||
return self.success(
|
||||
data=[
|
||||
{
|
||||
'uuid': pk.uuid,
|
||||
'name': pk.name,
|
||||
'aaguid': pk.aaguid,
|
||||
'transports': pk.transports,
|
||||
'backed_up': pk.backed_up,
|
||||
'created_at': pk.created_at.isoformat() if pk.created_at else None,
|
||||
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
|
||||
}
|
||||
for pk in passkeys
|
||||
]
|
||||
)
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Rename a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
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')
|
||||
|
||||
json_data = await quart.request.json
|
||||
name = (json_data.get('name') or '').strip()
|
||||
if not name:
|
||||
return self.fail(1, 'Passkey name cannot be empty')
|
||||
|
||||
updated = await self.ap.user_service.rename_user_passkey(
|
||||
account_uuid=user_obj.uuid,
|
||||
passkey_uuid=passkey_uuid,
|
||||
new_name=name,
|
||||
)
|
||||
if not updated:
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Delete/revoke a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
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')
|
||||
|
||||
deleted = await self.ap.user_service.delete_user_passkey(
|
||||
account_uuid=user_obj.uuid,
|
||||
passkey_uuid=passkey_uuid,
|
||||
)
|
||||
if not deleted:
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success()
|
||||
|
||||
async def _handle_space_direct_launch(
|
||||
self,
|
||||
launch_assertion: str,
|
||||
@@ -428,6 +697,10 @@ class UserRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
projection_service = self.ap.directory_projection_service
|
||||
if projection_service is None:
|
||||
raise SpaceLaunchError('Cloud directory projection is unavailable')
|
||||
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
|
||||
@@ -529,16 +529,20 @@ class AgentService:
|
||||
return config, component_ref, descriptor.supported_event_patterns
|
||||
|
||||
async def get_processor_runs(self, context, processor_id, *, before_id=None):
|
||||
"""Read only this Workspace's explicitly created processor instance."""
|
||||
"""Read only this Workspace's Agent or plugin processor runs."""
|
||||
from ....agent.runner.run_ledger_store import RunLedgerStore
|
||||
|
||||
processor = await self.get_agent(context, processor_id)
|
||||
if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR:
|
||||
raise ValueError('Event processor not found')
|
||||
if processor is None or processor.get('kind') not in {'agent', AGENT_KIND_EVENT_PROCESSOR}:
|
||||
raise ValueError('Processor not found')
|
||||
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
|
||||
items, cursor, has_more, total = await store.list_runs(
|
||||
workspace_id=require_workspace_uuid(context),
|
||||
binding_id=f'event_processor:{processor_id}',
|
||||
**(
|
||||
{'binding_id': f'event_processor:{processor_id}'}
|
||||
if processor.get('kind') == AGENT_KIND_EVENT_PROCESSOR
|
||||
else {'agent_id': processor_id}
|
||||
),
|
||||
before_id=before_id,
|
||||
)
|
||||
return {'items': items, 'next_cursor': cursor, 'has_more': has_more, 'total': total}
|
||||
@@ -548,14 +552,22 @@ class AgentService:
|
||||
from ....agent.runner.run_ledger_store import RunLedgerStore
|
||||
|
||||
processor = await self.get_agent(context, processor_id)
|
||||
if processor is None or processor.get('kind') != AGENT_KIND_EVENT_PROCESSOR:
|
||||
raise ValueError('Event processor not found')
|
||||
if processor is None or processor.get('kind') not in {'agent', AGENT_KIND_EVENT_PROCESSOR}:
|
||||
raise ValueError('Processor not found')
|
||||
store = RunLedgerStore(self.ap.persistence_mgr.get_db_engine())
|
||||
run = await store.get_run(run_id)
|
||||
if (
|
||||
run is None
|
||||
or run.get('workspace_id') != require_workspace_uuid(context)
|
||||
or run.get('binding_id') != f'event_processor:{processor_id}'
|
||||
or not (
|
||||
run.get('binding_id') == f'event_processor:{processor_id}'
|
||||
if processor.get('kind') == AGENT_KIND_EVENT_PROCESSOR
|
||||
else run.get('agent_id') == processor_id
|
||||
or (
|
||||
run.get('agent_id') is None
|
||||
and str(run.get('binding_id', '')).startswith((f'agent_{processor_id}_', f'debug:{processor_id}:'))
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError('Processor run not found')
|
||||
items, next_cursor, _, has_more = await store.page_run_events(
|
||||
|
||||
@@ -80,16 +80,40 @@ class MaintenanceService:
|
||||
DEFAULT_LOG_RETENTION_DAYS,
|
||||
'storage.cleanup.log_retention_days',
|
||||
)
|
||||
media_cfg = self.ap.instance_config.data.get('storage', {}).get('media_cache', {})
|
||||
media_retention_days = self._positive_int(
|
||||
media_cfg.get('retention_days'),
|
||||
30,
|
||||
'storage.media_cache.retention_days',
|
||||
)
|
||||
media_max_size_mb = self._non_negative_int(
|
||||
media_cfg.get('max_size_mb'),
|
||||
0,
|
||||
'storage.media_cache.max_size_mb',
|
||||
)
|
||||
media_cache = getattr(getattr(self.ap, 'storage_mgr', None), 'media_cache', None)
|
||||
is_singleton = await self._is_oss_singleton(context)
|
||||
media_cleanup = (
|
||||
await media_cache.cleanup(
|
||||
media_retention_days,
|
||||
media_max_size_mb,
|
||||
)
|
||||
if media_cache is not None and is_singleton
|
||||
else {}
|
||||
)
|
||||
|
||||
return {
|
||||
result = {
|
||||
'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days),
|
||||
'log_files': await asyncio.to_thread(
|
||||
self._cleanup_expired_log_files,
|
||||
log_retention_days,
|
||||
)
|
||||
if await self._is_oss_singleton(context)
|
||||
if is_singleton
|
||||
else 0,
|
||||
}
|
||||
if media_cache is not None and is_singleton:
|
||||
result['media_files'] = media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0)
|
||||
return result
|
||||
|
||||
async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
|
||||
require_workspace_uuid(context)
|
||||
@@ -573,6 +597,17 @@ class MaintenanceService:
|
||||
count += len(files)
|
||||
return count
|
||||
|
||||
def _non_negative_int(self, value: Any, default: int, name: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||
return default
|
||||
if parsed < 0:
|
||||
self.ap.logger.warning(f'{name} must be non-negative: {value!r}, using {default}')
|
||||
return default
|
||||
return parsed
|
||||
|
||||
def _positive_int(self, value: Any, default: int, name: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
|
||||
@@ -446,15 +446,19 @@ class MCPService:
|
||||
persisted_session = runtime_mcp_session
|
||||
|
||||
async def _refresh_and_report() -> None:
|
||||
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||
if needs_start:
|
||||
await persisted_session.start()
|
||||
else:
|
||||
try:
|
||||
await persisted_session.refresh()
|
||||
except Exception:
|
||||
try:
|
||||
needs_start = (
|
||||
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
||||
)
|
||||
if needs_start:
|
||||
await persisted_session.start()
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
else:
|
||||
try:
|
||||
await persisted_session.refresh()
|
||||
except Exception:
|
||||
await persisted_session.start()
|
||||
finally:
|
||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||
|
||||
coroutine = _refresh_and_report()
|
||||
else:
|
||||
@@ -471,8 +475,11 @@ class MCPService:
|
||||
async def _run_and_cleanup() -> None:
|
||||
try:
|
||||
await test_session.start()
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
finally:
|
||||
# start() raises for a failed connection. Preserve the
|
||||
# terminal runtime state so the UI can render actionable
|
||||
# failure phases such as OAuth-required.
|
||||
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
|
||||
try:
|
||||
await test_session.shutdown()
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
import datetime
|
||||
import functools
|
||||
@@ -29,6 +30,19 @@ _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
|
||||
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
|
||||
|
||||
|
||||
def _normalize_user_id(value: str | int | None) -> str | None:
|
||||
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
|
||||
|
||||
Opaque string IDs (including whitespace and leading zeros) and missing
|
||||
IDs must remain unchanged. Do not silently stringify unsupported objects.
|
||||
"""
|
||||
if value is None or isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return str(value)
|
||||
raise TypeError('user_id must be a string, integer, or None')
|
||||
|
||||
|
||||
def _workspace_transaction(method):
|
||||
"""Run an explicit service entrypoint in one Workspace transaction."""
|
||||
|
||||
@@ -281,19 +295,21 @@ class MonitoringService:
|
||||
for _batch_number in range(max_batches):
|
||||
|
||||
async def delete_batch() -> tuple[int, int]:
|
||||
key_columns = list(model_cls.__table__.primary_key.columns)
|
||||
select_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(pk_column)
|
||||
sqlalchemy.select(*key_columns)
|
||||
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
|
||||
.limit(batch_size)
|
||||
)
|
||||
pk_values = list(select_result.scalars().all())
|
||||
pk_values = [tuple(row) for row in select_result.all()]
|
||||
if not pk_values:
|
||||
return 0, 0
|
||||
|
||||
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(model_cls).where(
|
||||
model_cls.workspace_uuid == workspace_uuid,
|
||||
pk_column.in_(pk_values),
|
||||
sqlalchemy.tuple_(*key_columns).in_(pk_values),
|
||||
ts_column < cutoff,
|
||||
)
|
||||
)
|
||||
return len(pk_values), int(delete_result.rowcount or 0)
|
||||
@@ -402,6 +418,32 @@ class MonitoringService:
|
||||
|
||||
# ========== Recording Methods ==========
|
||||
|
||||
def _sanitize_message_content(self, content: str) -> str:
|
||||
"""Strip raw base64 data to protect database storage from unbounded bloating."""
|
||||
if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content):
|
||||
return content
|
||||
try:
|
||||
data = json.loads(content)
|
||||
|
||||
def _strip_node(node):
|
||||
if isinstance(node, list):
|
||||
return [_strip_node(x) for x in node]
|
||||
if isinstance(node, dict):
|
||||
res = dict(node)
|
||||
if res.get('type') == 'Image' and res.get('base64'):
|
||||
res['base64'] = None
|
||||
for k, v in list(res.items()):
|
||||
if isinstance(v, (list, dict)):
|
||||
res[k] = _strip_node(v)
|
||||
return res
|
||||
return node
|
||||
|
||||
return json.dumps(_strip_node(data), ensure_ascii=False)
|
||||
except Exception:
|
||||
return re.sub(
|
||||
r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content
|
||||
)
|
||||
|
||||
@_workspace_transaction
|
||||
async def record_message(
|
||||
self,
|
||||
@@ -415,7 +457,7 @@ class MonitoringService:
|
||||
status: str = 'success',
|
||||
level: str = 'info',
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
user_name: str | None = None,
|
||||
runner_name: str | None = None,
|
||||
variables: str | None = None,
|
||||
@@ -424,6 +466,7 @@ class MonitoringService:
|
||||
"""Record a message"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
message_id = str(uuid.uuid4())
|
||||
message_content = self._sanitize_message_content(message_content)
|
||||
message_data = {
|
||||
'id': message_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
@@ -437,7 +480,7 @@ class MonitoringService:
|
||||
'status': status,
|
||||
'level': level,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
'runner_name': runner_name,
|
||||
'variables': variables,
|
||||
@@ -610,7 +653,7 @@ class MonitoringService:
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
@@ -622,17 +665,29 @@ class MonitoringService:
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'message_count': 0,
|
||||
'message_count': 1,
|
||||
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'is_active': True,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
model = persistence_monitoring.MonitoringSession
|
||||
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
|
||||
statement = insert(model).values(session_data)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
|
||||
set_={
|
||||
'message_count': model.message_count + 1,
|
||||
'last_activity': statement.excluded.last_activity,
|
||||
'pipeline_id': statement.excluded.pipeline_id,
|
||||
'pipeline_name': statement.excluded.pipeline_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@_workspace_transaction
|
||||
@@ -642,6 +697,7 @@ class MonitoringService:
|
||||
session_id: str,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Update session last activity time and increment message count.
|
||||
|
||||
@@ -651,6 +707,9 @@ class MonitoringService:
|
||||
True if session was found and updated, False if session doesn't exist.
|
||||
"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
bot_id = bot_id if bot_id is not None else context.bot_uuid
|
||||
if not bot_id:
|
||||
raise ValueError('Session activity requires a bot_id')
|
||||
update_values = {
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
|
||||
@@ -667,6 +726,7 @@ class MonitoringService:
|
||||
.where(
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
persistence_monitoring.MonitoringSession.bot_id == bot_id,
|
||||
)
|
||||
.values(update_values)
|
||||
)
|
||||
@@ -769,13 +829,13 @@ class MonitoringService:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
|
||||
if end_time:
|
||||
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
|
||||
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
|
||||
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
|
||||
# Total messages
|
||||
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
|
||||
@@ -1257,6 +1317,7 @@ class MonitoringService:
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
user_query: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -1271,9 +1332,17 @@ class MonitoringService:
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
user_pattern = f'%{user_query.strip()}%'
|
||||
conditions.append(
|
||||
sqlalchemy.or_(
|
||||
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
||||
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
||||
)
|
||||
)
|
||||
if is_active is not None:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||
|
||||
@@ -1365,6 +1434,9 @@ class MonitoringService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
session_id: str,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
bot_id: str | None = None,
|
||||
) -> dict:
|
||||
"""Get bounded session details with full statistics computed in SQL."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -1374,8 +1446,13 @@ class MonitoringService:
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query)
|
||||
session_row = session_result.first()
|
||||
if bot_id is not None:
|
||||
session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id)
|
||||
session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2))
|
||||
session_rows = session_result.all()
|
||||
if len(session_rows) > 1:
|
||||
return {'session_id': session_id, 'found': False, 'ambiguous': True}
|
||||
session_row = session_rows[0] if session_rows else None
|
||||
|
||||
if not session_row:
|
||||
return {
|
||||
@@ -1384,6 +1461,7 @@ class MonitoringService:
|
||||
}
|
||||
|
||||
session = session_row[0] if isinstance(session_row, tuple) else session_row
|
||||
bot_id = session.bot_id
|
||||
|
||||
message_stats_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(
|
||||
@@ -1411,6 +1489,7 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
||||
persistence_monitoring.MonitoringMessage.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
message_stats = message_stats_result.one()
|
||||
@@ -1449,6 +1528,7 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringLLMCall.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
llm_stats = llm_stats_result.one()
|
||||
@@ -1475,15 +1555,22 @@ class MonitoringService:
|
||||
).where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
||||
)
|
||||
)
|
||||
tool_stats = tool_stats_result.one()
|
||||
tool_conditions = [
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
|
||||
]
|
||||
if start_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
||||
tool_query = (
|
||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||
)
|
||||
.where(*tool_conditions)
|
||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||
.limit(detail_limit + 1)
|
||||
)
|
||||
@@ -1504,6 +1591,7 @@ class MonitoringService:
|
||||
.where(
|
||||
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringError.session_id == session_id,
|
||||
persistence_monitoring.MonitoringError.bot_id == bot_id,
|
||||
)
|
||||
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
|
||||
.limit(detail_limit + 1)
|
||||
@@ -1988,9 +2076,9 @@ class MonitoringService:
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
|
||||
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
|
||||
persistence_monitoring.MonitoringSession.last_activity.desc()
|
||||
@@ -2024,6 +2112,7 @@ class MonitoringService:
|
||||
|
||||
# ========== Feedback Methods ==========
|
||||
|
||||
@_workspace_transaction
|
||||
async def record_feedback(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
@@ -2038,7 +2127,7 @@ class MonitoringService:
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
stream_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str | None:
|
||||
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||
@@ -2094,7 +2183,7 @@ class MonitoringService:
|
||||
'session_id': session_id,
|
||||
'message_id': message_id,
|
||||
'stream_id': stream_id,
|
||||
'user_id': user_id,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'platform': platform,
|
||||
}
|
||||
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Bounded traffic aggregation, independent of record-list pagination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
|
||||
MAX_TRAFFIC_POINTS = 1000
|
||||
|
||||
|
||||
async def get_traffic_series(
|
||||
ap: Application,
|
||||
context: TenantContext,
|
||||
*,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Count all matching records in UTC buckets, returning at most 1000 points."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
|
||||
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
|
||||
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
|
||||
points: dict[datetime.datetime, dict[str, int]] = {}
|
||||
truncated = False
|
||||
for model, field in ((MonitoringMessage, 'messages'), (MonitoringLLMCall, 'llm_calls')):
|
||||
timestamp = model.timestamp
|
||||
if postgres:
|
||||
time_bucket = sqlalchemy.func.date_trunc(bucket, timestamp)
|
||||
else:
|
||||
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
|
||||
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
|
||||
conditions = [model.workspace_uuid == workspace_uuid]
|
||||
if bot_ids:
|
||||
conditions.append(model.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(model.pipeline_id.in_(pipeline_ids))
|
||||
if start_time is not None:
|
||||
conditions.append(timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
conditions.append(timestamp <= end_time)
|
||||
statement = (
|
||||
sqlalchemy.select(time_bucket.label('bucket'), sqlalchemy.func.count(model.id).label('count'))
|
||||
.where(*conditions)
|
||||
.group_by(time_bucket)
|
||||
.order_by(time_bucket)
|
||||
.limit(MAX_TRAFFIC_POINTS + 1)
|
||||
)
|
||||
result = await ap.persistence_mgr.execute_async(statement)
|
||||
rows = result.all()
|
||||
truncated = truncated or len(rows) > MAX_TRAFFIC_POINTS
|
||||
for timestamp_value, count in rows[:MAX_TRAFFIC_POINTS]:
|
||||
key = (
|
||||
datetime.datetime.fromisoformat(timestamp_value)
|
||||
if isinstance(timestamp_value, str)
|
||||
else timestamp_value
|
||||
)
|
||||
points.setdefault(key, {'messages': 0, 'llm_calls': 0})[field] = int(count)
|
||||
|
||||
def floor(value: datetime.datetime) -> datetime.datetime:
|
||||
return value.replace(minute=0, second=0, microsecond=0, **({'hour': 0} if bucket == 'day' else {}))
|
||||
|
||||
first = floor(start_time) if start_time is not None else min(points, default=None)
|
||||
last = floor(end_time) if end_time is not None else max(points, default=None)
|
||||
series = []
|
||||
if first is not None and last is not None:
|
||||
cursor = first
|
||||
while cursor <= last and len(series) < MAX_TRAFFIC_POINTS:
|
||||
series.append(
|
||||
{'timestamp': cursor.isoformat() + 'Z', **points.get(cursor, {'messages': 0, 'llm_calls': 0})}
|
||||
)
|
||||
cursor += step
|
||||
truncated = truncated or cursor <= last
|
||||
return {'bucket': bucket, 'points': series, 'truncated': truncated}
|
||||
@@ -13,7 +13,7 @@ from ....pipeline.extension_preferences import (
|
||||
validate_extension_preferences,
|
||||
)
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
|
||||
from .secrets import redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
|
||||
@@ -213,12 +213,24 @@ class PipelineService:
|
||||
pipeline_data.pop(protected_field, None)
|
||||
|
||||
if 'config' in pipeline_data:
|
||||
current_config = None
|
||||
if contains_secret_placeholder(pipeline_data['config']):
|
||||
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
|
||||
if current_pipeline is None:
|
||||
raise WorkspaceNotFoundError('Pipeline not found')
|
||||
current_config = current_pipeline.get('config', {})
|
||||
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
|
||||
if current_pipeline is None:
|
||||
raise WorkspaceNotFoundError('Pipeline not found')
|
||||
current_config = current_pipeline.get('config', {})
|
||||
old_ai = current_config.get('ai', {}) if isinstance(current_config, dict) else {}
|
||||
old_runner = old_ai.get('runner') if isinstance(old_ai, dict) else None
|
||||
legacy_binding = (
|
||||
isinstance(old_runner, str)
|
||||
and bool(old_runner)
|
||||
or isinstance(old_runner, dict)
|
||||
and bool(old_runner.get('runner'))
|
||||
and not old_runner.get('id')
|
||||
)
|
||||
new_config = pipeline_data['config']
|
||||
new_ai = new_config.get('ai', {}) if isinstance(new_config, dict) else {}
|
||||
new_runner = new_ai.get('runner') if isinstance(new_ai, dict) else None
|
||||
if legacy_binding and isinstance(new_runner, dict) and new_runner.get('id'):
|
||||
raise ValueError('manual_migration_required')
|
||||
pipeline_data['config'] = restore_secret_placeholders(
|
||||
pipeline_data['config'],
|
||||
current_config if current_config is not None else {},
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
"""Explicit, tenant-scoped migration. Preview never contacts plugin runtimes.
|
||||
|
||||
The task handle is process-local. Original snapshots and pending activation are
|
||||
persistent; a refreshed preview is required after observation loss or restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import secrets
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
|
||||
from ..authz import Permission, permissions_for_role, require_permission
|
||||
from ..context import ExecutionContext, PrincipalType, RequestContext
|
||||
from ....agent.runner.config_resolver import RunnerConfigResolver
|
||||
from ....agent.runner import config_schema
|
||||
from ....agent.runner.resource_builder import AgentResourceBuilder
|
||||
from ....agent.runner.resource_policy import ResourcePolicyProjector
|
||||
from ....entity.persistence.model import LLMModel, RerankModel, EmbeddingModel
|
||||
from ....entity.persistence.rag import KnowledgeBase
|
||||
from ....entity.persistence.mcp import MCPServer
|
||||
from ....core.taskmgr import TaskContext
|
||||
from ....entity.persistence.pipeline import LegacyPipeline
|
||||
from ....entity.persistence.agent_interaction import AgentInteraction
|
||||
from ....persistence.pipeline_admission import lock_pipeline_admission
|
||||
from ....entity.persistence.pipeline_migration import PipelineMigrationSnapshot
|
||||
from ....entity.persistence.plugin import PluginSetting
|
||||
from ....entity.persistence.user import User
|
||||
from ....entity.persistence.workspace import Workspace, WorkspaceMembership, WorkspaceExecutionState
|
||||
from ....plugin.errors import (
|
||||
PluginRuntimeNotConnectedError,
|
||||
MarketplacePluginVersionNotFoundError,
|
||||
PluginInstallationFailedError,
|
||||
)
|
||||
from ....pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline
|
||||
|
||||
|
||||
class MigrationInstallContext(TaskContext):
|
||||
"""Publish only structured installer progress, never upstream logs or URLs."""
|
||||
|
||||
ACTIONS = {
|
||||
'downloading plugin package': 'downloading',
|
||||
'validating plugin package': 'validating',
|
||||
'preparing plugin installation': 'preparing',
|
||||
'installing plugin dependencies': 'installing_deps',
|
||||
'waiting for plugin initialization': 'activating',
|
||||
'refreshing plugin components': 'refreshing',
|
||||
'plugin installed': 'done',
|
||||
}
|
||||
METRICS = ('progress_percent', 'download_current', 'download_total', 'download_speed', 'deps_total')
|
||||
|
||||
def __init__(self, target):
|
||||
super().__init__()
|
||||
self.progress = {
|
||||
**target,
|
||||
'status': 'installing',
|
||||
'stage': 'checking',
|
||||
'code': None,
|
||||
'started_at': time.time(),
|
||||
'finished_at': None,
|
||||
'steps': [],
|
||||
}
|
||||
self._step('checking')
|
||||
|
||||
def _step(self, stage):
|
||||
steps = self.progress['steps']
|
||||
if steps and steps[-1]['stage'] == stage:
|
||||
return
|
||||
now = time.time()
|
||||
if steps:
|
||||
steps[-1]['finished_at'] = now
|
||||
steps.append({'stage': stage, 'started_at': now, 'finished_at': None})
|
||||
self.progress['stage'] = stage
|
||||
|
||||
def set_current_action(self, action):
|
||||
super().set_current_action(action)
|
||||
stage = self.ACTIONS.get(action)
|
||||
if stage:
|
||||
self._step(stage)
|
||||
self.publish()
|
||||
|
||||
def publish(self):
|
||||
for key in self.METRICS:
|
||||
value = self.metadata.get(key)
|
||||
if type(value) in (int, float) and math.isfinite(value) and value >= 0:
|
||||
self.progress[key] = min(value, 100) if key == 'progress_percent' else value
|
||||
self.progress['updated_at'] = time.time()
|
||||
|
||||
def finish(self, code=None):
|
||||
self.publish()
|
||||
self.progress.update(status='failed' if code else 'completed', code=code, finished_at=time.time())
|
||||
self.progress['steps'][-1]['finished_at'] = self.progress['finished_at']
|
||||
if not code:
|
||||
self.progress['progress_percent'] = 100
|
||||
|
||||
async def observe(self):
|
||||
while True:
|
||||
self.publish()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
class MigrationError(ValueError):
|
||||
"""Only constant, credential-free codes cross the API/task boundary."""
|
||||
|
||||
def __init__(self, code: str, status_code: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _failure_code(exc: Exception) -> str:
|
||||
if isinstance(exc, MigrationError):
|
||||
return exc.code
|
||||
if isinstance(exc, PluginRuntimeNotConnectedError):
|
||||
return 'plugin_runtime_unavailable'
|
||||
return 'migration_failed'
|
||||
|
||||
|
||||
def validate_execute_request(body) -> list[dict]:
|
||||
if not isinstance(body, dict) or set(body) != {'confirmed', 'items'} or body['confirmed'] is not True:
|
||||
raise MigrationError('confirmation_required', 400)
|
||||
items = body['items']
|
||||
if not isinstance(items, list) or not 1 <= len(items) <= 50:
|
||||
raise MigrationError('invalid_selection', 400)
|
||||
seen = set()
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or set(item) != {'pipeline_uuid', 'preview_token'}:
|
||||
raise MigrationError('invalid_selection', 400)
|
||||
for key, maximum in [('pipeline_uuid', 255), ('preview_token', 256)]:
|
||||
value = item[key]
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > maximum or value != value.strip():
|
||||
raise MigrationError('invalid_selection', 400)
|
||||
if item['pipeline_uuid'] in seen:
|
||||
raise MigrationError('invalid_selection', 400)
|
||||
seen.add(item['pipeline_uuid'])
|
||||
return copy.deepcopy(items)
|
||||
|
||||
|
||||
def _json(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _fingerprint(row):
|
||||
# SQL timestamps are neither monotonic revisions nor portable CAS values.
|
||||
return hashlib.sha256(
|
||||
_json(
|
||||
{k: v for k, v in row.items() if not k.startswith('_') and k not in ('created_at', 'updated_at')}
|
||||
).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
class PipelineMigrationService:
|
||||
def __init__(self, ap):
|
||||
self.ap = ap
|
||||
self.pm = ap.persistence_mgr
|
||||
# Loss of this process key only invalidates previews, never snapshots.
|
||||
self._token_key = secrets.token_bytes(32)
|
||||
self._all_tasks: set[str] = set()
|
||||
|
||||
async def _binding(self, ctx, session=None):
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
ctx.workspace_uuid, expected_generation=ctx.placement_generation, session=session
|
||||
)
|
||||
if binding.instance_uuid != ctx.instance_uuid:
|
||||
raise MigrationError('placement_changed')
|
||||
|
||||
async def _authorize(self, ctx, session=None):
|
||||
require_permission(ctx, Permission.RESOURCE_MANAGE)
|
||||
if ctx.principal.principal_type != PrincipalType.ACCOUNT or not ctx.account_uuid:
|
||||
raise MigrationError('authorization_changed', 403)
|
||||
status = (await self.pm.execute_async(sa.select(User.status).where(User.uuid == ctx.account_uuid))).scalar()
|
||||
if status != 'active':
|
||||
raise MigrationError('authorization_changed', 403)
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
ctx.account_uuid, ctx.workspace_uuid, session=session
|
||||
)
|
||||
if (
|
||||
access.workspace.uuid != ctx.workspace_uuid
|
||||
or access.membership.uuid != ctx.workspace.membership_uuid
|
||||
or access.membership.projection_revision != ctx.workspace.membership_revision
|
||||
or Permission.RESOURCE_MANAGE.value not in permissions_for_role(access.membership.role)
|
||||
or access.execution.instance_uuid != ctx.instance_uuid
|
||||
or access.execution.placement_generation != ctx.placement_generation
|
||||
):
|
||||
raise MigrationError('authorization_changed', 403)
|
||||
await self._binding(ctx, session)
|
||||
|
||||
async def _rows(self, ctx, pipeline_uuid=None):
|
||||
# Read DB-native text for JSON CAS: never compare PostgreSQL JSON using =,
|
||||
# and never assume Python's JSON whitespace/key order matches stored text.
|
||||
table = LegacyPipeline.__table__
|
||||
stmt = sa.select(
|
||||
table,
|
||||
*[sa.cast(table.c[k], sa.Text).label('_raw_' + k) for k in ('config', 'stages', 'extensions_preferences')],
|
||||
)
|
||||
stmt = stmt.where(table.c.workspace_uuid == ctx.workspace_uuid)
|
||||
if pipeline_uuid is not None:
|
||||
stmt = stmt.where(table.c.uuid == pipeline_uuid)
|
||||
return [dict(r._mapping) for r in (await self.pm.execute_async(stmt.order_by(table.c.uuid))).all()]
|
||||
|
||||
async def _plugin(self, ctx, plan):
|
||||
target = plan.get('target_plugin')
|
||||
if not target:
|
||||
return None
|
||||
t = PluginSetting.__table__
|
||||
row = (
|
||||
await self.pm.execute_async(
|
||||
sa.select(
|
||||
t.c.enabled, t.c.installation_uuid, t.c.artifact_digest, t.c.runtime_revision, t.c.install_info
|
||||
).where(
|
||||
t.c.workspace_uuid == ctx.workspace_uuid,
|
||||
t.c.plugin_author == target['author'],
|
||||
t.c.plugin_name == target['name'],
|
||||
)
|
||||
)
|
||||
).first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
def _native_interactions(self, ctx, pipeline_uuid):
|
||||
"""Inspect the old in-process cache without importing/pruning it.
|
||||
|
||||
Native forms have no durable handoff to the plugin runner. Operators
|
||||
must finish them in the original runtime before migrating; never infer
|
||||
completion from the cache TTL or submit/cancel a form here.
|
||||
"""
|
||||
module = sys.modules.get('langbot.pkg.provider.runners.difysvapi')
|
||||
if module is None:
|
||||
return []
|
||||
cache = getattr(module, '_PENDING_FORMS', None)
|
||||
if not isinstance(cache, dict):
|
||||
return ['unavailable']
|
||||
observed = []
|
||||
for key, forms in cache.items():
|
||||
if not isinstance(key, tuple) or len(key) != 8:
|
||||
if forms:
|
||||
observed.append('unscoped')
|
||||
continue
|
||||
instance, workspace, _, _, pipeline, *_ = key
|
||||
# Inspect ownership before touching any form contents. Empty scope
|
||||
# cannot prove this is another tenant; block without disclosing it.
|
||||
if instance and instance != ctx.instance_uuid or workspace and workspace != ctx.workspace_uuid:
|
||||
continue
|
||||
if pipeline and pipeline != pipeline_uuid:
|
||||
continue
|
||||
if forms:
|
||||
observed.append(hashlib.sha256(_json(key).encode()).hexdigest())
|
||||
return sorted(observed)
|
||||
|
||||
async def _interaction_state(self, ctx, pipeline_uuid):
|
||||
"""Read only correlation/status, never request/submission/token data.
|
||||
|
||||
The real table has workspace_id (not workspace_uuid), and no instance
|
||||
column. Execution placement is verified by the caller; processor_id is
|
||||
the globally unique pipeline UUID. NULL ownership on that exact UUID
|
||||
is uncertainty, not permission to query other Workspaces.
|
||||
"""
|
||||
if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}:
|
||||
return {'boundary': 'unavailable'}, True
|
||||
t = AgentInteraction.__table__
|
||||
try:
|
||||
records = (
|
||||
await self.pm.execute_async(
|
||||
sa.select(t.c.id, t.c.status, t.c.updated_at, t.c.workspace_id)
|
||||
.where(
|
||||
t.c.processor_type == 'pipeline',
|
||||
t.c.processor_id == pipeline_uuid,
|
||||
sa.or_(
|
||||
t.c.workspace_id == ctx.workspace_uuid, t.c.workspace_id.is_(None), t.c.workspace_id == ''
|
||||
),
|
||||
)
|
||||
.order_by(t.c.id)
|
||||
)
|
||||
).all()
|
||||
durable = [tuple(r) for r in records]
|
||||
blocked = any(r.status not in {'submitted', 'cancelled', 'expired', 'delivery_failed'} for r in records)
|
||||
except Exception:
|
||||
# Missing schema, permissions, or a failed store must not be treated
|
||||
# as an empty store. No original diagnostic crosses the API.
|
||||
durable, blocked = ['unavailable'], True
|
||||
native = self._native_interactions(ctx, pipeline_uuid)
|
||||
return {'durable': durable, 'native': native}, blocked or bool(native)
|
||||
|
||||
async def _plan(self, ctx, row):
|
||||
plan = plan_legacy_pipeline(row['config'], row['extensions_preferences'])
|
||||
pending = None
|
||||
if plan['state'] == 'already_current':
|
||||
t = PipelineMigrationSnapshot.__table__
|
||||
snapshots = (
|
||||
await self.pm.execute_async(
|
||||
sa.select(t).where(
|
||||
t.c.workspace_uuid == ctx.workspace_uuid,
|
||||
t.c.pipeline_uuid == row['uuid'],
|
||||
t.c.state == 'activation_pending',
|
||||
t.c.target_fingerprint == _fingerprint(row),
|
||||
t.c.planner_version == str(PLANNER_VERSION),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
if snapshots:
|
||||
pending = dict(snapshots[0]._mapping)
|
||||
source = pending['source_snapshot']
|
||||
plan = plan_legacy_pipeline(source['config'], source['extensions_preferences'])
|
||||
candidate = {**row, 'config': plan.get('config')}
|
||||
if plan['state'] != 'ready' or _fingerprint(candidate) != _fingerprint(row):
|
||||
raise MigrationError('planner_changed')
|
||||
facts = await self._plugin(ctx, plan)
|
||||
state = 'activation_pending' if pending else plan['state']
|
||||
blockers = list(plan['blockers'])
|
||||
if state in ('ready', 'activation_pending'):
|
||||
if facts is None:
|
||||
state = 'needs_plugin'
|
||||
blockers.append({'code': 'plugin_missing'})
|
||||
elif facts['enabled'] is not True:
|
||||
state = 'blocked'
|
||||
blockers.append({'code': 'plugin_disabled'})
|
||||
if plan['state'] == 'ready':
|
||||
observation, interaction_blocked = await self._interaction_state(ctx, row['uuid'])
|
||||
plan['_interaction_state'] = observation
|
||||
if interaction_blocked:
|
||||
state = 'blocked'
|
||||
blockers.append({'code': 'runtime.pending_interaction'})
|
||||
return plan, facts, pending, state, blockers
|
||||
|
||||
def _token(self, ctx, row, plan, facts, pending):
|
||||
claim = [
|
||||
ctx.instance_uuid,
|
||||
ctx.workspace_uuid,
|
||||
ctx.placement_generation,
|
||||
ctx.account_uuid,
|
||||
ctx.workspace.membership_uuid,
|
||||
ctx.workspace.membership_revision,
|
||||
str(PLANNER_VERSION),
|
||||
_fingerprint(row),
|
||||
plan,
|
||||
facts,
|
||||
pending['uuid'] if pending else None,
|
||||
]
|
||||
# An authenticated opaque digest discloses neither credentials nor configs.
|
||||
return hmac.new(self._token_key, _json(claim).encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
async def preview(self, ctx: RequestContext):
|
||||
require_permission(ctx, Permission.RESOURCE_VIEW)
|
||||
async with self.pm.tenant_scope(ctx.workspace_uuid):
|
||||
await self._binding(ctx)
|
||||
items = []
|
||||
for row in await self._rows(ctx):
|
||||
plan, facts, pending, state, blockers = await self._plan(ctx, row)
|
||||
item = {
|
||||
k: copy.deepcopy(plan[k])
|
||||
for k in ('legacy_runner', 'target_runner_id', 'target_plugin', 'changed_paths', 'warnings')
|
||||
}
|
||||
item.update(
|
||||
pipeline_uuid=row['uuid'],
|
||||
name=row['name'],
|
||||
state=state,
|
||||
blockers=blockers,
|
||||
preview_token=self._token(ctx, row, plan, facts, pending)
|
||||
if state in ('ready', 'activation_pending')
|
||||
else None,
|
||||
)
|
||||
items.append(item)
|
||||
return {
|
||||
'planner_version': str(PLANNER_VERSION),
|
||||
'workspace_uuid': ctx.workspace_uuid,
|
||||
'items': items,
|
||||
'total': len(items),
|
||||
}
|
||||
|
||||
async def _selected(self, ctx, selection, *, data_only=False):
|
||||
rows = await self._rows(ctx, selection['pipeline_uuid'])
|
||||
if not rows:
|
||||
raise MigrationError('pipeline_not_found', 404)
|
||||
row = rows[0]
|
||||
plan, facts, pending, state, blockers = await self._plan(ctx, row)
|
||||
token = self._token(ctx, row, plan, facts, pending)
|
||||
if not hmac.compare_digest(token, selection['preview_token']):
|
||||
raise MigrationError('preview_stale')
|
||||
if state not in ('ready', 'activation_pending') and not (
|
||||
data_only
|
||||
and plan['state'] == 'ready'
|
||||
and all(b['code'] in ('plugin_missing', 'plugin_disabled') for b in blockers)
|
||||
):
|
||||
raise MigrationError('migration_blocked')
|
||||
return row, plan, facts, pending
|
||||
|
||||
async def execute(self, ctx: RequestContext, body):
|
||||
require_permission(ctx, Permission.RESOURCE_MANAGE)
|
||||
if isinstance(body, dict) and body.get('all') is True:
|
||||
if (
|
||||
set(body) != {'confirmed', 'all', 'install_plugins'}
|
||||
or body['confirmed'] is not True
|
||||
or not isinstance(body['install_plugins'], bool)
|
||||
):
|
||||
raise MigrationError('confirmation_required', 400)
|
||||
return await self._execute_all(ctx, install_plugins=body['install_plugins'])
|
||||
items = validate_execute_request(body)
|
||||
async with self.pm.tenant_scope(ctx.workspace_uuid):
|
||||
await self._authorize(ctx)
|
||||
# Preflight the entire selection before creating tasks or contacting runtimes.
|
||||
for selection in items:
|
||||
await self._selected(ctx, selection)
|
||||
task_context = TaskContext.new()
|
||||
task_context.metadata = {
|
||||
'kind': 'pipeline_migration',
|
||||
'results': [{'pipeline_uuid': i['pipeline_uuid'], 'state': 'pending', 'code': None} for i in items],
|
||||
}
|
||||
task = self.ap.task_mgr.create_user_task(
|
||||
self._run(ctx, ExecutionContext.from_request(ctx), items, task_context),
|
||||
kind='pipeline_migration',
|
||||
name='pipeline_migration',
|
||||
context=task_context,
|
||||
instance_uuid=ctx.instance_uuid,
|
||||
workspace_uuid=ctx.workspace_uuid,
|
||||
placement_generation=ctx.placement_generation,
|
||||
)
|
||||
return {'task_id': task.id}
|
||||
|
||||
async def _execute_all(self, ctx, *, install_plugins):
|
||||
# Capture all sources on admission. Plugin installation must not silently
|
||||
# include later edits or pipelines created while the task is running.
|
||||
if ctx.workspace_uuid in self._all_tasks:
|
||||
raise MigrationError('migration_running')
|
||||
self._all_tasks.add(ctx.workspace_uuid)
|
||||
try:
|
||||
async with self.pm.tenant_scope(ctx.workspace_uuid):
|
||||
await self._authorize(ctx)
|
||||
sources = []
|
||||
for row in await self._rows(ctx):
|
||||
_, _, _, state, _ = await self._plan(ctx, row)
|
||||
if state not in ('already_current', 'not_legacy'):
|
||||
sources.append(row)
|
||||
if not sources:
|
||||
raise MigrationError('nothing_to_migrate')
|
||||
task_context = TaskContext.new()
|
||||
task_context.metadata = {
|
||||
'kind': 'pipeline_migration',
|
||||
'phase': 'installing' if install_plugins else 'migrating',
|
||||
'installations': [],
|
||||
'results': [{'pipeline_uuid': row['uuid'], 'state': 'pending', 'code': None} for row in sources],
|
||||
}
|
||||
task = self.ap.task_mgr.create_user_task(
|
||||
self._run_all(
|
||||
ctx, ExecutionContext.from_request(ctx), sources, task_context, install_plugins=install_plugins
|
||||
),
|
||||
kind='pipeline_migration',
|
||||
name='pipeline_migration',
|
||||
context=task_context,
|
||||
instance_uuid=ctx.instance_uuid,
|
||||
workspace_uuid=ctx.workspace_uuid,
|
||||
placement_generation=ctx.placement_generation,
|
||||
)
|
||||
return {'task_id': task.id, 'pipeline_uuids': [row['uuid'] for row in sources]}
|
||||
except BaseException:
|
||||
self._all_tasks.discard(ctx.workspace_uuid)
|
||||
raise
|
||||
|
||||
async def _run_all(self, ctx, execution, sources, task_context, *, install_plugins):
|
||||
installed = {}
|
||||
try:
|
||||
async with self.pm.tenant_scope(execution.workspace_uuid):
|
||||
for source, result in zip(sources, task_context.metadata['results']):
|
||||
if result['state'] != 'pending':
|
||||
continue
|
||||
try:
|
||||
await self._authorize(ctx)
|
||||
current = await self._rows(ctx, source['uuid'])
|
||||
if not current or _fingerprint(current[0]) != _fingerprint(source):
|
||||
raise MigrationError('preview_stale')
|
||||
plan, facts, pending, state, blockers = await self._plan(ctx, current[0])
|
||||
hard_blockers = [b for b in blockers if b['code'] not in ('plugin_missing', 'plugin_disabled')]
|
||||
if hard_blockers:
|
||||
raise MigrationError(hard_blockers[0]['code'])
|
||||
if state in ('already_current', 'not_legacy'):
|
||||
result.update(state='already_current', code=None)
|
||||
continue
|
||||
target = plan.get('target_plugin')
|
||||
if not target:
|
||||
raise MigrationError('migration_blocked')
|
||||
if not install_plugins:
|
||||
selection = {
|
||||
'pipeline_uuid': source['uuid'],
|
||||
'preview_token': self._token(ctx, current[0], plan, facts, pending),
|
||||
}
|
||||
await self._run(ctx, execution, [selection], task_context, results=[result], data_only=True)
|
||||
continue
|
||||
key = (target['author'], target['name'], target['version'])
|
||||
if key not in installed:
|
||||
runners = await self.ap.runner_registry.list_runners(
|
||||
execution, use_cache=False, usage='agent'
|
||||
)
|
||||
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
|
||||
ready = (
|
||||
facts
|
||||
and facts['enabled']
|
||||
and descriptor
|
||||
and descriptor.plugin_version == target['version']
|
||||
)
|
||||
if not ready:
|
||||
task_context.metadata['phase'] = 'installing'
|
||||
install_context = MigrationInstallContext(target)
|
||||
task_context.metadata['installations'].append(install_context.progress)
|
||||
observer = asyncio.create_task(install_context.observe())
|
||||
install_code = 'operation_cancelled'
|
||||
try:
|
||||
await self._authorize(ctx)
|
||||
await self.ap.plugin_connector.require_workspace_context(execution)
|
||||
await self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE,
|
||||
{
|
||||
'plugin_author': target['author'],
|
||||
'plugin_name': target['name'],
|
||||
'plugin_version': target['version'],
|
||||
},
|
||||
task_context=install_context,
|
||||
)
|
||||
install_code = None
|
||||
except PluginRuntimeNotConnectedError:
|
||||
install_code = 'plugin_runtime_unavailable'
|
||||
except MarketplacePluginVersionNotFoundError:
|
||||
install_code = 'plugin_version_unavailable'
|
||||
except httpx.TimeoutException:
|
||||
install_code = 'plugin_download_timeout'
|
||||
except httpx.HTTPStatusError as exc:
|
||||
install_code = (
|
||||
'plugin_version_unavailable'
|
||||
if exc.response.status_code == 404
|
||||
else 'plugin_marketplace_unavailable'
|
||||
)
|
||||
except httpx.RequestError:
|
||||
install_code = 'plugin_download_failed'
|
||||
except PluginInstallationFailedError as exc:
|
||||
install_code = {
|
||||
'dependency_prepare_failed': 'dependency_prepare_failed',
|
||||
'worker_launch_failed': 'plugin_launch_failed',
|
||||
}.get(exc.error_code, 'plugin_install_failed')
|
||||
except Exception:
|
||||
install_code = 'plugin_install_failed'
|
||||
finally:
|
||||
observer.cancel()
|
||||
try:
|
||||
await observer
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
install_context.finish(install_code)
|
||||
installed[key] = install_code
|
||||
else:
|
||||
installed[key] = None
|
||||
if installed[key]:
|
||||
raise MigrationError(installed[key])
|
||||
await self._authorize(ctx)
|
||||
current = await self._rows(ctx, source['uuid'])
|
||||
if not current or _fingerprint(current[0]) != _fingerprint(source):
|
||||
raise MigrationError('preview_stale')
|
||||
plan, facts, pending, state, blockers = await self._plan(ctx, current[0])
|
||||
if state not in ('ready', 'activation_pending'):
|
||||
raise MigrationError(blockers[0]['code'] if blockers else 'migration_blocked')
|
||||
selection = {
|
||||
'pipeline_uuid': source['uuid'],
|
||||
'preview_token': self._token(ctx, current[0], plan, facts, pending),
|
||||
}
|
||||
task_context.metadata['phase'] = 'migrating'
|
||||
await self._run(ctx, execution, [selection], task_context, results=[result])
|
||||
except Exception as exc:
|
||||
result.update(
|
||||
state='blocked' if isinstance(exc, MigrationError) else 'failed',
|
||||
code=_failure_code(exc),
|
||||
)
|
||||
if any(
|
||||
r['code'] in ('operation_cancelled', 'commit_outcome_unknown')
|
||||
for r in task_context.metadata['results']
|
||||
):
|
||||
break
|
||||
finally:
|
||||
for result in task_context.metadata['results']:
|
||||
if result['state'] == 'pending':
|
||||
result.update(state='failed', code='operation_cancelled')
|
||||
task_context.metadata['phase'] = 'finished'
|
||||
self._all_tasks.discard(ctx.workspace_uuid)
|
||||
return task_context.metadata
|
||||
|
||||
async def _verify_runtime(self, execution, plan):
|
||||
runners = await self.ap.runner_registry.list_runners(execution, use_cache=False, usage='agent')
|
||||
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
|
||||
if descriptor is None or 'agent' not in descriptor.usages:
|
||||
raise MigrationError('runner_unavailable')
|
||||
if descriptor.plugin_version != plan['target_plugin']['version']:
|
||||
raise MigrationError('plugin_version_incompatible')
|
||||
config = plan['config']
|
||||
RunnerConfigResolver.validate_pipeline_config(config)
|
||||
runner_config = config['ai']['runner_config'][plan['target_runner_id']]
|
||||
schema = {item['name']: item for item in descriptor.config_schema}
|
||||
# Host fields have their own scope/capability checks below; plugin
|
||||
# schema defaults must never implicitly authorize a foreign resource.
|
||||
host_fields = {
|
||||
'enable-all-tools': 'boolean',
|
||||
'tools': 'array',
|
||||
'knowledge-bases': 'knowledge-base-multi-selector',
|
||||
'mcp-resources': 'array',
|
||||
'mcp-resource-agent-read-enabled': 'boolean',
|
||||
}
|
||||
for name, value in runner_config.items():
|
||||
field = schema.get(name)
|
||||
if field is None and name in host_fields:
|
||||
field = {'type': host_fields[name]}
|
||||
if field is None:
|
||||
raise MigrationError('runner_schema_incompatible')
|
||||
if value is None and field.get('nullable') is True:
|
||||
continue
|
||||
kind = config_schema.normalize_schema_item_type(field.get('type'))
|
||||
valid = (
|
||||
(
|
||||
kind
|
||||
in (
|
||||
'string',
|
||||
'text',
|
||||
'password',
|
||||
'secret',
|
||||
'select',
|
||||
'llm-model-selector',
|
||||
'embedding-model-selector',
|
||||
'rerank-model-selector',
|
||||
)
|
||||
and isinstance(value, str)
|
||||
)
|
||||
or (kind == 'boolean' and type(value) is bool)
|
||||
or (kind in ('integer', 'int') and type(value) is int)
|
||||
or (kind in ('float', 'number') and type(value) in (int, float))
|
||||
or (
|
||||
kind
|
||||
in (
|
||||
'array',
|
||||
'multi-select',
|
||||
'knowledge-base-selector',
|
||||
'knowledge-base-multi-selector',
|
||||
'prompt-editor',
|
||||
)
|
||||
and isinstance(value, list)
|
||||
)
|
||||
or (kind in ('object', 'json') and type(value) is dict)
|
||||
or (kind == 'array[string]' and type(value) is list and all(type(v) is str for v in value))
|
||||
or (
|
||||
kind == 'model-fallback-selector'
|
||||
and (
|
||||
isinstance(value, str)
|
||||
or isinstance(value, dict)
|
||||
and set(value) <= {'primary', 'fallbacks', 'reasoning'}
|
||||
and isinstance(value.get('primary'), str)
|
||||
and isinstance(value.get('fallbacks', []), list)
|
||||
and all(isinstance(v, str) for v in value.get('fallbacks', []))
|
||||
and isinstance(value.get('reasoning', {}), dict)
|
||||
)
|
||||
)
|
||||
)
|
||||
if not valid:
|
||||
raise MigrationError('runner_schema_incompatible')
|
||||
if kind == 'select' and field.get('options') and field.get('allow_custom') is not True:
|
||||
if value not in [o.get('value', o.get('name')) for o in field['options']]:
|
||||
raise MigrationError('runner_schema_incompatible')
|
||||
if any(
|
||||
f.get('required') and name not in runner_config and f.get('default') is None for name, f in schema.items()
|
||||
):
|
||||
raise MigrationError('runner_schema_incompatible')
|
||||
await self._verify_resources(execution, descriptor, runner_config)
|
||||
return _json(
|
||||
[
|
||||
descriptor.id,
|
||||
descriptor.plugin_version,
|
||||
descriptor.usages,
|
||||
descriptor.config_schema,
|
||||
getattr(descriptor, 'capabilities', None),
|
||||
getattr(descriptor, 'permissions', None),
|
||||
]
|
||||
)
|
||||
|
||||
async def _verify_resources(self, execution, descriptor, runner_config):
|
||||
"""Fail closed on references the scoped Host cannot authorize.
|
||||
|
||||
This runs only on explicit execution, outside the mutation transaction.
|
||||
Resource use is still reauthorized by the Host on every eventual run.
|
||||
"""
|
||||
|
||||
async def require_row(model, identifier, *, column=None):
|
||||
column = model.uuid if column is None else column
|
||||
found = (
|
||||
await self.pm.execute_async(
|
||||
sa.select(model.uuid).where(
|
||||
model.workspace_uuid == execution.workspace_uuid,
|
||||
column == identifier,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if found is None:
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
|
||||
permissions = getattr(descriptor, 'permissions', None)
|
||||
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
|
||||
allowed = set(getattr(permissions, 'models', []))
|
||||
if not (allowed & ({'rerank'} if model_type == 'rerank' else {'invoke', 'stream'})):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
await require_row(RerankModel if model_type == 'rerank' else LLMModel, model_uuid)
|
||||
for field in config_schema.iter_schema_items(descriptor, {'embedding-model-selector'}):
|
||||
value = runner_config.get(field['name'])
|
||||
if value and value not in config_schema.NONE_SENTINELS:
|
||||
await require_row(EmbeddingModel, value)
|
||||
|
||||
kb_ids = runner_config.get('knowledge-bases', [])
|
||||
if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
if kb_ids:
|
||||
if not config_schema.uses_host_knowledge_bases(descriptor) or 'retrieve' not in getattr(
|
||||
permissions, 'knowledge_bases', []
|
||||
):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
for kb_id in kb_ids:
|
||||
await require_row(KnowledgeBase, kb_id)
|
||||
|
||||
tool_names = runner_config.get('tools', [])
|
||||
if not isinstance(tool_names, list) or any(not isinstance(v, str) or not v for v in tool_names):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
tool_grant = (
|
||||
runner_config.get('enable-all-tools', False)
|
||||
or tool_names
|
||||
or runner_config.get('mcp-resource-agent-read-enabled', False)
|
||||
)
|
||||
if tool_grant:
|
||||
if not config_schema.uses_host_tools(descriptor) or 'call' not in getattr(permissions, 'tools', []):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
policy = ResourcePolicyProjector.from_runner_config(runner_config)
|
||||
tools = await AgentResourceBuilder(self.ap)._build_tools_from_binding(
|
||||
execution,
|
||||
permissions,
|
||||
policy,
|
||||
descriptor,
|
||||
runner_config,
|
||||
)
|
||||
if not set(tool_names) <= {tool['tool_name'] for tool in tools}:
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
|
||||
for attachment in runner_config.get('mcp-resources', []):
|
||||
if not isinstance(attachment, dict):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
identifier = attachment.get('server_uuid') or attachment.get('server_id')
|
||||
name = attachment.get('server_name')
|
||||
if identifier:
|
||||
await require_row(MCPServer, identifier)
|
||||
elif name:
|
||||
await require_row(MCPServer, name, column=MCPServer.name)
|
||||
else:
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
|
||||
async def _lock(self, ctx, pipeline_uuid, session):
|
||||
# Every pending producer shares this row and revalidates its captured
|
||||
# configuration/conversation authority after acquiring admission.
|
||||
if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}:
|
||||
raise MigrationError('runtime.pending_interaction')
|
||||
await lock_pipeline_admission(session, ctx.workspace_uuid, pipeline_uuid)
|
||||
# Hold revocable directory rows through commit (no network in this UoW).
|
||||
await session.execute(sa.select(User.uuid).where(User.uuid == ctx.account_uuid).with_for_update())
|
||||
for model, condition in [
|
||||
(Workspace, Workspace.uuid == ctx.workspace_uuid),
|
||||
(WorkspaceExecutionState, WorkspaceExecutionState.workspace_uuid == ctx.workspace_uuid),
|
||||
(
|
||||
WorkspaceMembership,
|
||||
sa.and_(
|
||||
WorkspaceMembership.workspace_uuid == ctx.workspace_uuid,
|
||||
WorkspaceMembership.account_uuid == ctx.account_uuid,
|
||||
),
|
||||
),
|
||||
(PluginSetting, PluginSetting.workspace_uuid == ctx.workspace_uuid),
|
||||
]:
|
||||
await session.execute(sa.select(model.__table__).where(condition).with_for_update())
|
||||
|
||||
async def _commit(self, ctx, selection, expected_facts, candidate, *, data_only=False):
|
||||
async with self.pm.tenant_uow(ctx.workspace_uuid) as uow:
|
||||
await self._lock(ctx, selection['pipeline_uuid'], uow.session)
|
||||
await self._authorize(ctx, uow.session)
|
||||
row, plan, facts, pending = await self._selected(ctx, selection, data_only=data_only)
|
||||
if facts != expected_facts:
|
||||
raise MigrationError('plugin_changed')
|
||||
if pending:
|
||||
return pending['uuid'], pending['target_fingerprint']
|
||||
target = {**row, 'config': candidate['config']}
|
||||
t = LegacyPipeline.__table__
|
||||
cas = [t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == row['uuid']]
|
||||
for key in ('config', 'stages', 'extensions_preferences'):
|
||||
cas.append(sa.cast(t.c[key], sa.Text) == row['_raw_' + key])
|
||||
result = await self.pm.execute_async(sa.update(t).where(*cas).values(config=candidate['config']))
|
||||
if result.rowcount != 1:
|
||||
raise MigrationError('preview_stale')
|
||||
snapshot_uuid = str(uuid.uuid4())
|
||||
target_fingerprint = _fingerprint(target)
|
||||
await self.pm.execute_async(
|
||||
sa.insert(PipelineMigrationSnapshot).values(
|
||||
uuid=snapshot_uuid,
|
||||
workspace_uuid=ctx.workspace_uuid,
|
||||
pipeline_uuid=row['uuid'],
|
||||
source_fingerprint=_fingerprint(row),
|
||||
target_fingerprint=target_fingerprint,
|
||||
source_snapshot=json.loads(_json({k: v for k, v in row.items() if not k.startswith('_')})),
|
||||
planner_version=str(PLANNER_VERSION),
|
||||
state='activation_pending',
|
||||
)
|
||||
)
|
||||
if self._native_interactions(ctx, row['uuid']):
|
||||
raise MigrationError('runtime.pending_interaction')
|
||||
return snapshot_uuid, target_fingerprint
|
||||
|
||||
async def _activate(self, ctx, selection, snapshot_uuid, target_fingerprint, candidate, plan, expected_facts):
|
||||
# Re-read the committed target under a short lock before synchronous
|
||||
# publication. A later editor must never be replaced by our old candidate.
|
||||
async with self.pm.tenant_uow(ctx.workspace_uuid) as uow:
|
||||
await self._lock(ctx, selection['pipeline_uuid'], uow.session)
|
||||
await self._authorize(ctx, uow.session)
|
||||
rows = await self._rows(ctx, selection['pipeline_uuid'])
|
||||
if not rows or _fingerprint(rows[0]) != target_fingerprint:
|
||||
raise MigrationError('activation_source_changed')
|
||||
if await self._plugin(ctx, plan) != expected_facts:
|
||||
raise MigrationError('plugin_changed')
|
||||
_, interaction_blocked = await self._interaction_state(ctx, selection['pipeline_uuid'])
|
||||
if interaction_blocked:
|
||||
raise MigrationError('runtime.pending_interaction')
|
||||
# Native cache inspection above is the last await before publication
|
||||
# and session invalidation; no native producer can interleave here.
|
||||
self.ap.pipeline_mgr.publish_pipeline(candidate)
|
||||
for session in self.ap.sess_mgr.session_list:
|
||||
conversation = session.using_conversation
|
||||
if (
|
||||
conversation is not None
|
||||
and conversation.pipeline_uuid == selection['pipeline_uuid']
|
||||
and getattr(session, 'workspace_uuid', None) == ctx.workspace_uuid
|
||||
):
|
||||
session.using_conversation = None
|
||||
t = PipelineMigrationSnapshot.__table__
|
||||
await self.pm.execute_async(
|
||||
sa.update(t)
|
||||
.where(t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == snapshot_uuid)
|
||||
.values(state='active')
|
||||
)
|
||||
|
||||
async def _run(self, ctx, execution, items, task_context, results=None, *, data_only=False):
|
||||
async with self.pm.tenant_scope(execution.workspace_uuid):
|
||||
for selection, result in zip(items, results if results is not None else task_context.metadata['results']):
|
||||
committed = False
|
||||
commit_attempted = False
|
||||
try:
|
||||
await self._authorize(ctx)
|
||||
row, plan, facts, pending = await self._selected(ctx, selection, data_only=data_only)
|
||||
runtime_schema = None if data_only else await self._verify_runtime(execution, plan)
|
||||
RunnerConfigResolver.validate_pipeline_config(plan['config'])
|
||||
candidate_entity = {k: copy.deepcopy(v) for k, v in row.items() if not k.startswith('_')}
|
||||
candidate_entity['config'] = copy.deepcopy(plan['config'])
|
||||
runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity))
|
||||
if not data_only and await self._verify_runtime(execution, plan) != runtime_schema:
|
||||
raise MigrationError('runner_schema_changed')
|
||||
# Runtime awaits are over. Recheck authorization/source/plugin
|
||||
# facts under database locks, then atomically journal and CAS.
|
||||
commit_attempted = True
|
||||
snapshot_uuid, target_fingerprint = await self._commit(
|
||||
ctx, selection, facts, candidate_entity, **({'data_only': True} if data_only else {})
|
||||
)
|
||||
committed = True
|
||||
await self._activate(ctx, selection, snapshot_uuid, target_fingerprint, runtime, plan, facts)
|
||||
result.update(state='migrated', code='data_only' if data_only else None)
|
||||
except (Exception, asyncio.CancelledError) as exc:
|
||||
cancelled = isinstance(exc, asyncio.CancelledError)
|
||||
reconciliation_cancel = None
|
||||
code = 'operation_cancelled' if cancelled else _failure_code(exc)
|
||||
if commit_attempted and not committed and not isinstance(exc, MigrationError):
|
||||
# A commit can succeed while acknowledgement/connection
|
||||
# cleanup fails, including cancellation. Make one read
|
||||
# after the UoW has unwound, in this task's tenant scope;
|
||||
# never shield/retry through another cancellation.
|
||||
try:
|
||||
t = PipelineMigrationSnapshot.__table__
|
||||
conditions = [
|
||||
t.c.workspace_uuid == ctx.workspace_uuid,
|
||||
t.c.pipeline_uuid == selection['pipeline_uuid'],
|
||||
t.c.source_fingerprint
|
||||
== (pending['source_fingerprint'] if pending else _fingerprint(row)),
|
||||
t.c.target_fingerprint == _fingerprint(candidate_entity),
|
||||
t.c.planner_version == str(PLANNER_VERSION),
|
||||
]
|
||||
if pending:
|
||||
# A retry starts from the target, not the legacy
|
||||
# source. Only the original snapshot proves this
|
||||
# durable migration; an unrelated journal cannot.
|
||||
conditions.extend(
|
||||
[
|
||||
t.c.uuid == pending['uuid'],
|
||||
t.c.target_fingerprint == pending['target_fingerprint'],
|
||||
]
|
||||
)
|
||||
found = (await self.pm.execute_async(sa.select(t.c.uuid).where(*conditions))).first()
|
||||
committed = found is not None
|
||||
except asyncio.CancelledError as cancel_exc:
|
||||
reconciliation_cancel = cancel_exc
|
||||
cancelled = True
|
||||
code = 'commit_outcome_unknown'
|
||||
except Exception:
|
||||
code = 'commit_outcome_unknown'
|
||||
state = (
|
||||
# Unknown is deliberately recovery-needed, not a claim
|
||||
# of rollback or proof that a snapshot was committed.
|
||||
'activation_pending'
|
||||
if committed or code == 'commit_outcome_unknown'
|
||||
else 'stale'
|
||||
if code == 'preview_stale'
|
||||
else 'blocked'
|
||||
if isinstance(exc, MigrationError)
|
||||
else 'failed'
|
||||
)
|
||||
result.update(state=state, code=code)
|
||||
if cancelled:
|
||||
for unstarted in task_context.metadata['results']:
|
||||
if unstarted['state'] == 'pending':
|
||||
unstarted.update(state='failed', code='operation_cancelled')
|
||||
if reconciliation_cancel is not None:
|
||||
# Preserve cancellation propagation during recovery,
|
||||
# but record safe outcomes before leaving the task.
|
||||
raise reconciliation_cancel
|
||||
return task_context.metadata
|
||||
# Never attach the original exception, traceback or plugin
|
||||
# diagnostic to TaskContext: upstream errors can contain keys.
|
||||
return task_context.metadata
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import traceback
|
||||
|
||||
@@ -7,8 +8,10 @@ import sqlalchemy
|
||||
|
||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
from ....core import app
|
||||
from ....core.task_boundary import create_detached_task
|
||||
from ....entity.persistence import model as persistence_model
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....provider.modelmgr.codex_auth import CodexAuth, REQUESTER as CODEX_REQUESTER, validate_config
|
||||
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
|
||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
|
||||
@@ -20,6 +23,8 @@ class ModelProviderService:
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self.codex_auth = CodexAuth(ap)
|
||||
self._deletion_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
def _is_cloud_runtime(self) -> bool:
|
||||
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
||||
@@ -116,14 +121,30 @@ class ModelProviderService:
|
||||
provider_data = provider_data.copy()
|
||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||
validate_config(provider_data)
|
||||
provider_data['uuid'] = str(uuid.uuid4())
|
||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||
provider_data['api_keys'] = self._normalize_api_keys(
|
||||
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
if provider_data.get('requester') == CODEX_REQUESTER:
|
||||
async with self.ap.persistence_mgr.tenant_uow(provider_data['workspace_uuid']):
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.CodexCredential).values(
|
||||
workspace_uuid=provider_data['workspace_uuid'],
|
||||
provider_uuid=provider_data['uuid'],
|
||||
payload={},
|
||||
version=0,
|
||||
lease_until=0,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||
)
|
||||
|
||||
# load to runtime
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
|
||||
@@ -138,6 +159,17 @@ class ModelProviderService:
|
||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||
provider_data.pop('uuid', None)
|
||||
provider_data.pop('workspace_uuid', None)
|
||||
if {'requester', 'base_url', 'api_keys'} & provider_data.keys():
|
||||
current = await self.get_provider(context, provider_uuid, include_secret=True)
|
||||
if current is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
if CODEX_REQUESTER in (current.get('requester'), provider_data.get('requester')):
|
||||
if provider_data.get('requester', current.get('requester')) != current.get('requester'):
|
||||
raise ValueError('Create a separate provider to change the ChatGPT authentication type')
|
||||
merged = {**current, **provider_data}
|
||||
validate_config(merged)
|
||||
provider_data['base_url'] = merged['base_url']
|
||||
provider_data['api_keys'] = []
|
||||
if 'api_keys' in provider_data:
|
||||
submitted_keys = provider_data.get('api_keys')
|
||||
if contains_secret_placeholder(submitted_keys, sensitive=True):
|
||||
@@ -163,60 +195,107 @@ class ModelProviderService:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
await self.ap.model_mgr.reload_provider(context, provider_uuid)
|
||||
|
||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
||||
"""Delete a provider (only if no models reference it)"""
|
||||
await self._assert_provider_mutable(context, provider_uuid)
|
||||
async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None:
|
||||
"""Delete a provider, optionally deleting all its Workspace-scoped models."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check if any models use this provider
|
||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.LLMModel,
|
||||
workspace_uuid,
|
||||
)
|
||||
persistence = self.ap.persistence_mgr
|
||||
model_types = (
|
||||
(persistence_model.LLMModel, 'LLM', 'remove_llm_model'),
|
||||
(persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'),
|
||||
(persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'),
|
||||
)
|
||||
if llm_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: LLM models still reference it')
|
||||
|
||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.EmbeddingModel,
|
||||
workspace_uuid,
|
||||
deleted_models: list[tuple[str, list[str]]] = []
|
||||
async with persistence.tenant_uow(workspace_uuid):
|
||||
# Check ownership before touching children. Lock the provider on PostgreSQL
|
||||
# so concurrent model inserts cannot race the reference check/deletion.
|
||||
provider_result = await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.ModelProvider.requester)
|
||||
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||
.with_for_update(),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
)
|
||||
if embedding_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
||||
provider = provider_result.first()
|
||||
if provider is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
if self._system_requester_is_reserved(provider.requester):
|
||||
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
|
||||
|
||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.RerankModel,
|
||||
workspace_uuid,
|
||||
for model_type, label, remover in model_types:
|
||||
result = await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid),
|
||||
model_type,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
model_uuids = list(result.scalars())
|
||||
if model_uuids and not cascade:
|
||||
raise ValueError(f'Cannot delete provider: {label} models still reference it')
|
||||
if model_uuids:
|
||||
# Model services have no pipeline/KB deletion side effects: they
|
||||
# delete the scoped row and evict its runtime cache. Defer eviction
|
||||
# here rather than calling those services before our commit.
|
||||
await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(model_type).where(model_type.provider_uuid == provider_uuid),
|
||||
model_type,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
deleted_models.append((remover, model_uuids))
|
||||
|
||||
# Explicit cleanup also works on legacy SQLite connections without FK
|
||||
# enforcement; never load or serialize the private credential payload.
|
||||
await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.CodexCredential).where(
|
||||
persistence_model.CodexCredential.provider_uuid == provider_uuid
|
||||
),
|
||||
persistence_model.CodexCredential,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
)
|
||||
if rerank_result.first() is not None:
|
||||
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
result = await persistence.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == provider_uuid
|
||||
),
|
||||
persistence_model.ModelProvider,
|
||||
workspace_uuid,
|
||||
)
|
||||
)
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
if result.rowcount == 0:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
|
||||
await self.ap.model_mgr.remove_provider(context, provider_uuid)
|
||||
async def remove_runtime() -> None:
|
||||
async with persistence.tenant_scope(workspace_uuid):
|
||||
for remover, model_uuids in deleted_models:
|
||||
for model_uuid in model_uuids:
|
||||
await getattr(self.ap.model_mgr, remover)(context, model_uuid)
|
||||
# This also closes the requester's HTTP client; models go first.
|
||||
await self.ap.model_mgr.remove_provider(context, provider_uuid)
|
||||
|
||||
if persistence.current_session() is None:
|
||||
await remove_runtime()
|
||||
else:
|
||||
# A nested UoW has not committed yet. Reuse the rollback-cancelled gate
|
||||
# and detached context boundary instead of evicting uncommitted data.
|
||||
task = create_detached_task(
|
||||
remove_runtime(),
|
||||
after_commit_manager=persistence,
|
||||
workspace_uuid=workspace_uuid,
|
||||
)
|
||||
self._deletion_tasks.add(task)
|
||||
|
||||
def completed(task: asyncio.Task[None]) -> None:
|
||||
self._deletion_tasks.discard(task)
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
self.ap.logger.error('Failed to remove deleted provider runtime', exc_info=task.exception())
|
||||
|
||||
task.add_done_callback(completed)
|
||||
|
||||
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
|
||||
"""Get count of models using this provider"""
|
||||
|
||||
@@ -119,7 +119,7 @@ class SpaceService:
|
||||
|
||||
space_config = self._get_space_config()
|
||||
authorize_url = space_config['oauth_authorize_url']
|
||||
params = {'redirect_uri': redirect_uri}
|
||||
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||
if state:
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
@@ -129,6 +129,8 @@ class SpaceService:
|
||||
code: str,
|
||||
workspace_uuids: list[str] | None = None,
|
||||
workspace_created_ats: dict[str, int] | None = None,
|
||||
*,
|
||||
redirect_uri: str = '',
|
||||
) -> typing.Dict:
|
||||
"""Exchange OAuth authorization code for tokens"""
|
||||
from langbot.pkg.utils import constants
|
||||
@@ -141,6 +143,7 @@ class SpaceService:
|
||||
f'{space_url}/api/v1/accounts/oauth/token',
|
||||
json={
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'instance_id': constants.instance_id,
|
||||
# Sending an explicit empty list tells new Space servers not to
|
||||
# synthesize a legacy instance-derived Workspace binding.
|
||||
|
||||
@@ -4,6 +4,7 @@ import sqlalchemy
|
||||
import argon2
|
||||
import jwt
|
||||
import datetime
|
||||
import json
|
||||
import typing
|
||||
import asyncio
|
||||
import dataclasses
|
||||
@@ -12,10 +13,19 @@ import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
import webauthn
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ....entity.persistence import user
|
||||
from ....entity.persistence import passkey
|
||||
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
|
||||
from ....utils import constants
|
||||
from ....entity.errors import account as account_errors
|
||||
@@ -29,6 +39,9 @@ if typing.TYPE_CHECKING:
|
||||
_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
|
||||
_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
|
||||
_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
|
||||
_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096
|
||||
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64
|
||||
_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4
|
||||
|
||||
|
||||
class AccountExistsLoginRequiredError(ValueError):
|
||||
@@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption:
|
||||
launch_workspace_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class PasskeyChallengeData:
|
||||
challenge: bytes
|
||||
purpose: typing.Literal['register', 'auth']
|
||||
rp_id: str
|
||||
origin: str
|
||||
expires_at: float
|
||||
account_uuid: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
class UserService:
|
||||
ap: Application
|
||||
_create_user_lock: asyncio.Lock
|
||||
@@ -65,6 +89,9 @@ class UserService:
|
||||
self._space_oauth_state_lock = asyncio.Lock()
|
||||
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
|
||||
self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
|
||||
self._passkey_challenge_lock = asyncio.Lock()
|
||||
self._passkey_challenges: dict[str, PasskeyChallengeData] = {}
|
||||
self._passkey_challenge_expiry_heap: list[tuple[float, str]] = []
|
||||
|
||||
@staticmethod
|
||||
def _space_oauth_state_digest(state: str) -> str:
|
||||
@@ -774,7 +801,7 @@ class UserService:
|
||||
f'email:{normalized_email}',
|
||||
)
|
||||
|
||||
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
||||
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||
"""Bind Space account to existing local account"""
|
||||
local_account = await self.get_user_by_email(user_email)
|
||||
if local_account is None:
|
||||
@@ -794,12 +821,13 @@ class UserService:
|
||||
code,
|
||||
[binding.workspace_uuid],
|
||||
{binding.workspace_uuid: created_ts},
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
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)
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
|
||||
access_token = token_data.get('access_token')
|
||||
refresh_token = token_data.get('refresh_token')
|
||||
expires_in = token_data.get('expires_in', 0)
|
||||
@@ -849,3 +877,309 @@ class UserService:
|
||||
await self._update_space_provider_for_account(local_account, api_key)
|
||||
|
||||
return await self.get_user_by_email(space_email)
|
||||
|
||||
def _prune_passkey_challenges(self, now: float) -> None:
|
||||
while self._passkey_challenge_expiry_heap:
|
||||
expires_at, token = self._passkey_challenge_expiry_heap[0]
|
||||
entry = self._passkey_challenges.get(token)
|
||||
if entry is None or entry.expires_at != expires_at:
|
||||
heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
continue
|
||||
if expires_at > now:
|
||||
break
|
||||
heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
self._passkey_challenges.pop(token, None)
|
||||
|
||||
max_heap_entries = max(
|
||||
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR,
|
||||
len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER,
|
||||
)
|
||||
if len(self._passkey_challenge_expiry_heap) > max_heap_entries:
|
||||
self._passkey_challenge_expiry_heap[:] = [
|
||||
(entry.expires_at, token) for token, entry in self._passkey_challenges.items()
|
||||
]
|
||||
heapq.heapify(self._passkey_challenge_expiry_heap)
|
||||
|
||||
async def issue_passkey_challenge(
|
||||
self,
|
||||
purpose: typing.Literal['register', 'auth'],
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
*,
|
||||
account_uuid: str | None = None,
|
||||
user_email: str | None = None,
|
||||
ttl_seconds: int = 300,
|
||||
) -> tuple[str, bytes]:
|
||||
now = time.monotonic()
|
||||
challenge_bytes = secrets.token_bytes(32)
|
||||
challenge_token = secrets.token_urlsafe(32)
|
||||
expires_at = now + ttl_seconds
|
||||
|
||||
async with self._passkey_challenge_lock:
|
||||
self._prune_passkey_challenges(now)
|
||||
while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES:
|
||||
if not self._passkey_challenge_expiry_heap:
|
||||
break
|
||||
_, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap)
|
||||
self._passkey_challenges.pop(oldest_token, None)
|
||||
|
||||
self._passkey_challenges[challenge_token] = PasskeyChallengeData(
|
||||
challenge=challenge_bytes,
|
||||
purpose=purpose,
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
expires_at=expires_at,
|
||||
account_uuid=account_uuid,
|
||||
user_email=user_email,
|
||||
)
|
||||
heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token))
|
||||
|
||||
return challenge_token, challenge_bytes
|
||||
|
||||
async def consume_passkey_challenge(
|
||||
self,
|
||||
challenge_token: str,
|
||||
purpose: typing.Literal['register', 'auth'],
|
||||
) -> PasskeyChallengeData:
|
||||
now = time.monotonic()
|
||||
async with self._passkey_challenge_lock:
|
||||
self._prune_passkey_challenges(now)
|
||||
data = self._passkey_challenges.pop(challenge_token, None)
|
||||
|
||||
if data is None or data.expires_at < now:
|
||||
raise ValueError('Invalid or expired passkey challenge')
|
||||
if data.purpose != purpose:
|
||||
raise ValueError('Passkey challenge purpose mismatch')
|
||||
return data
|
||||
|
||||
async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]:
|
||||
statement = (
|
||||
sqlalchemy.select(passkey.PasskeyCredential)
|
||||
.where(passkey.PasskeyCredential.account_uuid == account_uuid)
|
||||
.order_by(passkey.PasskeyCredential.created_at.desc())
|
||||
)
|
||||
async with self._session_factory()() as session:
|
||||
result = await session.scalars(statement)
|
||||
return list(result.all())
|
||||
|
||||
async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None:
|
||||
statement = sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.credential_id == credential_id
|
||||
)
|
||||
async with self._session_factory()() as session:
|
||||
return await session.scalar(statement)
|
||||
|
||||
async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None:
|
||||
statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid)
|
||||
async with self._session_factory()() as session:
|
||||
return await session.scalar(statement)
|
||||
|
||||
async def generate_passkey_registration_options(
|
||||
self,
|
||||
account_uuid: str,
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
rp_name: str = 'LangBot',
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
account = await self.get_user_by_uuid(account_uuid)
|
||||
if account is None:
|
||||
raise ValueError('User not found')
|
||||
self._require_active_account(account)
|
||||
|
||||
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
|
||||
purpose='register',
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
account_uuid=account_uuid,
|
||||
user_email=account.user,
|
||||
)
|
||||
|
||||
existing_passkeys = await self.get_user_passkeys(account_uuid)
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys
|
||||
]
|
||||
|
||||
options = webauthn.generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
user_name=account.user,
|
||||
user_id=account.uuid.encode('utf-8'),
|
||||
user_display_name=account.user,
|
||||
challenge=challenge_bytes,
|
||||
exclude_credentials=exclude_credentials or None,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
|
||||
options_dict = json.loads(webauthn.options_to_json(options))
|
||||
return options_dict, challenge_token
|
||||
|
||||
async def verify_and_save_passkey_registration(
|
||||
self,
|
||||
challenge_token: str,
|
||||
credential_data: dict[str, typing.Any] | str,
|
||||
name: str | None = None,
|
||||
) -> passkey.PasskeyCredential:
|
||||
challenge_data = await self.consume_passkey_challenge(challenge_token, 'register')
|
||||
if not challenge_data.account_uuid:
|
||||
raise ValueError('Registration challenge must be bound to an account')
|
||||
|
||||
verification = webauthn.verify_registration_response(
|
||||
credential=credential_data,
|
||||
expected_challenge=challenge_data.challenge,
|
||||
expected_rp_id=challenge_data.rp_id,
|
||||
expected_origin=challenge_data.origin,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
cred_id_str = bytes_to_base64url(verification.credential_id)
|
||||
pub_key_str = bytes_to_base64url(verification.credential_public_key)
|
||||
|
||||
transports = None
|
||||
if isinstance(credential_data, dict):
|
||||
resp = credential_data.get('response', {})
|
||||
if isinstance(resp, dict) and 'transports' in resp:
|
||||
t_list = resp.get('transports')
|
||||
if isinstance(t_list, list):
|
||||
transports = ','.join(str(x) for x in t_list)
|
||||
|
||||
credential_name = (name or '').strip()
|
||||
if not credential_name:
|
||||
credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})'
|
||||
|
||||
record = passkey.PasskeyCredential(
|
||||
uuid=str(uuid.uuid4()),
|
||||
account_uuid=challenge_data.account_uuid,
|
||||
name=credential_name,
|
||||
credential_id=cred_id_str,
|
||||
public_key=pub_key_str,
|
||||
sign_count=verification.sign_count,
|
||||
aaguid=verification.aaguid,
|
||||
transports=transports,
|
||||
backed_up=verification.credential_backed_up,
|
||||
)
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def generate_passkey_authentication_options(
|
||||
self,
|
||||
rp_id: str,
|
||||
origin: str,
|
||||
email: str | None = None,
|
||||
) -> tuple[dict[str, typing.Any], str]:
|
||||
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
|
||||
purpose='auth',
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
user_email=email,
|
||||
)
|
||||
|
||||
allow_credentials: list[PublicKeyCredentialDescriptor] | None = None
|
||||
if email:
|
||||
user_obj = await self.get_user_by_email(email)
|
||||
if user_obj:
|
||||
user_passkeys = await self.get_user_passkeys(user_obj.uuid)
|
||||
if user_passkeys:
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys
|
||||
]
|
||||
|
||||
options = webauthn.generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
challenge=challenge_bytes,
|
||||
allow_credentials=allow_credentials or None,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
|
||||
options_dict = json.loads(webauthn.options_to_json(options))
|
||||
return options_dict, challenge_token
|
||||
|
||||
async def verify_passkey_authentication(
|
||||
self,
|
||||
challenge_token: str,
|
||||
credential_data: dict[str, typing.Any] | str,
|
||||
) -> tuple[str, user.User]:
|
||||
challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth')
|
||||
|
||||
raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None
|
||||
if not raw_id:
|
||||
raise ValueError('Missing credential id')
|
||||
|
||||
stored_credential = await self.get_passkey_by_credential_id(raw_id)
|
||||
if stored_credential is None:
|
||||
raise ValueError('Passkey credential not recognized')
|
||||
|
||||
user_obj = await self.get_user_by_uuid(stored_credential.account_uuid)
|
||||
if user_obj is None:
|
||||
raise ValueError('Associated user not found')
|
||||
self._require_active_account(user_obj)
|
||||
|
||||
verification = webauthn.verify_authentication_response(
|
||||
credential=credential_data,
|
||||
expected_challenge=challenge_data.challenge,
|
||||
expected_rp_id=challenge_data.rp_id,
|
||||
expected_origin=challenge_data.origin,
|
||||
credential_public_key=base64url_to_bytes(stored_credential.public_key),
|
||||
credential_current_sign_count=stored_credential.sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.id == stored_credential.id
|
||||
)
|
||||
)
|
||||
if record:
|
||||
record.sign_count = verification.new_sign_count
|
||||
record.last_used_at = datetime.datetime.now()
|
||||
record.backed_up = verification.credential_backed_up
|
||||
|
||||
token = await self.generate_jwt_token(user_obj)
|
||||
return token, user_obj
|
||||
|
||||
async def rename_user_passkey(
|
||||
self,
|
||||
account_uuid: str,
|
||||
passkey_uuid: str,
|
||||
new_name: str,
|
||||
) -> passkey.PasskeyCredential | None:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.uuid == passkey_uuid,
|
||||
passkey.PasskeyCredential.account_uuid == account_uuid,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return None
|
||||
record.name = new_name
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def delete_user_passkey(
|
||||
self,
|
||||
account_uuid: str,
|
||||
passkey_uuid: str,
|
||||
) -> bool:
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(
|
||||
sqlalchemy.select(passkey.PasskeyCredential).where(
|
||||
passkey.PasskeyCredential.uuid == passkey_uuid,
|
||||
passkey.PasskeyCredential.account_uuid == account_uuid,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return False
|
||||
await session.delete(record)
|
||||
return True
|
||||
|
||||
@@ -227,14 +227,16 @@ class LangBotMCPServer:
|
||||
return _dump(await ap.agent_service.get_agent_metadata(context))
|
||||
|
||||
@mcp.tool(
|
||||
description='List one Event processor instance run history; use before_id to page older runs. '
|
||||
description='List one Agent or plugin processor run history; use before_id to page older runs. '
|
||||
'created_at_ms, started_at_ms and finished_at_ms are Host lifecycle times in epoch milliseconds.'
|
||||
)
|
||||
async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id))
|
||||
|
||||
@mcp.tool(description='Read logs and action results for an Event processor run; page using after_sequence.')
|
||||
@mcp.tool(
|
||||
description='Read logs and action results for an Agent or plugin processor run; page using after_sequence.'
|
||||
)
|
||||
async def get_processor_run_events(
|
||||
processor_uuid: str,
|
||||
run_id: str,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Runner-owned Box selection over Host-owned execution and file services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import base64
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import message as pm
|
||||
from langbot_plugin.api.entities.builtin.runner.box import BoxAcquireRequest
|
||||
from langbot_plugin.box.errors import BoxValidationError
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunBoxBinding:
|
||||
run_id: str
|
||||
session_id: str
|
||||
spec: dict
|
||||
io_scope: str
|
||||
imported: dict = field(default_factory=dict)
|
||||
exported: dict = field(default_factory=dict)
|
||||
submitted: set = field(default_factory=set)
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
def prepare_input_files(query, agent_input):
|
||||
"""Retain provider inputs and issue references without downloading files.
|
||||
|
||||
Only platform-origin components may carry a Host-local path. A path in an
|
||||
event envelope is metadata, never permission to read the Host filesystem.
|
||||
"""
|
||||
from langbot_plugin.api.entities.builtin.runner.input import InputAttachment
|
||||
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
|
||||
originals = input_files(query)
|
||||
if not agent_input.attachments:
|
||||
agent_input.attachments = [
|
||||
InputAttachment.model_validate(item)
|
||||
for item in QueryEntryAdapter._build_attachments(query, [c.model_dump() for c in agent_input.contents])
|
||||
]
|
||||
components = []
|
||||
for i, attachment in enumerate(agent_input.attachments):
|
||||
kind = (attachment.type or 'file').lower()
|
||||
fields = {'url': attachment.url or '', 'base64': attachment.content or ''}
|
||||
cls = {'image': pm.Image, 'voice': pm.Voice}.get(kind, pm.File)
|
||||
if cls is pm.File:
|
||||
fields.update(name=attachment.name or '', id=attachment.id or '')
|
||||
component = cls(**fields)
|
||||
# Match by payload, never by list position (contents and message chains
|
||||
# can describe attachments in different orders).
|
||||
for original in originals:
|
||||
if type(original) is not cls:
|
||||
continue
|
||||
if (
|
||||
(attachment.url and attachment.url == original.url)
|
||||
or (attachment.content and attachment.content == original.base64)
|
||||
or (
|
||||
attachment.id
|
||||
and attachment.id
|
||||
== getattr(original, 'id', getattr(original, 'image_id', getattr(original, 'voice_id', None)))
|
||||
)
|
||||
or (
|
||||
not attachment.url
|
||||
and not attachment.content
|
||||
and not attachment.id
|
||||
and attachment.name
|
||||
and attachment.name == getattr(original, 'name', None)
|
||||
)
|
||||
):
|
||||
component = original
|
||||
break
|
||||
components.append(component)
|
||||
attachment.ref, attachment.path = f'attachment-{i}', None
|
||||
object.__setattr__(query, '_box_inputs', components)
|
||||
|
||||
|
||||
def input_files(query):
|
||||
prepared = getattr(query, '_box_inputs', None)
|
||||
if prepared is not None:
|
||||
return prepared
|
||||
return [c for c in query.message_chain or [] if isinstance(c, (pm.Image, pm.Voice, pm.File))]
|
||||
|
||||
|
||||
def binding_for(query) -> RunBoxBinding:
|
||||
binding = getattr(query, '_box_binding', None)
|
||||
if not isinstance(binding, RunBoxBinding):
|
||||
raise BoxValidationError('Runner must bind a Box before using sandbox tools or files')
|
||||
return binding
|
||||
|
||||
|
||||
def exported_message(query, file_ids: list[str], *, consume: bool = False) -> pm.MessageChain:
|
||||
binding = binding_for(query)
|
||||
if not isinstance(file_ids, list) or len(file_ids) > 100 or any(type(x) is not str for x in file_ids):
|
||||
raise BoxValidationError('file_ids must contain at most 100 exported file IDs')
|
||||
if len(set(file_ids)) != len(file_ids):
|
||||
raise BoxValidationError('Duplicate exported file ID')
|
||||
if any(key not in binding.exported or key in binding.submitted for key in file_ids):
|
||||
raise BoxValidationError('Output file does not belong to this run or has already been submitted')
|
||||
components = []
|
||||
for key in file_ids:
|
||||
item = binding.exported[key]
|
||||
if item['type'] == 'Image':
|
||||
components.append(pm.Image(base64=item['base64']))
|
||||
elif item['type'] == 'Voice':
|
||||
components.append(pm.Voice(base64=item['base64']))
|
||||
else:
|
||||
components.append(pm.File(name=item['name'], base64=item['base64']))
|
||||
if consume:
|
||||
binding.submitted.update(file_ids)
|
||||
return pm.MessageChain(components)
|
||||
|
||||
|
||||
class RunnerBoxService:
|
||||
def __init__(self, box):
|
||||
self.box = box
|
||||
|
||||
async def status(self, context):
|
||||
status = await self.box.get_status(context)
|
||||
capacity = status.get('capacity', {})
|
||||
return {
|
||||
'enabled': self.box.enabled,
|
||||
'available': status.get('available', False),
|
||||
'limit': capacity.get('limit'),
|
||||
'used': capacity.get('used'),
|
||||
'remaining': capacity.get('remaining'),
|
||||
'required_reuse_key': 'global' if self.box.managed_admission_required else None,
|
||||
'reason': status.get('connector_error'),
|
||||
}
|
||||
|
||||
async def sessions(self, context):
|
||||
# Do not hide connection failures behind an empty list.
|
||||
context = await self.box.require_workspace_sandbox(context)
|
||||
return await self.box.client.get_sessions(action_context=self.box._action_context(context))
|
||||
|
||||
@staticmethod
|
||||
def public_session(item):
|
||||
return {'id': item['session_id'], 'status': item.get('status', 'idle')}
|
||||
|
||||
async def acquire(self, context, data, query=None):
|
||||
request = BoxAcquireRequest.model_validate(data)
|
||||
key = request.reuse_key
|
||||
if self.box.managed_admission_required:
|
||||
if key != 'global':
|
||||
raise BoxValidationError('This deployment requires the global Box reuse key')
|
||||
session_id = 'global'
|
||||
else:
|
||||
session_id = 'runner-' + hashlib.sha256(key.encode()).hexdigest()
|
||||
# Plugin options cannot override policy, host mounts, or execution identity.
|
||||
if set(request.options) - {'image'}:
|
||||
raise BoxValidationError('Only the Box image can be specified; resource limits are Host-owned')
|
||||
spec = {'session_id': session_id, **request.options}
|
||||
if query is not None:
|
||||
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
|
||||
result = await self.box.create_session(context, spec)
|
||||
return self.public_session(result)
|
||||
|
||||
async def bind(self, context, query, run_id, box_id):
|
||||
current = getattr(query, '_box_binding', None)
|
||||
if current is not None:
|
||||
if current.run_id != run_id or current.session_id != box_id:
|
||||
raise BoxValidationError('A run cannot change its Box after binding')
|
||||
return self.binding_info(current)
|
||||
sessions = await self.sessions(context)
|
||||
item = next((item for item in sessions if item['session_id'] == box_id), None)
|
||||
if item is None:
|
||||
raise BoxValidationError('Box not found in the current Workspace')
|
||||
current = getattr(query, '_box_binding', None)
|
||||
if current is not None:
|
||||
if current.run_id != run_id or current.session_id != box_id:
|
||||
raise BoxValidationError('A run cannot change its Box after binding')
|
||||
return self.binding_info(current)
|
||||
fields = ('image', 'network', 'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs', 'persistent')
|
||||
spec = {key: item[key] for key in fields if key in item}
|
||||
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
|
||||
binding = RunBoxBinding(run_id, box_id, spec, run_id)
|
||||
object.__setattr__(query, '_box_binding', binding)
|
||||
return self.binding_info(binding)
|
||||
|
||||
@staticmethod
|
||||
def binding_info(binding):
|
||||
return {
|
||||
'box_id': binding.session_id,
|
||||
'outbox': f'/workspace/outbox/{binding.io_scope}',
|
||||
}
|
||||
|
||||
async def import_attachments(self, query, attachment_ids):
|
||||
binding = binding_for(query)
|
||||
available = {f'attachment-{i}': c for i, c in enumerate(input_files(query))}
|
||||
ids = list(available) if attachment_ids is None else attachment_ids
|
||||
if not isinstance(ids, list) or any(type(key) is not str or key not in available for key in ids):
|
||||
raise BoxValidationError('Unknown input attachment reference')
|
||||
if len(ids) != len(set(ids)):
|
||||
raise BoxValidationError('Duplicate input attachment reference')
|
||||
async with binding.lock:
|
||||
for key in ids:
|
||||
if key in binding.imported:
|
||||
continue
|
||||
transfer = copy.copy(query)
|
||||
# Each input has its own directory: repeated imports cannot erase other files.
|
||||
object.__setattr__(transfer, '_box_binding', replace(binding, io_scope=f'{binding.io_scope}-{key}'))
|
||||
transfer.message_chain = pm.MessageChain([available[key]])
|
||||
items = await self.box.materialize_inbound_attachments(transfer)
|
||||
if not items:
|
||||
raise BoxValidationError(f'Failed to import input attachment {key}')
|
||||
binding.imported[key] = {'id': key, **items[0]}
|
||||
return {'items': [binding.imported[key] for key in ids]}
|
||||
|
||||
async def export_files(self, query):
|
||||
binding = binding_for(query)
|
||||
async with binding.lock:
|
||||
items = await self.box.collect_outbound_attachments(query)
|
||||
for item in items:
|
||||
if len(binding.exported) >= 100:
|
||||
raise BoxValidationError('At most 100 output files may be exported per run')
|
||||
size = len(base64.b64decode(item['base64'].split(';base64,')[-1]))
|
||||
if sum(x['size'] for x in binding.exported.values()) + size > self.box._ATTACHMENT_MAX_TOTAL_BYTES:
|
||||
raise BoxValidationError('Output files exceed the per-run byte limit')
|
||||
key = str(uuid.uuid4())
|
||||
binding.exported[key] = {
|
||||
**item,
|
||||
'id': key,
|
||||
'size': size,
|
||||
}
|
||||
return {
|
||||
'items': [
|
||||
{k: v for k, v in item.items() if k != 'base64'}
|
||||
for key, item in binding.exported.items()
|
||||
if key not in binding.submitted
|
||||
]
|
||||
}
|
||||
+22
-102
@@ -38,8 +38,6 @@ _INT_ADAPTER = pydantic.TypeAdapter(int)
|
||||
_UTC = _dt.timezone.utc
|
||||
_MAX_RECENT_ERRORS = 50
|
||||
_MIB = 1024 * 1024
|
||||
_HOST_BOX_SCOPE_VARIABLE = '_host_box_scope'
|
||||
_BOX_SESSION_ID_PREFIX = 'lb-box-'
|
||||
_DEFAULT_MAX_WORKSPACE_ENTRIES = 100_000
|
||||
_HARD_MAX_WORKSPACE_ENTRIES = 1_000_000
|
||||
|
||||
@@ -464,7 +462,9 @@ class BoxService:
|
||||
|
||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
if self._cloud_managed:
|
||||
if self._admission is None:
|
||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||
@@ -579,7 +579,15 @@ class BoxService:
|
||||
skip_host_mount_validation: bool = False,
|
||||
) -> dict:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
from .runner import binding_for
|
||||
|
||||
binding = binding_for(query)
|
||||
if spec_payload.get('session_id') not in (None, '', binding.session_id):
|
||||
raise BoxValidationError('session_id must match the bound Box')
|
||||
spec_payload = {**binding.spec, **spec_payload, 'session_id': binding.session_id}
|
||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
@@ -632,49 +640,10 @@ class BoxService:
|
||||
return self._serialize_result(result)
|
||||
|
||||
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
|
||||
"""Resolve a Host-owned Box session ID for the current conversation."""
|
||||
if query is None:
|
||||
raise BoxValidationError('Box execution requires a Host session context.')
|
||||
"""Use the Box explicitly bound by the current Runner invocation."""
|
||||
from .runner import binding_for
|
||||
|
||||
if self._cloud_managed:
|
||||
return 'global'
|
||||
|
||||
variables = getattr(query, 'variables', None)
|
||||
if isinstance(variables, dict) and _HOST_BOX_SCOPE_VARIABLE in variables:
|
||||
private_scope = variables[_HOST_BOX_SCOPE_VARIABLE]
|
||||
if not isinstance(private_scope, str) or not private_scope.strip():
|
||||
raise BoxValidationError('Box execution requires a Host conversation scope.')
|
||||
return self._hash_box_session_scope(f'host:{private_scope}')
|
||||
|
||||
forced_template = self._forced_box_session_id_template()
|
||||
if forced_template:
|
||||
template = forced_template
|
||||
else:
|
||||
template = (
|
||||
(query.pipeline_config or {})
|
||||
.get('ai', {})
|
||||
.get('local-agent', {})
|
||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
)
|
||||
variables = dict(query.variables or {})
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
launcher_type = launcher_type.value
|
||||
|
||||
sender_id = getattr(query, 'sender_id', None)
|
||||
query_id = getattr(query, 'query_id', None)
|
||||
variables.setdefault('query_id', str(query_id or 'unknown'))
|
||||
variables.setdefault('launcher_type', str(launcher_type or 'query'))
|
||||
variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown'))
|
||||
variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown'))
|
||||
variables.setdefault('global', 'global')
|
||||
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
||||
|
||||
@staticmethod
|
||||
def _hash_box_session_scope(scope: str) -> str:
|
||||
digest = hashlib.sha256(scope.encode('utf-8')).hexdigest()
|
||||
return f'{_BOX_SESSION_ID_PREFIX}{digest}'
|
||||
return binding_for(query).session_id
|
||||
|
||||
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Build extra_mounts entries for all pipeline-bound skills.
|
||||
@@ -822,6 +791,9 @@ class BoxService:
|
||||
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
|
||||
|
||||
def _attachment_query_key(self, query: pipeline_query.Query) -> str:
|
||||
binding = getattr(query, '_box_binding', None)
|
||||
if binding is not None:
|
||||
return binding.io_scope
|
||||
query_uuid = str(getattr(query, 'query_uuid', '') or '').strip()
|
||||
if query_uuid:
|
||||
if query_uuid in {'.', '..'} or '/' in query_uuid or '\\' in query_uuid or '\x00' in query_uuid:
|
||||
@@ -1833,16 +1805,6 @@ class BoxService:
|
||||
raw = str(self._local_config().get('image', '') or '').strip()
|
||||
return raw or None
|
||||
|
||||
def _forced_box_session_id_template(self) -> str:
|
||||
"""Return the operator-forced sandbox scope template, if configured."""
|
||||
|
||||
limitation = (
|
||||
(self.ap.instance_config.data or {}).get('system', {}).get('limitation', {})
|
||||
if getattr(self.ap, 'instance_config', None) is not None
|
||||
else {}
|
||||
)
|
||||
return str(limitation.get('force_box_session_id_template', '') or '').strip()
|
||||
|
||||
def _load_workspace_quota_mb(self) -> int | None:
|
||||
raw_value = self._local_config().get('workspace_quota_mb')
|
||||
if raw_value in (None, ''):
|
||||
@@ -2069,51 +2031,6 @@ class BoxService:
|
||||
and error.get('workspace_uuid') == execution_context.workspace_uuid
|
||||
]
|
||||
|
||||
def get_system_guidance(self, query: pipeline_query.Query | int | str | None = None) -> str:
|
||||
"""Return LLM system-prompt guidance for the exec tool.
|
||||
|
||||
All execution-specific prompt text is kept here so that callers
|
||||
(e.g. LocalRunner) stay free of box domain knowledge.
|
||||
|
||||
``query`` is the current turn's pipeline query. When provided,
|
||||
the guidance ALWAYS advertises the per-query outbox path so the agent
|
||||
knows how to deliver generated files back to the user — even on turns
|
||||
where the user sent no inbound attachment (e.g. "generate a QR code"),
|
||||
which is exactly when the inbound-attachment note never fires. Outbound
|
||||
collection in the wrapper runs on every turn regardless of inbound
|
||||
files, so without this the file would be produced and silently dropped.
|
||||
"""
|
||||
guidance = (
|
||||
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
|
||||
'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, '
|
||||
'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec '
|
||||
'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation '
|
||||
'details, do not include the generated script in the final answer; return the result and a brief explanation only.'
|
||||
)
|
||||
if self.default_workspace:
|
||||
guidance += (
|
||||
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or '
|
||||
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
|
||||
'user for directory parameters unless they explicitly need a different directory.'
|
||||
)
|
||||
if query is not None:
|
||||
if not isinstance(query, (int, str)):
|
||||
query_key = self._attachment_query_key(query)
|
||||
else:
|
||||
# Backwards compatibility for OSS callers/tests that passed
|
||||
# the old process-local integer identity. Cloud callers must
|
||||
# pass the full Query so an opaque UUID is always advertised.
|
||||
if self._cloud_managed:
|
||||
raise BoxValidationError('Cloud outbox guidance requires a pipeline Query')
|
||||
query_key = str(query)
|
||||
outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_key}'
|
||||
guidance += (
|
||||
f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, '
|
||||
f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be '
|
||||
'delivered to the user automatically; do not paste file contents or base64 into your reply.'
|
||||
)
|
||||
return guidance
|
||||
|
||||
async def get_backend_status(self) -> dict:
|
||||
"""Return instance-level backend readiness without tenant resource data."""
|
||||
|
||||
@@ -2170,5 +2087,8 @@ class BoxService:
|
||||
if backend_name:
|
||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||
else:
|
||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
||||
payload['connector_error'] = (
|
||||
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||
'Trusted local development may explicitly select the unsafe host backend.'
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
|
||||
# The database cursor remains the shared projection high-water mark,
|
||||
# while this cursor tracks what this process has actually observed.
|
||||
self._consumer_cursor: int | None = None
|
||||
self._sync_lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def refresh_snapshot(self) -> None:
|
||||
"""Refresh from one full signed snapshot within the sync single-flight."""
|
||||
|
||||
async with self._sync_lock:
|
||||
await self._refresh_snapshot()
|
||||
|
||||
async def _refresh_snapshot(self) -> None:
|
||||
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||
for _attempt in range(5):
|
||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
|
||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||
|
||||
async def sync_once(self) -> None:
|
||||
async with self._sync_lock:
|
||||
await self._sync_once()
|
||||
|
||||
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
|
||||
"""Synchronously project an exact Workspace set without moving the event cursor."""
|
||||
|
||||
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
|
||||
if not requested:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
|
||||
if len(requested) > self.event_limit:
|
||||
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
|
||||
async with self._sync_lock:
|
||||
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
|
||||
await self._apply_targeted_delta(delta, requested)
|
||||
|
||||
async def _apply_targeted_delta(
|
||||
self,
|
||||
delta: DirectoryDelta,
|
||||
requested_workspace_uuids: tuple[str, ...],
|
||||
) -> None:
|
||||
if not isinstance(delta, DirectoryDelta):
|
||||
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
|
||||
workspace_count, membership_count = self._validate_batch_capacity(
|
||||
delta.workspaces,
|
||||
full_snapshot=False,
|
||||
)
|
||||
delta = DirectoryDelta.model_validate(delta.model_dump())
|
||||
if delta.instance_uuid != self.instance_uuid:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
|
||||
requested = set(requested_workspace_uuids)
|
||||
if set(delta.requested_workspace_uuids) != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
|
||||
if {workspace.uuid for workspace in delta.workspaces} != requested:
|
||||
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
|
||||
|
||||
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
|
||||
if not callable(directory_uow):
|
||||
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
|
||||
|
||||
async with directory_uow(self.instance_uuid) as uow:
|
||||
session = uow.session
|
||||
state = await session.scalar(
|
||||
sqlalchemy.select(DirectoryProjectionState)
|
||||
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if state is None:
|
||||
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
|
||||
snapshot = DirectorySnapshot(
|
||||
instance_uuid=self.instance_uuid,
|
||||
cursor=state.cursor,
|
||||
generated_at=delta.generated_at,
|
||||
workspaces=delta.workspaces,
|
||||
)
|
||||
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
|
||||
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
|
||||
active_workspace_count = await self._enforce_active_workspace_capacity(session)
|
||||
await session.flush()
|
||||
|
||||
await self._update_entitlement_workspace_activity(
|
||||
snapshot.workspaces,
|
||||
requested_workspace_uuids=requested,
|
||||
)
|
||||
self._publish_runtime_execution_projection(
|
||||
snapshot.workspaces,
|
||||
affected_workspace_uuids=requested,
|
||||
)
|
||||
self._request_model_catalog_sync()
|
||||
self._record_batch_cardinality(
|
||||
active_workspaces=active_workspace_count,
|
||||
workspaces=workspace_count,
|
||||
memberships=membership_count,
|
||||
)
|
||||
|
||||
async def _sync_once(self) -> None:
|
||||
cursor = self._consumer_cursor
|
||||
if cursor is None:
|
||||
await self.initialize()
|
||||
await self._refresh_snapshot()
|
||||
return
|
||||
batch = await self.provider.fetch_events(
|
||||
self.instance_uuid,
|
||||
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
|
||||
for row in inbox_rows:
|
||||
row.applied_at = now
|
||||
|
||||
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
|
||||
async def _apply_accounts(
|
||||
self,
|
||||
session: Any,
|
||||
snapshot: DirectorySnapshot,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
) -> dict[str, User]:
|
||||
selected: dict[str, DirectoryMember] = {}
|
||||
emails: dict[str, str] = {}
|
||||
for workspace in snapshot.workspaces:
|
||||
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
|
||||
continue
|
||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
||||
if preserve_existing:
|
||||
# A targeted Workspace fetch has no independently monotonic
|
||||
# Account revision. It may create a missing runtime shadow, but
|
||||
# ordered event/snapshot projection remains the only updater of
|
||||
# existing Account identity and status fields.
|
||||
continue
|
||||
if account.projection_revision > snapshot.cursor:
|
||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||
projected_account = self._account_projection(member)
|
||||
|
||||
@@ -653,9 +653,9 @@ class Application:
|
||||
frontend_path = paths.get_frontend_path()
|
||||
|
||||
if not os.path.exists(frontend_path):
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
|
||||
self.logger.warning(
|
||||
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
|
||||
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ required_deps = {
|
||||
'slack_sdk': 'slack_sdk',
|
||||
'asyncpg': 'asyncpg',
|
||||
'litellm': 'litellm',
|
||||
'xxhash': 'xxhash',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from .. import stage, app
|
||||
|
||||
# This stage runs before SetupLoggerStage, so ap.logger is still None here;
|
||||
# the module logger falls back to the stderr lastResort handler.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits.
|
||||
_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
_RECOVERY_KEY_LENGTH = 8
|
||||
|
||||
|
||||
@stage.stage_class('GenKeysStage')
|
||||
class GenKeysStage(stage.BootingStage):
|
||||
@@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage):
|
||||
ap.instance_config.data['system']['recovery_key'] = ''
|
||||
|
||||
if not ap.instance_config.data['system']['recovery_key']:
|
||||
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
|
||||
# Keep recovery practical to type. Security also requires the reset
|
||||
# endpoint's concurrency-safe quota (five admissions per 15 minutes).
|
||||
ap.instance_config.data['system']['recovery_key'] = ''.join(
|
||||
secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH)
|
||||
)
|
||||
await ap.instance_config.dump_config()
|
||||
elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH:
|
||||
_logger.warning(
|
||||
'Low-entropy legacy recovery key detected (length < 8); '
|
||||
'regenerate system.recovery_key in the configuration file '
|
||||
'with a strong random value (#2392)'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import typing
|
||||
import datetime
|
||||
import time
|
||||
@@ -197,6 +198,41 @@ class TaskWrapper:
|
||||
},
|
||||
}
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
"""Return the stable task projection exposed to API-key callers."""
|
||||
if self.task.cancelled():
|
||||
status = 'cancelled'
|
||||
error = {'type': 'task_cancelled', 'message': 'Task was cancelled'}
|
||||
result = None
|
||||
elif not self.task.done():
|
||||
status = 'running'
|
||||
error = None
|
||||
result = None
|
||||
else:
|
||||
exception = self.assume_exception()
|
||||
if exception is not None:
|
||||
status = 'failed'
|
||||
error = {'type': 'task_failed', 'message': 'Task execution failed'}
|
||||
result = None
|
||||
else:
|
||||
status = 'succeeded'
|
||||
error = None
|
||||
result = self.assume_result()
|
||||
try:
|
||||
json.dumps(result)
|
||||
except (TypeError, ValueError):
|
||||
result = None
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'task_type': self.task_type,
|
||||
'kind': self.kind,
|
||||
'status': status,
|
||||
'error': error,
|
||||
'result': result,
|
||||
'created_at': self.created_at,
|
||||
}
|
||||
|
||||
def cancel(self):
|
||||
self.task.cancel()
|
||||
|
||||
@@ -325,19 +361,20 @@ class AsyncTaskManager:
|
||||
instance_uuid: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
placement_generation: int | None = None,
|
||||
public: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
'tasks': [
|
||||
t.to_dict()
|
||||
for t in self.tasks
|
||||
if (type is None or t.task_type == type)
|
||||
and (kind is None or t.kind == kind)
|
||||
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
||||
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
||||
and (placement_generation is None or t.placement_generation == placement_generation)
|
||||
],
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
tasks = [
|
||||
t.to_public_dict() if public else t.to_dict()
|
||||
for t in self.tasks
|
||||
if (type is None or t.task_type == type)
|
||||
and (kind is None or t.kind == kind)
|
||||
and (instance_uuid is None or t.instance_uuid == instance_uuid)
|
||||
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
|
||||
and (placement_generation is None or t.placement_generation == placement_generation)
|
||||
]
|
||||
if public:
|
||||
return {'tasks': tasks}
|
||||
return {'tasks': tasks, 'id_index': TaskWrapper._id_index}
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
completed = sum(1 for t in self.tasks if t.task.done())
|
||||
|
||||
@@ -33,6 +33,28 @@ class ModelProvider(Base):
|
||||
)
|
||||
|
||||
|
||||
class CodexCredential(Base):
|
||||
"""Server-only OAuth state. Never joined into provider/model serialization."""
|
||||
|
||||
__tablename__ = 'codex_credentials'
|
||||
|
||||
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
|
||||
payload = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
|
||||
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
lease_owner = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
lease_until = sqlalchemy.Column(sqlalchemy.Float, nullable=False, default=0)
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'provider_uuid'],
|
||||
['model_providers.workspace_uuid', 'model_providers.uuid'],
|
||||
name='fk_codex_credentials_workspace_provider',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sqlalchemy.Index('ix_codex_credentials_workspace', 'workspace_uuid'),
|
||||
)
|
||||
|
||||
|
||||
class LLMModel(Base):
|
||||
"""LLM model"""
|
||||
|
||||
|
||||
@@ -111,8 +111,8 @@ class MonitoringSession(Base):
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, index=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PasskeyCredential(Base):
|
||||
__tablename__ = 'passkey_credentials'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||
uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
nullable=False,
|
||||
default=lambda: str(uuid_lib.uuid4()),
|
||||
)
|
||||
account_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
|
||||
aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
|
||||
transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True),
|
||||
sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True),
|
||||
sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'),
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tenant-owned original configurations; never expose through public serialization."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class PipelineMigrationSnapshot(Base):
|
||||
__tablename__ = 'pipeline_migration_snapshots'
|
||||
|
||||
uuid = sa.Column(sa.String(36), primary_key=True)
|
||||
workspace_uuid = sa.Column(sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False)
|
||||
pipeline_uuid = sa.Column(sa.String(255), nullable=False)
|
||||
source_fingerprint = sa.Column(sa.String(64), nullable=False)
|
||||
target_fingerprint = sa.Column(sa.String(64), nullable=False)
|
||||
planner_version = sa.Column(sa.String(64), nullable=False)
|
||||
source_snapshot = sa.Column(sa.JSON, nullable=False)
|
||||
state = sa.Column(sa.String(32), nullable=False)
|
||||
created_at = sa.Column(sa.DateTime, nullable=False, server_default=sa.func.now())
|
||||
|
||||
__table_args__ = (
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'pipeline_uuid'],
|
||||
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
|
||||
name='fk_pipeline_migration_workspace_pipeline',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source'
|
||||
),
|
||||
sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'),
|
||||
sa.Index('ix_pipeline_migration_workspace_pipeline', 'workspace_uuid', 'pipeline_uuid'),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add isolated server-only Codex credentials and tenant RLS.
|
||||
|
||||
Revision ID: 0022_codex_credentials
|
||||
Revises: 0021_merge_reasoning_config
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0022_codex_credentials'
|
||||
down_revision = '0021_merge_reasoning_config'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# Fresh startup creates ORM metadata before running Alembic.
|
||||
if 'codex_credentials' not in sa.inspect(conn).get_table_names():
|
||||
op.create_table(
|
||||
'codex_credentials',
|
||||
sa.Column('provider_uuid', sa.String(255), primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('payload', sa.JSON(), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.Column('lease_owner', sa.String(64), nullable=True),
|
||||
sa.Column('lease_until', sa.Float(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'provider_uuid'],
|
||||
['model_providers.workspace_uuid', 'model_providers.uuid'],
|
||||
name='fk_codex_credentials_workspace_provider',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
)
|
||||
op.create_index('ix_codex_credentials_workspace', 'codex_credentials', ['workspace_uuid'])
|
||||
if conn.dialect.name == 'postgresql':
|
||||
op.execute('ALTER TABLE codex_credentials ENABLE ROW LEVEL SECURITY')
|
||||
op.execute('ALTER TABLE codex_credentials FORCE ROW LEVEL SECURITY')
|
||||
op.execute('DROP POLICY IF EXISTS langbot_workspace_isolation ON codex_credentials')
|
||||
expression = "workspace_uuid::text = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
|
||||
op.execute(
|
||||
f'CREATE POLICY langbot_workspace_isolation ON codex_credentials '
|
||||
f'FOR ALL USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('codex_credentials')
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Scope monitoring sessions by bot without changing runtime session IDs.
|
||||
|
||||
Revision ID: 0023_bot_scoped_sessions
|
||||
Revises: 0022_codex_credentials
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
|
||||
revision = '0023_bot_scoped_sessions'
|
||||
down_revision = '0022_codex_credentials'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = 'monitoring_sessions'
|
||||
_KEY = ['workspace_uuid', 'bot_id', 'session_id']
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if _TABLE not in inspector.get_table_names():
|
||||
return
|
||||
pk = inspector.get_pk_constraint(_TABLE)
|
||||
if pk['constrained_columns'] == _KEY:
|
||||
return
|
||||
# PostgreSQL alters in place, retaining indexes, grants, policies and RLS.
|
||||
# SQLite batch reflection retains all existing indexes and foreign keys.
|
||||
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
|
||||
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
|
||||
batch.create_primary_key(f'pk_{_TABLE}', _KEY)
|
||||
|
||||
metadata = sa.MetaData()
|
||||
sessions = sa.Table(_TABLE, metadata, autoload_with=conn)
|
||||
messages = sa.Table('monitoring_messages', metadata, autoload_with=conn)
|
||||
m = messages.c
|
||||
collisions = (
|
||||
sa.select(m.workspace_uuid, m.session_id)
|
||||
.group_by(m.workspace_uuid, m.session_id)
|
||||
.having(sa.func.count(sa.distinct(m.bot_id)) > 1)
|
||||
.subquery()
|
||||
)
|
||||
partition = [m.workspace_uuid, m.bot_id, m.session_id]
|
||||
# Repair only demonstrable collisions. Retention may have removed earlier
|
||||
# evidence; these summaries describe surviving messages, never invented text.
|
||||
ranked = (
|
||||
sa.select(
|
||||
*[m[name] for name in _KEY],
|
||||
m.bot_name,
|
||||
m.pipeline_id,
|
||||
m.pipeline_name,
|
||||
m.platform,
|
||||
m.user_id,
|
||||
m.user_name,
|
||||
sa.func.sum(sa.case((sa.or_(m.role == 'user', m.role.is_(None)), 1), else_=0))
|
||||
.over(partition_by=partition)
|
||||
.label('message_count'),
|
||||
sa.func.min(m.timestamp).over(partition_by=partition).label('start_time'),
|
||||
sa.func.max(m.timestamp).over(partition_by=partition).label('last_activity'),
|
||||
sa.func.row_number().over(partition_by=partition, order_by=[m.timestamp.desc(), m.id.desc()]).label('rank'),
|
||||
)
|
||||
.join(
|
||||
collisions,
|
||||
sa.and_(m.workspace_uuid == collisions.c.workspace_uuid, m.session_id == collisions.c.session_id),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
columns = _KEY + [
|
||||
'bot_name',
|
||||
'pipeline_id',
|
||||
'pipeline_name',
|
||||
'platform',
|
||||
'user_id',
|
||||
'user_name',
|
||||
'message_count',
|
||||
'start_time',
|
||||
'last_activity',
|
||||
'is_active',
|
||||
]
|
||||
select = sa.select(*[ranked.c[name] for name in columns[:-1]], sa.literal(True)).where(ranked.c.rank == 1)
|
||||
insert = postgresql.insert if conn.dialect.name == 'postgresql' else sqlite.insert
|
||||
statement = insert(sessions).from_select(columns, select)
|
||||
conn.execute(
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=_KEY,
|
||||
set_={name: statement.excluded[name] for name in columns if name not in _KEY and name != 'is_active'},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _TABLE not in sa.inspect(conn).get_table_names():
|
||||
return
|
||||
collisions = conn.execute(
|
||||
sa.text('SELECT 1 FROM monitoring_sessions GROUP BY workspace_uuid, session_id HAVING COUNT(*) > 1 LIMIT 1')
|
||||
).first()
|
||||
if collisions:
|
||||
raise RuntimeError('Cannot downgrade bot-scoped sessions without losing colliding bot records')
|
||||
pk = sa.inspect(conn).get_pk_constraint(_TABLE)
|
||||
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
|
||||
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
|
||||
batch.create_primary_key(f'pk_{_TABLE}', ['workspace_uuid', 'session_id'])
|
||||
@@ -0,0 +1,54 @@
|
||||
"""add passkey credentials table
|
||||
|
||||
Revision ID: 0024_passkey_credentials
|
||||
Revises: 0023_bot_scoped_sessions
|
||||
Create Date: 2026-09-12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '0024_passkey_credentials'
|
||||
down_revision = '0023_bot_scoped_sessions'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE_NAME = 'passkey_credentials'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
if _TABLE_NAME not in existing_tables:
|
||||
op.create_table(
|
||||
_TABLE_NAME,
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('uuid', sa.String(36), nullable=False),
|
||||
sa.Column(
|
||||
'account_uuid',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('credential_id', sa.String(255), nullable=False),
|
||||
sa.Column('public_key', sa.Text(), nullable=False),
|
||||
sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('aaguid', sa.String(64), nullable=True),
|
||||
sa.Column('transports', sa.String(255), nullable=True),
|
||||
sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
|
||||
op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True)
|
||||
op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME)
|
||||
op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME)
|
||||
op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Join the published master credentials and 4.11 bot processor branches.
|
||||
|
||||
Revision ID: 0026_merge_master_beta
|
||||
Revises: 0024_passkey_credentials, 0025_bot_plugin_processors
|
||||
|
||||
Both parent chains are already published. Keep their identities and schema/data
|
||||
operations intact; Alembic applies the missing branch before this no-op merge.
|
||||
"""
|
||||
|
||||
revision = '0026_merge_master_beta'
|
||||
down_revision = ('0024_passkey_credentials', '0025_bot_plugin_processors')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Only unmerge the revision graph; neither branch's schema/data is removed.
|
||||
pass
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Create the manual migration journal only; never convert pipeline JSON."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0027_pipeline_migration'
|
||||
down_revision = '0026_merge_master_beta'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
table = 'pipeline_migration_snapshots'
|
||||
if not sa.inspect(bind).has_table(table):
|
||||
op.create_table(
|
||||
table,
|
||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
'workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False
|
||||
),
|
||||
sa.Column('pipeline_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('source_fingerprint', sa.String(64), nullable=False),
|
||||
sa.Column('target_fingerprint', sa.String(64), nullable=False),
|
||||
sa.Column('planner_version', sa.String(64), nullable=False),
|
||||
sa.Column('source_snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('state', sa.String(32), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'pipeline_uuid'],
|
||||
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
|
||||
name='fk_pipeline_migration_workspace_pipeline',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source'
|
||||
),
|
||||
sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'),
|
||||
)
|
||||
op.create_index('ix_pipeline_migration_workspace_pipeline', table, ['workspace_uuid', 'pipeline_uuid'])
|
||||
if bind.dialect.name == 'postgresql':
|
||||
op.execute(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')
|
||||
op.execute(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')
|
||||
op.execute(f'DROP POLICY IF EXISTS langbot_workspace_isolation ON {table}')
|
||||
expression = "workspace_uuid = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
|
||||
op.execute(
|
||||
f'CREATE POLICY langbot_workspace_isolation ON {table} FOR ALL TO PUBLIC '
|
||||
f'USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('pipeline_migration_snapshots')
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Join pipeline migration and knowledge base draft branches.
|
||||
|
||||
Revision ID: 0028_merge_knowledge_drafts
|
||||
Revises: 0027_pipeline_migration, 0026_knowledge_base_drafts
|
||||
"""
|
||||
|
||||
revision = '0028_merge_knowledge_drafts'
|
||||
down_revision = ('0027_pipeline_migration', '0026_knowledge_base_drafts')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Only unmerge the revision graph; neither branch's schema is removed.
|
||||
pass
|
||||
@@ -64,11 +64,14 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'binary_storages',
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'codex_credentials',
|
||||
'passkey_credentials',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
'legacy_pipelines',
|
||||
'pipeline_run_records',
|
||||
'pipeline_migration_snapshots',
|
||||
'plugin_settings',
|
||||
'knowledge_bases',
|
||||
'knowledge_base_files',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Transaction-held, tenant/pipeline-scoped admission for form producers.
|
||||
|
||||
Every pending producer must hold this row until its INSERT commits. Migration
|
||||
holds the same row for both CAS and activation. No table/process/advisory lock.
|
||||
The no-op UPDATE also acquires SQLite's write reservation before any reads.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from ..entity.persistence.pipeline import LegacyPipeline
|
||||
|
||||
|
||||
async def lock_pipeline_admission(session, workspace_uuid: str, pipeline_uuid: str):
|
||||
if not workspace_uuid or not pipeline_uuid:
|
||||
raise ValueError('Pipeline admission requires exact ownership')
|
||||
t = LegacyPipeline.__table__
|
||||
result = await session.execute(
|
||||
sa.update(t)
|
||||
.where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
|
||||
.values(updated_at=t.c.updated_at)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise ValueError('Pipeline admission authority unavailable')
|
||||
return (
|
||||
await session.execute(
|
||||
sa.select(t.c.config).where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
|
||||
)
|
||||
).scalar_one()
|
||||
@@ -52,11 +52,13 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'binary_storages': 'workspace_uuid',
|
||||
'mcp_servers': 'workspace_uuid',
|
||||
'model_providers': 'workspace_uuid',
|
||||
'codex_credentials': 'workspace_uuid',
|
||||
'llm_models': 'workspace_uuid',
|
||||
'embedding_models': 'workspace_uuid',
|
||||
'rerank_models': 'workspace_uuid',
|
||||
'legacy_pipelines': 'workspace_uuid',
|
||||
'pipeline_run_records': 'workspace_uuid',
|
||||
'pipeline_migration_snapshots': 'workspace_uuid',
|
||||
'plugin_settings': 'workspace_uuid',
|
||||
'knowledge_bases': 'workspace_uuid',
|
||||
'knowledge_base_files': 'workspace_uuid',
|
||||
@@ -207,6 +209,8 @@ _SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None]
|
||||
_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'coalesce': sqlalchemy.sql.functions.coalesce,
|
||||
'count': sqlalchemy.sql.functions.count,
|
||||
'min': sqlalchemy.sql.functions.min,
|
||||
'max': sqlalchemy.sql.functions.max,
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
@@ -464,9 +468,21 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
||||
|
||||
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'
|
||||
# Manual Pipeline migration compares DB-native JSON text, because
|
||||
# PostgreSQL JSON has no equality operator. Permit only these fixed
|
||||
# mapped columns and a built-in Text target, never arbitrary casts.
|
||||
source = element.clause
|
||||
pipeline_json_cas = (
|
||||
type(element.type) is sqlalchemy.Text
|
||||
and isinstance(source, sqlalchemy.Column)
|
||||
and type(source.type) is sqlalchemy.JSON
|
||||
and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines'
|
||||
and source.name in {'config', 'stages', 'extensions_preferences'}
|
||||
)
|
||||
if not pipeline_json_cas:
|
||||
raise ScopedSessionTransactionError(
|
||||
'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts'
|
||||
)
|
||||
|
||||
if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
|
||||
function_name = str(getattr(element, 'name', '')).casefold()
|
||||
@@ -853,7 +869,30 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
|
||||
self._require_owner_task()
|
||||
self._enter_internal_access()
|
||||
try:
|
||||
await transaction.commit()
|
||||
# Retain the actual connection before COMMIT: after a failed SQLite
|
||||
# COMMIT the logical transaction is inactive, but the DBAPI writer
|
||||
# can still hold PENDING/RESERVED locks. Session.close()/rollback()
|
||||
# alone can then return that poisoned connection to the pool.
|
||||
connection = await super().connection()
|
||||
try:
|
||||
await transaction.commit()
|
||||
except BaseException as exc:
|
||||
cleanup = asyncio.create_task(connection.invalidate())
|
||||
# Invalidation does not access the task-owned Session. Shield
|
||||
# physical cleanup, including against repeated cancellation,
|
||||
# before the owner closes the Session and releases its scope.
|
||||
while not cleanup.done():
|
||||
try:
|
||||
await asyncio.shield(cleanup)
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except BaseException:
|
||||
break
|
||||
try:
|
||||
cleanup.result()
|
||||
except BaseException as cleanup_error:
|
||||
exc.add_note(f'Failed to invalidate transaction connection: {cleanup_error!r}')
|
||||
raise
|
||||
finally:
|
||||
self._exit_internal_access()
|
||||
|
||||
@@ -1370,6 +1409,7 @@ class TenantUnitOfWork:
|
||||
state.mark_rollback_only(exc_value)
|
||||
rollback_only = state.rollback_only
|
||||
committed = False
|
||||
transaction_error: BaseException | None = None
|
||||
try:
|
||||
if exc_type is None and not rollback_only:
|
||||
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
|
||||
@@ -1382,6 +1422,9 @@ class TenantUnitOfWork:
|
||||
_UOW_SESSION_CONTROL_CAPABILITY,
|
||||
transaction,
|
||||
)
|
||||
except BaseException as exc:
|
||||
transaction_error = exc
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
if self._active_transaction is not None and self._context_token is not None:
|
||||
@@ -1389,6 +1432,10 @@ class TenantUnitOfWork:
|
||||
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
|
||||
_UOW_SESSION_CONTROL_CAPABILITY
|
||||
)
|
||||
except BaseException as cleanup_error:
|
||||
if transaction_error is None:
|
||||
raise
|
||||
transaction_error.add_note(f'Failed to close transaction Session: {cleanup_error!r}')
|
||||
finally:
|
||||
if self._database_operation_token is not None:
|
||||
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
"""Pure planning for explicitly requested legacy Pipeline migrations.
|
||||
|
||||
The candidate config is internal-only: never serialize it in a preview or log
|
||||
it. ``ready`` means a config candidate, NOT permission to execute: installation,
|
||||
resource ownership and existing conversation-state checks belong to the caller.
|
||||
No IO, registry access, defaults from UI schemas, or runtime mutation occurs here.
|
||||
|
||||
Reviewed native source: 9b7ba0d64708496ace30a82866f6dbc185f089dc.
|
||||
Reviewed official plugins: local parity patches over 678b18fd65af98230806c3b97b649514c83acea9.
|
||||
Patch manifest versions below are required; version alone is not artifact proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
from string import Formatter
|
||||
|
||||
PLANNER_VERSION = '3'
|
||||
|
||||
_TARGETS = {
|
||||
'local-agent': ('LocalAgent', '0.1.7'),
|
||||
'dify-service-api': ('DifyAgent', '0.1.7'),
|
||||
'coze-api': ('CozeAgent', '0.1.7'),
|
||||
'dashscope-app-api': ('DashScopeAgent', '0.1.7'),
|
||||
'n8n-service-api': ('N8nAgent', '0.1.7'),
|
||||
'langflow-api': ('LangflowAgent', '0.1.7'),
|
||||
'deerflow-api': ('DeerFlowAgent', '0.1.7'),
|
||||
'tbox-app-api': ('TboxAgent', '0.1.5'),
|
||||
'weknora-api': ('WeKnoraAgent', '0.1.7'),
|
||||
}
|
||||
_DEERFLOW_FIELDS = {
|
||||
'api-base',
|
||||
'api-key',
|
||||
'auth-header',
|
||||
'assistant-id',
|
||||
'model-name',
|
||||
'thinking-enabled',
|
||||
'plan-mode',
|
||||
'subagent-enabled',
|
||||
'max-concurrent-subagents',
|
||||
'timeout',
|
||||
'recursion-limit',
|
||||
}
|
||||
_FIELDS = {
|
||||
'local-agent': {
|
||||
'model',
|
||||
'max-round',
|
||||
'prompt',
|
||||
'box-session-id-template',
|
||||
'rerank-model',
|
||||
'rerank-top-k',
|
||||
'enable-all-tools',
|
||||
'tools',
|
||||
'knowledge-bases',
|
||||
'knowledge-base',
|
||||
'mcp-resources',
|
||||
'mcp-resource-agent-read-enabled',
|
||||
},
|
||||
'dify-service-api': {'base-url', 'api-key', 'app-type', 'base-prompt', 'timeout'},
|
||||
'coze-api': {'api-key', 'bot-id', 'api-base', 'timeout', 'auto-save-history', 'auto_save_history'},
|
||||
'dashscope-app-api': {'app-type', 'api-key', 'app-id', 'references_quote', 'references-quote'},
|
||||
'n8n-service-api': {
|
||||
'webhook-url',
|
||||
'auth-type',
|
||||
'basic-username',
|
||||
'basic-password',
|
||||
'jwt-secret',
|
||||
'jwt-algorithm',
|
||||
'header-name',
|
||||
'header-value',
|
||||
'timeout',
|
||||
'output-key',
|
||||
'response-handling',
|
||||
},
|
||||
'langflow-api': {
|
||||
'base-url',
|
||||
'api-key',
|
||||
'flow-id',
|
||||
'input-type',
|
||||
'output-type',
|
||||
'input_type',
|
||||
'output_type',
|
||||
'tweaks',
|
||||
},
|
||||
'deerflow-api': _DEERFLOW_FIELDS,
|
||||
'tbox-app-api': {'api-key', 'app-id'},
|
||||
'weknora-api': {
|
||||
'base-url',
|
||||
'api-key',
|
||||
'app-type',
|
||||
'agent-id',
|
||||
'knowledge-base-ids',
|
||||
'web-search-enabled',
|
||||
'timeout',
|
||||
'base-prompt',
|
||||
},
|
||||
}
|
||||
_REQUIRED = {
|
||||
'local-agent': ('model', 'prompt'),
|
||||
'dify-service-api': ('base-url', 'api-key', 'app-type', 'base-prompt'),
|
||||
'coze-api': ('api-key', 'bot-id', 'api-base', 'timeout'),
|
||||
'dashscope-app-api': ('app-type', 'api-key', 'app-id'),
|
||||
'n8n-service-api': ('webhook-url',),
|
||||
'langflow-api': ('base-url', 'api-key', 'flow-id'),
|
||||
'deerflow-api': ('api-base',),
|
||||
'tbox-app-api': ('api-key', 'app-id'),
|
||||
'weknora-api': ('base-url', 'api-key', 'app-type'),
|
||||
}
|
||||
_BOOLEANS = {
|
||||
'enable-all-tools',
|
||||
'mcp-resource-agent-read-enabled',
|
||||
'auto-save-history',
|
||||
'auto_save_history',
|
||||
'thinking-enabled',
|
||||
'plan-mode',
|
||||
'subagent-enabled',
|
||||
'web-search-enabled',
|
||||
}
|
||||
_INTEGERS = {'max-round', 'rerank-top-k', 'max-concurrent-subagents', 'recursion-limit'}
|
||||
_NAME_LISTS = {'tools', 'knowledge-bases', 'knowledge-base-ids'}
|
||||
_DEERFLOW_DEFAULTS = {
|
||||
'api-key': '',
|
||||
'auth-header': '',
|
||||
'assistant-id': 'lead_agent',
|
||||
'model-name': '',
|
||||
'thinking-enabled': False,
|
||||
'plan-mode': False,
|
||||
'subagent-enabled': False,
|
||||
'max-concurrent-subagents': 3,
|
||||
'timeout': 300,
|
||||
'recursion-limit': 1000,
|
||||
}
|
||||
# Reviewed local patch manifests and implementations explicitly preserve native
|
||||
# provider identity using trusted Host conversation fields; absent context fails
|
||||
# closed in the plugin. Caller must verify the installed artifact/Host contract.
|
||||
_IDENTITY_SOURCES = {
|
||||
'dify-service-api': 'legacy-session',
|
||||
'coze-api': 'legacy-session',
|
||||
'n8n-service-api': 'legacy-session',
|
||||
'tbox-app-api': 'legacy-bot',
|
||||
}
|
||||
_LOCAL_DEFAULTS = {
|
||||
'advanced-settings': False,
|
||||
'date-grounding': True,
|
||||
'timeout': 300,
|
||||
'retrieval-top-k': 5,
|
||||
'rerank-model': '',
|
||||
'rerank-top-k': 5,
|
||||
'max-tool-iterations': 100,
|
||||
'tool-execution-mode': 'serial',
|
||||
'max-tool-result-chars': 20000,
|
||||
'context-history-fetch-limit': 50,
|
||||
'context-window-tokens': 200000,
|
||||
'context-reserve-tokens': 16384,
|
||||
'context-keep-recent-tokens': 20000,
|
||||
'context-summary-tokens': 8000,
|
||||
'enable-all-tools': True,
|
||||
'tools': [],
|
||||
'knowledge-bases': [],
|
||||
}
|
||||
_DEFAULTS = {
|
||||
'local-agent': _LOCAL_DEFAULTS,
|
||||
'dify-service-api': {'timeout': 30, 'advanced-settings': False},
|
||||
'coze-api': {'auto-save-history': True, 'advanced-settings': False},
|
||||
'dashscope-app-api': {'references_quote': '参考资料来自:', 'timeout': 120, 'advanced-settings': False},
|
||||
'n8n-service-api': {
|
||||
'auth-type': 'none',
|
||||
'basic-username': '',
|
||||
'basic-password': '',
|
||||
'jwt-secret': '',
|
||||
'jwt-algorithm': 'HS256',
|
||||
'header-name': '',
|
||||
'header-value': '',
|
||||
'timeout': 120,
|
||||
'output-key': 'response',
|
||||
'response-handling': 'reply',
|
||||
'basic-encoding': 'latin1',
|
||||
'advanced-settings': False,
|
||||
},
|
||||
'langflow-api': {'input-type': 'chat', 'output-type': 'chat', 'tweaks': {}, 'advanced-settings': False},
|
||||
'deerflow-api': _DEERFLOW_DEFAULTS,
|
||||
'tbox-app-api': {'timeout': 120},
|
||||
'weknora-api': {
|
||||
'knowledge-base-ids': [],
|
||||
'web-search-enabled': False,
|
||||
'timeout': 120,
|
||||
'base-prompt': '请回答用户的问题。',
|
||||
'advanced-settings': False,
|
||||
},
|
||||
}
|
||||
_ASSET_RUNNERS = {'dify-service-api', 'coze-api', 'dashscope-app-api', 'n8n-service-api', 'langflow-api'}
|
||||
_REMOVE_THINK_RUNNERS = {'local-agent', 'dify-service-api', 'coze-api', 'dashscope-app-api', 'tbox-app-api'}
|
||||
_REASONING_LEVELS = {'provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'}
|
||||
|
||||
|
||||
def _strict_json(value, ancestors=None, depth=0):
|
||||
"""Reject cycles, custom objects, nonfinite numbers and non-string keys."""
|
||||
if depth > 100:
|
||||
return False
|
||||
kind = type(value)
|
||||
if value is None or kind in (str, bool, int):
|
||||
return True
|
||||
if kind is float:
|
||||
return math.isfinite(value)
|
||||
if kind not in (dict, list):
|
||||
return False
|
||||
ancestors = set() if ancestors is None else ancestors
|
||||
identity = id(value)
|
||||
if identity in ancestors:
|
||||
return False
|
||||
ancestors.add(identity)
|
||||
try:
|
||||
if kind is dict:
|
||||
return all(type(k) is str and _strict_json(v, ancestors, depth + 1) for k, v in value.items())
|
||||
return all(_strict_json(v, ancestors, depth + 1) for v in value)
|
||||
finally:
|
||||
ancestors.remove(identity)
|
||||
|
||||
|
||||
def _block(result, code, field):
|
||||
"""Only developer-owned codes and paths may reach the public projection."""
|
||||
diagnostic = {'code': code, 'field': field}
|
||||
if diagnostic not in result['blockers']:
|
||||
result['blockers'].append(diagnostic)
|
||||
result['state'] = 'blocked'
|
||||
return result
|
||||
|
||||
|
||||
def _names(value):
|
||||
return type(value) is list and all(type(item) is str and bool(item) for item in value)
|
||||
|
||||
|
||||
def _attachments(value):
|
||||
return type(value) is list and all(
|
||||
type(item) is dict and ('enabled' not in item or type(item['enabled']) is bool) for item in value
|
||||
)
|
||||
|
||||
|
||||
def _validate_preferences(result, preferences, name):
|
||||
if preferences is None:
|
||||
return
|
||||
if type(preferences) is not dict or not _strict_json(preferences):
|
||||
_block(result, 'invalid_extension_preferences', 'extensions_preferences')
|
||||
return
|
||||
for field in (
|
||||
'enable_all_plugins',
|
||||
'enable_all_mcp_servers',
|
||||
'enable_all_skills',
|
||||
'mcp_resource_agent_read_enabled',
|
||||
):
|
||||
if field in preferences and type(preferences[field]) is not bool:
|
||||
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
|
||||
for field in ('mcp_servers', 'skills'):
|
||||
if field in preferences and not _names(preferences[field]):
|
||||
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
|
||||
if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']):
|
||||
_block(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources')
|
||||
plugins = preferences.get('plugins', [])
|
||||
if type(plugins) is not list or not all(
|
||||
type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) for item in plugins
|
||||
):
|
||||
_block(result, 'invalid_extension_preferences', 'extensions_preferences.plugins')
|
||||
elif preferences.get('enable_all_plugins', True) is False and not any(
|
||||
item['author'] == 'langbot-team' and item['name'] == name for item in plugins
|
||||
):
|
||||
# The immutable result contract contains no extension-preference patch.
|
||||
# Require explicit binding instead of silently flipping allow-all.
|
||||
_block(result, 'extensions.runner_excluded', 'extensions_preferences.plugins')
|
||||
|
||||
|
||||
def _warn(result, code, field):
|
||||
diagnostic = {'code': code, 'field': field}
|
||||
if diagnostic not in result['warnings']:
|
||||
result['warnings'].append(diagnostic)
|
||||
|
||||
|
||||
def _message_shape(value, shape, required=()):
|
||||
# SDK Message schemas, without importing runtime services or coercing values.
|
||||
return (
|
||||
type(value) is dict
|
||||
and not (set(value) - set(shape))
|
||||
and all(key in value for key in required)
|
||||
and all(check(value[key]) for key, check in shape.items() if key in value)
|
||||
)
|
||||
|
||||
|
||||
def _valid_prompt(prompt):
|
||||
def string(value):
|
||||
return type(value) is str
|
||||
|
||||
def optional_string(value):
|
||||
return value is None or string(value)
|
||||
|
||||
def metadata(value):
|
||||
return value is None or type(value) is dict
|
||||
|
||||
def content(value):
|
||||
if value is None or string(value):
|
||||
return True
|
||||
fields = {key: optional_string for key in ('text', 'image_base64', 'file_url', 'file_base64', 'file_name')}
|
||||
fields.update(type=string, image_url=lambda v: v is None or _message_shape(v, {'url': string}, ('url',)))
|
||||
return type(value) is list and all(_message_shape(v, fields, ('type',)) for v in value)
|
||||
|
||||
def tools(value):
|
||||
fields = {
|
||||
'id': string,
|
||||
'type': string,
|
||||
'function': lambda v: _message_shape(v, {'name': string, 'arguments': string}, ('name', 'arguments')),
|
||||
'provider_specific_fields': metadata,
|
||||
}
|
||||
return value is None or (
|
||||
type(value) is list and all(_message_shape(v, fields, ('id', 'type', 'function')) for v in value)
|
||||
)
|
||||
|
||||
fields = {key: optional_string for key in ('name', 'tool_call_id', 'resp_message_id')}
|
||||
fields.update(role=string, content=content, tool_calls=tools, provider_specific_fields=metadata)
|
||||
return type(prompt) is list and all(_message_shape(item, fields, ('role',)) for item in prompt)
|
||||
|
||||
|
||||
def _validate_local(result, section):
|
||||
prefix = 'ai.local-agent'
|
||||
model = section.get('model')
|
||||
if type(model) is dict:
|
||||
if set(model) - {'primary', 'fallbacks', 'reasoning'}:
|
||||
_block(result, 'unknown_field', f'{prefix}.model')
|
||||
if type(model.get('primary')) is not str or not model['primary'] or not _names(model.get('fallbacks', [])):
|
||||
_block(result, 'invalid_type', f'{prefix}.model')
|
||||
reasoning = model.get('reasoning', {})
|
||||
if type(reasoning) is not dict or not all(type(v) is str for v in reasoning.values()):
|
||||
_block(result, 'invalid_type', f'{prefix}.model.reasoning')
|
||||
elif any(value not in _REASONING_LEVELS for value in reasoning.values()):
|
||||
_block(result, 'local.reasoning_value', f'{prefix}.model')
|
||||
elif reasoning:
|
||||
_warn(result, 'local.model_reasoning', f'{prefix}.model.reasoning')
|
||||
elif type(model) is not str or not model:
|
||||
_block(result, 'invalid_type', f'{prefix}.model')
|
||||
if not _valid_prompt(section.get('prompt')):
|
||||
_block(result, 'local.prompt_shape', f'{prefix}.prompt')
|
||||
_warn(result, 'local.context_defaults', f'{prefix}.max-round')
|
||||
_warn(result, 'local.serial_tools_preserved', f'{prefix}.tools')
|
||||
_warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases')
|
||||
template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
if type(template) is not str or not template.strip():
|
||||
_block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
|
||||
else:
|
||||
try:
|
||||
fields = list(Formatter().parse(template))
|
||||
if any(
|
||||
name is not None
|
||||
and (not name or name.isdecimal() or re.search(r'[.\[\]{}]', name) or spec or conversion)
|
||||
for _, name, spec, conversion in fields
|
||||
):
|
||||
raise ValueError('Only named interpolation variables are supported')
|
||||
except ValueError:
|
||||
_block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
|
||||
_warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template')
|
||||
|
||||
|
||||
def _unique_json_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError('duplicate_key')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _parse_tweaks(raw):
|
||||
if raw is None or (type(raw) is str and not raw.strip()):
|
||||
return {}
|
||||
decoded = json.loads(raw, object_pairs_hook=_unique_json_object) if type(raw) is str else raw
|
||||
if decoded is None:
|
||||
return {}
|
||||
if type(decoded) is not dict or not _strict_json(decoded):
|
||||
raise ValueError('invalid_tweaks')
|
||||
return decoded
|
||||
|
||||
|
||||
def _validate_external(result, legacy, section):
|
||||
prefix = f'ai.{legacy}'
|
||||
app_types = {
|
||||
'dify-service-api': ('chat', 'agent', 'workflow', 'chatflow'),
|
||||
'dashscope-app-api': ('agent', 'workflow'),
|
||||
'weknora-api': ('chat', 'agent'),
|
||||
}
|
||||
required_strings = {
|
||||
'dify-service-api': ('api-key', 'base-url'),
|
||||
'coze-api': ('api-key', 'bot-id'),
|
||||
'dashscope-app-api': ('api-key', 'app-id'),
|
||||
'n8n-service-api': ('webhook-url',),
|
||||
'langflow-api': ('base-url', 'api-key', 'flow-id'),
|
||||
'tbox-app-api': ('api-key', 'app-id'),
|
||||
'weknora-api': ('api-key', 'base-url'),
|
||||
}
|
||||
for field in required_strings.get(legacy, ()):
|
||||
value = section.get(field)
|
||||
if type(value) is str and (not value or (legacy == 'weknora-api' and not value.strip())):
|
||||
_block(result, 'invalid_value', f'{prefix}.{field}')
|
||||
if legacy in app_types and 'app-type' in section and section['app-type'] not in app_types[legacy]:
|
||||
_block(result, 'invalid_value', f'{prefix}.app-type')
|
||||
if legacy == 'coze-api':
|
||||
actual, alias = section.get('auto_save_history'), section.get('auto-save-history')
|
||||
if type(actual) is bool and type(alias) is bool and actual != alias:
|
||||
_block(result, 'coze.history_alias', f'{prefix}.auto_save_history')
|
||||
if 'auto_save_history' in section or 'auto-save-history' in section:
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.auto_save_history')
|
||||
if actual is None and alias is None:
|
||||
_warn(result, 'migration.null_default', f'{prefix}.auto_save_history')
|
||||
base = section.get('api-base')
|
||||
try:
|
||||
parsed = urlsplit(base) if type(base) is str else None
|
||||
if (
|
||||
parsed is None
|
||||
or parsed.scheme not in ('http', 'https')
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or any(c.isspace() or ord(c) < 32 for c in base)
|
||||
):
|
||||
raise ValueError
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
_block(result, 'coze.custom_endpoint', f'{prefix}.api-base')
|
||||
_warn(result, 'coze.persistent_history', prefix)
|
||||
elif legacy == 'dashscope-app-api':
|
||||
if 'references-quote' in section:
|
||||
if 'references_quote' in section and section['references_quote'] != section['references-quote']:
|
||||
_block(result, 'dashscope.references_alias', f'{prefix}.references_quote')
|
||||
else:
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.references-quote')
|
||||
elif legacy == 'langflow-api':
|
||||
for axis in ('input', 'output'):
|
||||
actual, ui = f'{axis}_type', f'{axis}-type'
|
||||
if ui in section and section[ui] != section.get(actual, 'chat'):
|
||||
_block(result, 'langflow.io_alias', f'{prefix}.{actual}')
|
||||
if actual in section:
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.{actual}')
|
||||
try:
|
||||
_parse_tweaks(section.get('tweaks'))
|
||||
except (ValueError, RecursionError):
|
||||
_block(result, 'langflow.invalid_tweaks', f'{prefix}.tweaks')
|
||||
if section.get('tweaks') is None or (
|
||||
type(section.get('tweaks')) is str
|
||||
and (not section['tweaks'].strip() or section['tweaks'].strip() == 'null')
|
||||
):
|
||||
_warn(result, 'langflow.tweaks_default', f'{prefix}.tweaks')
|
||||
_warn(result, 'langflow.persistent_history', prefix)
|
||||
elif legacy == 'dify-service-api':
|
||||
_warn(result, 'dify.timeout_default', f'{prefix}.timeout')
|
||||
elif legacy == 'n8n-service-api':
|
||||
mode = section.get('auth-type', 'none')
|
||||
auth_fields = {
|
||||
'none': (),
|
||||
'basic': ('basic-username', 'basic-password'),
|
||||
'jwt': ('jwt-secret',),
|
||||
'header': ('header-name', 'header-value'),
|
||||
}
|
||||
if type(mode) is not str or mode not in auth_fields:
|
||||
_block(result, 'invalid_value', f'{prefix}.auth-type')
|
||||
else:
|
||||
for field in auth_fields[mode]:
|
||||
if field not in section:
|
||||
_block(result, 'missing_field', f'{prefix}.{field}')
|
||||
response = section.get('response-handling', 'reply')
|
||||
if response not in ('reply', 'ignore'):
|
||||
_block(result, 'invalid_value', f'{prefix}.response-handling')
|
||||
if mode == 'basic':
|
||||
for field in ('basic-username', 'basic-password'):
|
||||
value = section.get(field)
|
||||
if type(value) is str:
|
||||
try:
|
||||
value.encode('latin1')
|
||||
if field == 'basic-username' and ':' in value:
|
||||
raise ValueError
|
||||
except (UnicodeEncodeError, ValueError):
|
||||
_block(result, 'n8n.basic_encoding', f'{prefix}.{field}')
|
||||
if mode == 'header' and type(section.get('header-name')) is str:
|
||||
if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", section['header-name']):
|
||||
_block(result, 'n8n.header_name', f'{prefix}.header-name')
|
||||
_warn(result, 'n8n.session_ids_reset', prefix)
|
||||
elif legacy == 'weknora-api':
|
||||
knowledge = section.get('knowledge-base-ids')
|
||||
if type(knowledge) is list and any(type(v) is str and not v.strip() for v in knowledge):
|
||||
_block(result, 'invalid_value', f'{prefix}.knowledge-base-ids')
|
||||
if legacy in ('coze-api', 'n8n-service-api', 'weknora-api'):
|
||||
timeout = section.get('timeout', 120)
|
||||
if type(timeout) in (int, float) and timeout <= 0:
|
||||
_block(result, 'invalid_value', f'{prefix}.timeout')
|
||||
|
||||
|
||||
def _validate_section(result, legacy, section):
|
||||
prefix = f'ai.{legacy}'
|
||||
if set(section) - _FIELDS[legacy]:
|
||||
_block(result, 'unknown_field', prefix)
|
||||
for field in _REQUIRED[legacy]:
|
||||
if field not in section:
|
||||
_block(result, 'missing_field', f'{prefix}.{field}')
|
||||
# Iterate only known keys, never put an arbitrary saved key in diagnostics.
|
||||
for field in sorted(_FIELDS[legacy] & section.keys()):
|
||||
value = section[field]
|
||||
if field in ('model', 'prompt', 'tweaks') or (legacy == 'dify-service-api' and field == 'timeout'):
|
||||
continue
|
||||
if value is None and (
|
||||
(legacy == 'coze-api' and field in ('auto_save_history', 'auto-save-history'))
|
||||
or (legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids'))
|
||||
):
|
||||
continue
|
||||
if field in _BOOLEANS:
|
||||
valid = type(value) is bool
|
||||
elif field in _INTEGERS or (field == 'timeout' and legacy == 'deerflow-api'):
|
||||
valid = type(value) is int
|
||||
elif field == 'timeout':
|
||||
valid = type(value) in (int, float)
|
||||
elif field in _NAME_LISTS:
|
||||
valid = _names(value)
|
||||
elif field == 'mcp-resources':
|
||||
valid = _attachments(value)
|
||||
else:
|
||||
valid = type(value) is str
|
||||
if not valid:
|
||||
_block(result, 'invalid_type', f'{prefix}.{field}')
|
||||
if legacy == 'local-agent':
|
||||
_validate_local(result, section)
|
||||
else:
|
||||
_validate_external(result, legacy, section)
|
||||
if legacy == 'deerflow-api':
|
||||
base = section.get('api-base')
|
||||
if type(base) is str and not base.strip().startswith(('http://', 'https://')):
|
||||
_block(result, 'invalid_value', f'{prefix}.api-base')
|
||||
|
||||
|
||||
def _validate_output(result, config, legacy):
|
||||
output = config.get('output', {})
|
||||
if type(output) is not dict:
|
||||
_block(result, 'invalid_type', 'output')
|
||||
return
|
||||
misc = output.get('misc', {})
|
||||
if type(misc) is not dict:
|
||||
_block(result, 'invalid_type', 'output.misc')
|
||||
return
|
||||
value = misc.get('remove-think', False)
|
||||
if type(value) is not bool:
|
||||
_block(result, 'invalid_type', 'output.misc.remove-think')
|
||||
|
||||
|
||||
def _assemble(result, legacy, section, config, preferences):
|
||||
selected = {**copy.deepcopy(_DEFAULTS[legacy]), **copy.deepcopy(section)}
|
||||
prefix = f'ai.{legacy}'
|
||||
if legacy in _IDENTITY_SOURCES:
|
||||
selected['user-id-source'] = _IDENTITY_SOURCES[legacy]
|
||||
if legacy == 'local-agent':
|
||||
selected.pop('max-round', None)
|
||||
singular = selected.pop('knowledge-base', '')
|
||||
if not selected['knowledge-bases'] and singular and singular != '__none__':
|
||||
selected['knowledge-bases'] = [singular]
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.knowledge-base')
|
||||
model = selected['model']
|
||||
selected['model'] = (
|
||||
{'primary': model, 'fallbacks': [], 'reasoning': {}}
|
||||
if type(model) is str
|
||||
else {'fallbacks': [], 'reasoning': {}, **model}
|
||||
)
|
||||
preferences = preferences or {}
|
||||
for field, default in (('mcp-resources', []), ('mcp-resource-agent-read-enabled', True)):
|
||||
selected[field] = copy.deepcopy(section.get(field, preferences.get(field.replace('-', '_'), default)))
|
||||
else:
|
||||
selected.update(
|
||||
{
|
||||
'enable-all-tools': False,
|
||||
'tools': [],
|
||||
'knowledge-bases': [],
|
||||
'mcp-resources': [],
|
||||
'mcp-resource-agent-read-enabled': False,
|
||||
}
|
||||
)
|
||||
if legacy in _ASSET_RUNNERS:
|
||||
selected['langbot-assets-enabled'] = False
|
||||
if legacy in _REMOVE_THINK_RUNNERS:
|
||||
selected['remove-think'] = config.get('output', {}).get('misc', {}).get('remove-think', False)
|
||||
_warn(result, 'migration.output_policy_copied', 'output.misc.remove-think')
|
||||
if legacy == 'dify-service-api':
|
||||
selected['timeout'] = 30
|
||||
elif legacy == 'coze-api':
|
||||
actual = selected.pop('auto_save_history', None)
|
||||
selected['auto-save-history'] = actual if type(actual) is bool else section.get('auto-save-history')
|
||||
if selected['auto-save-history'] is None:
|
||||
selected['auto-save-history'] = True
|
||||
elif legacy == 'dashscope-app-api':
|
||||
if 'references-quote' in selected:
|
||||
selected['references_quote'] = selected.pop('references-quote')
|
||||
elif legacy == 'langflow-api':
|
||||
for axis in ('input', 'output'):
|
||||
selected[f'{axis}-type'] = selected.pop(f'{axis}_type', selected[f'{axis}-type'])
|
||||
selected['tweaks'] = copy.deepcopy(_parse_tweaks(section.get('tweaks')))
|
||||
elif legacy == 'weknora-api':
|
||||
selected.setdefault(
|
||||
'agent-id', 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning'
|
||||
)
|
||||
if selected['knowledge-base-ids'] is None:
|
||||
selected['knowledge-base-ids'] = []
|
||||
_warn(result, 'weknora.session_title_changed', prefix)
|
||||
return selected
|
||||
|
||||
|
||||
def plan_legacy_pipeline(config, extensions_preferences=None) -> dict:
|
||||
"""Return a detached config candidate or safe, value-free diagnostics."""
|
||||
result = {
|
||||
'state': 'not_legacy',
|
||||
'legacy_runner': None,
|
||||
'target_runner_id': None,
|
||||
'target_plugin': None,
|
||||
'config': None,
|
||||
'changed_paths': [],
|
||||
'blockers': [],
|
||||
'warnings': [],
|
||||
}
|
||||
if type(config) is not dict:
|
||||
return _block(result, 'invalid_type', 'config')
|
||||
if not _strict_json(config):
|
||||
return _block(result, 'invalid_json_value', 'config')
|
||||
ai = config.get('ai', {})
|
||||
if type(ai) is not dict:
|
||||
return _block(result, 'invalid_type', 'ai')
|
||||
selection = ai.get('runner', {})
|
||||
if type(selection) is not dict:
|
||||
return _block(result, 'invalid_type', 'ai.runner')
|
||||
if 'id' in selection and 'runner' in selection:
|
||||
return _block(result, 'mixed_runner_selection', 'ai.runner')
|
||||
if 'id' in selection:
|
||||
current = selection['id']
|
||||
if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current):
|
||||
return _block(result, 'invalid_runner_id', 'ai.runner.id')
|
||||
result['state'] = 'already_current'
|
||||
return result
|
||||
if 'runner' not in selection:
|
||||
return result
|
||||
legacy = selection['runner']
|
||||
if type(legacy) is not str:
|
||||
return _block(result, 'invalid_type', 'ai.runner.runner')
|
||||
if legacy not in _TARGETS:
|
||||
return result
|
||||
name, version = _TARGETS[legacy]
|
||||
target = f'plugin:langbot-team/{name}/default'
|
||||
result.update(
|
||||
legacy_runner=legacy,
|
||||
target_runner_id=target,
|
||||
target_plugin={'author': 'langbot-team', 'name': name, 'version': version},
|
||||
)
|
||||
if 'runner_config' in ai:
|
||||
if type(ai['runner_config']) is not dict:
|
||||
return _block(result, 'invalid_type', 'ai.runner_config')
|
||||
if ai['runner_config']:
|
||||
return _block(result, 'mixed_runner_config', 'ai.runner_config')
|
||||
if 'expire-time' in selection:
|
||||
expiry = selection['expire-time']
|
||||
if type(expiry) is not int or expiry < 0:
|
||||
_block(result, 'invalid_expiry', 'ai.runner.expire-time')
|
||||
if set(ai) - set(_TARGETS) - {'runner', 'runner_config'}:
|
||||
_block(result, 'unknown_field', 'ai')
|
||||
if set(selection) - {'runner', 'expire-time'}:
|
||||
_block(result, 'unknown_field', 'ai.runner')
|
||||
if legacy not in ai:
|
||||
return _block(result, 'missing_field', f'ai.{legacy}')
|
||||
section = ai[legacy]
|
||||
if type(section) is not dict:
|
||||
return _block(result, 'invalid_type', f'ai.{legacy}')
|
||||
_validate_section(result, legacy, section)
|
||||
_validate_preferences(result, extensions_preferences, name)
|
||||
_validate_output(result, config, legacy)
|
||||
_warn(result, 'external.state_validation_required', 'ai.runner')
|
||||
_warn(result, 'migration.history_reset', 'ai.runner')
|
||||
_warn(result, 'migration.new_defaults', f'ai.{legacy}')
|
||||
_warn(result, 'migration.legacy_sections_archived', 'ai')
|
||||
if legacy in ('dify-service-api', 'dashscope-app-api', 'n8n-service-api'):
|
||||
_warn(result, 'migration.filtered_variables', f'ai.{legacy}')
|
||||
if legacy in _IDENTITY_SOURCES:
|
||||
_warn(result, 'migration.identity_preserved', f'ai.{legacy}')
|
||||
if result['blockers']:
|
||||
return result
|
||||
candidate = copy.deepcopy(config)
|
||||
candidate['ai']['runner'].pop('runner')
|
||||
candidate['ai']['runner']['id'] = target
|
||||
selected = _assemble(result, legacy, section, config, extensions_preferences)
|
||||
candidate['ai']['runner_config'] = {target: selected}
|
||||
# The durable transaction stores the full source, including inactive legacy
|
||||
# credentials. The active configuration must be canonical so the guarded
|
||||
# editor never needs to interpret mixed legacy/current containers.
|
||||
archived_paths = []
|
||||
for old_runner in _TARGETS:
|
||||
if old_runner in candidate['ai']:
|
||||
candidate['ai'].pop(old_runner)
|
||||
archived_paths.append(f'ai.{old_runner}')
|
||||
result.update(
|
||||
state='ready',
|
||||
config=candidate,
|
||||
changed_paths=['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths],
|
||||
)
|
||||
return result
|
||||
@@ -48,7 +48,10 @@ class MonitoringHelper:
|
||||
# Try to record message
|
||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||
message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False)
|
||||
chain_dump = query.message_chain.model_dump()
|
||||
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
|
||||
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
|
||||
message_content = json.dumps(chain_dump, ensure_ascii=False)
|
||||
else:
|
||||
message_content = str(query)
|
||||
|
||||
@@ -79,6 +82,7 @@ class MonitoringHelper:
|
||||
session_updated = await ap.monitoring_service.update_session_activity(
|
||||
get_query_execution_context(query),
|
||||
session_id,
|
||||
bot_id=bot_id,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
)
|
||||
@@ -167,7 +171,10 @@ class MonitoringHelper:
|
||||
if hasattr(last_resp, 'get_content_platform_message_chain'):
|
||||
chain = last_resp.get_content_platform_message_chain()
|
||||
if hasattr(chain, 'model_dump'):
|
||||
message_content = json.dumps(chain.model_dump(), ensure_ascii=False)
|
||||
chain_dump = chain.model_dump()
|
||||
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
|
||||
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
|
||||
message_content = json.dumps(chain_dump, ensure_ascii=False)
|
||||
else:
|
||||
message_content = str(chain)
|
||||
else:
|
||||
|
||||
@@ -636,6 +636,16 @@ class PipelineManager:
|
||||
return dataclasses.replace(context, pipeline_uuid=pipeline_uuid)
|
||||
|
||||
async def load_pipeline(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
pipeline_entity,
|
||||
*,
|
||||
_binding_validated: bool = False,
|
||||
):
|
||||
candidate = await self.prepare_pipeline(context, pipeline_entity, _binding_validated=_binding_validated)
|
||||
self.publish_pipeline(candidate)
|
||||
|
||||
async def prepare_pipeline(
|
||||
self,
|
||||
context: ExecutionContext | RequestContext,
|
||||
pipeline_entity: persistence_pipeline.LegacyPipeline
|
||||
@@ -657,8 +667,6 @@ class PipelineManager:
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
self._observe_execution_context(execution_context)
|
||||
|
||||
coerce_pipeline_config(
|
||||
pipeline_entity.config,
|
||||
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
|
||||
@@ -685,17 +693,25 @@ class PipelineManager:
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
self._observe_execution_context(execution_context)
|
||||
runtime_pipeline = RuntimePipeline(
|
||||
return RuntimePipeline(
|
||||
self.ap,
|
||||
pipeline_entity,
|
||||
stage_containers,
|
||||
execution_context,
|
||||
)
|
||||
|
||||
def publish_pipeline(self, runtime_pipeline: RuntimePipeline) -> None:
|
||||
"""Publish a prepared candidate without yielding.
|
||||
|
||||
Callers preparing before a database commit must recheck the committed
|
||||
source and execution binding before invoking this synchronous seam.
|
||||
"""
|
||||
execution_context = runtime_pipeline.execution_context
|
||||
self._observe_execution_context(execution_context)
|
||||
key = (
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
pipeline_entity.uuid,
|
||||
runtime_pipeline.pipeline_entity.uuid,
|
||||
)
|
||||
self._pipelines_by_key[key] = runtime_pipeline
|
||||
self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key)
|
||||
|
||||
@@ -147,12 +147,14 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
if result.all_content is not None:
|
||||
result = result.model_copy(update={'content': result.all_content})
|
||||
elif is_stream and isinstance(result, provider_message.Message):
|
||||
attachments = result.attachments
|
||||
result = provider_message.MessageChunk.model_validate(
|
||||
{
|
||||
**result.model_dump(),
|
||||
'is_final': True,
|
||||
}
|
||||
)
|
||||
result.attachments = attachments
|
||||
|
||||
result.resp_message_id = str(resp_message_id)
|
||||
|
||||
|
||||
@@ -25,50 +25,6 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
async def initialize(self, pipeline_config: dict):
|
||||
pass
|
||||
|
||||
def _is_final_assistant_message(self, result) -> bool:
|
||||
"""Whether *result* is the agent's final, tool-call-free answer.
|
||||
|
||||
Intermediate streaming chunks and tool-call rounds must NOT trigger
|
||||
outbound attachment collection — only the terminal assistant message.
|
||||
"""
|
||||
if getattr(result, 'role', None) != 'assistant':
|
||||
return False
|
||||
if result.tool_calls:
|
||||
return False
|
||||
if isinstance(result, provider_message.MessageChunk):
|
||||
return bool(result.is_final)
|
||||
return True
|
||||
|
||||
async def _append_outbound_attachments(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> None:
|
||||
"""Collect sandbox outbox files and append them to *message_chain*.
|
||||
|
||||
Runs at most once per query (guarded by a query variable) and never
|
||||
raises into the pipeline — attachment delivery is best-effort.
|
||||
"""
|
||||
if query.variables.get('_sandbox_outbound_collected'):
|
||||
return
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
return
|
||||
query.variables['_sandbox_outbound_collected'] = True
|
||||
try:
|
||||
attachments = await box_service.collect_outbound_attachments(query)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Outbound attachment collection failed: {e}')
|
||||
return
|
||||
for att in attachments:
|
||||
att_type = att.get('type')
|
||||
if att_type == 'Image':
|
||||
message_chain.append(platform_message.Image(base64=att['base64']))
|
||||
elif att_type == 'Voice':
|
||||
message_chain.append(platform_message.Voice(base64=att['base64']))
|
||||
else:
|
||||
message_chain.append(platform_message.File(name=att.get('name', 'file'), base64=att['base64']))
|
||||
|
||||
async def process(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -105,7 +61,7 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
|
||||
reply_text = ''
|
||||
|
||||
if result.content: # 有内容
|
||||
if result.content or result.attachments: # 有内容
|
||||
reply_text = str(result.get_content_platform_message_chain())
|
||||
|
||||
# ============= 触发插件事件 ===============
|
||||
@@ -140,11 +96,6 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
reply_chain = result.get_content_platform_message_chain()
|
||||
is_plugin_reply = False
|
||||
|
||||
# Attach files the agent produced in the sandbox
|
||||
# outbox, but only on the terminal assistant message.
|
||||
if self._is_final_assistant_message(result):
|
||||
await self._append_outbound_attachments(query, reply_chain)
|
||||
|
||||
query.resp_message_chain.append(reply_chain)
|
||||
if is_plugin_reply:
|
||||
plugin_diagnostics.record_last_plugin_response_source(
|
||||
@@ -162,9 +113,8 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
||||
):
|
||||
# Final streaming chunk with no text content but
|
||||
# possibly carrying sandbox outbox attachments.
|
||||
# no implicit file collection.
|
||||
reply_chain = platform_message.MessageChain([])
|
||||
await self._append_outbound_attachments(query, reply_chain)
|
||||
query.resp_message_chain.append(reply_chain)
|
||||
yield entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
|
||||
@@ -1144,7 +1144,10 @@ class RuntimeBot:
|
||||
"""Record a discarded message in the monitoring system."""
|
||||
try:
|
||||
if hasattr(message_chain, 'model_dump'):
|
||||
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||
chain_dump = message_chain.model_dump()
|
||||
if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'):
|
||||
chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
|
||||
message_content = json.dumps(chain_dump, ensure_ascii=False)
|
||||
else:
|
||||
message_content = str(message_chain)
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Authenticated plugin Box resources and invocation file operations."""
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction as Action
|
||||
from langbot_plugin.runtime.io.handler import ActionResponse
|
||||
from ..box.runner import RunnerBoxService
|
||||
from .agent_run_support import _validate_agent_run_session
|
||||
|
||||
|
||||
def register(h):
|
||||
async def dispatch(action, data):
|
||||
from .handler import _resolve_action_query
|
||||
|
||||
action_context, _ = await h._require_plugin_action_context()
|
||||
context = h._execution_context(action_context)
|
||||
query = None
|
||||
run_id = data.get('run_id')
|
||||
context_actions = {Action.BIND_BOX, Action.IMPORT_BOX_ATTACHMENTS, Action.EXPORT_BOX_FILES}
|
||||
if action in context_actions and not run_id:
|
||||
return ActionResponse.error('run_id is required for Box context operations')
|
||||
if run_id:
|
||||
session, error = await _validate_agent_run_session(
|
||||
run_id,
|
||||
data.get('caller_plugin_identity'),
|
||||
h.ap,
|
||||
'Box API',
|
||||
api_capability='box',
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
query = _resolve_action_query(data, session, h.ap, action_context)
|
||||
if query is None:
|
||||
return ActionResponse.error('The current execution context has expired')
|
||||
service = RunnerBoxService(h.ap.box_service)
|
||||
if action == Action.GET_BOX_STATUS:
|
||||
return ActionResponse.success(await service.status(context))
|
||||
if action == Action.LIST_BOXES:
|
||||
return ActionResponse.success(
|
||||
{'items': [service.public_session(s) for s in await service.sessions(context)]}
|
||||
)
|
||||
if action == Action.ACQUIRE_BOX:
|
||||
request = {k: v for k, v in data.items() if k not in {'run_id', 'caller_plugin_identity'}}
|
||||
return ActionResponse.success(await service.acquire(context, request, query))
|
||||
if action == Action.BIND_BOX:
|
||||
return ActionResponse.success(await service.bind(context, query, run_id, data['box_id']))
|
||||
if action == Action.IMPORT_BOX_ATTACHMENTS:
|
||||
return ActionResponse.success(await service.import_attachments(query, data.get('attachment_ids')))
|
||||
if action == Action.EXPORT_BOX_FILES:
|
||||
return ActionResponse.success(await service.export_files(query))
|
||||
raise ValueError('Unsupported Box action')
|
||||
|
||||
def register_action(action):
|
||||
@h.action(action)
|
||||
async def invoke(data):
|
||||
try:
|
||||
return await dispatch(action, data)
|
||||
except Exception as exc:
|
||||
return ActionResponse.error(f'{type(exc).__name__}: {exc}')
|
||||
|
||||
for action in (
|
||||
Action.GET_BOX_STATUS,
|
||||
Action.LIST_BOXES,
|
||||
Action.ACQUIRE_BOX,
|
||||
Action.BIND_BOX,
|
||||
Action.IMPORT_BOX_ATTACHMENTS,
|
||||
Action.EXPORT_BOX_FILES,
|
||||
):
|
||||
register_action(action)
|
||||
@@ -23,6 +23,11 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||
|
||||
from ..core import app
|
||||
from . import handler
|
||||
from .errors import (
|
||||
PluginRuntimeNotConnectedError,
|
||||
PluginInstallationFailedError,
|
||||
MarketplacePluginVersionNotFoundError,
|
||||
)
|
||||
from .archive import inspect_plugin_archive_metadata
|
||||
from .github import (
|
||||
validate_github_plugin_install_info,
|
||||
@@ -89,8 +94,10 @@ async def _read_httpx_response_limited(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_bytes: int,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> bytes:
|
||||
content_length = response.headers.get('content-length')
|
||||
declared_size = None
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
@@ -99,9 +106,16 @@ async def _read_httpx_response_limited(
|
||||
if declared_size is not None and declared_size > max_bytes:
|
||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||
|
||||
started = time.monotonic()
|
||||
if task_context is not None:
|
||||
task_context.metadata.update(download_current=0, download_total=max(declared_size or 0, 0))
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
||||
body.extend(chunk)
|
||||
if task_context is not None:
|
||||
task_context.metadata.update(
|
||||
download_current=len(body), download_speed=len(body) / max(time.monotonic() - started, 0.001)
|
||||
)
|
||||
if len(body) > max_bytes:
|
||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||
return bytes(body)
|
||||
@@ -113,14 +127,25 @@ async def _marketplace_get(
|
||||
*,
|
||||
max_bytes: int,
|
||||
allow_not_found: bool = False,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> tuple[int, bytes]:
|
||||
async with client.stream('GET', url) as response:
|
||||
if allow_not_found and response.status_code == 404:
|
||||
return response.status_code, b''
|
||||
if response.is_error:
|
||||
body = await _read_httpx_response_limited(response, max_bytes=min(max_bytes, 64 * 1024))
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
payload = {}
|
||||
# Space currently returns HTTP 500 for a missing plugin release.
|
||||
if isinstance(payload, dict) and str(payload.get('msg', '')).startswith('plugin version not found:'):
|
||||
raise MarketplacePluginVersionNotFoundError('The requested plugin version is not available')
|
||||
response.raise_for_status()
|
||||
return response.status_code, await _read_httpx_response_limited(
|
||||
response,
|
||||
max_bytes=max_bytes,
|
||||
task_context=task_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -134,25 +159,6 @@ def _decode_json_object(body: bytes, *, subject: str) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
class PluginRuntimeNotConnectedError(RuntimeError):
|
||||
"""Raised when plugin runtime operations are requested before connection."""
|
||||
|
||||
|
||||
class PluginInstallationFailedError(RuntimeError):
|
||||
"""Stable Runtime desired-state failure for one plugin installation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
installation_uuid: str,
|
||||
error_code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
self.installation_uuid = installation_uuid
|
||||
self.error_code = error_code
|
||||
self.runtime_message = message
|
||||
super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
|
||||
|
||||
|
||||
class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
"""Plugin runtime connector"""
|
||||
|
||||
@@ -1631,6 +1637,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
task_context: taskmgr.TaskContext | None,
|
||||
version: str | None = None,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Return a plugin package, or install an MCP/skill and return none."""
|
||||
|
||||
@@ -1640,6 +1647,23 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
timeout=15,
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES),
|
||||
) as client:
|
||||
if version is not None:
|
||||
if (
|
||||
not isinstance(version, str)
|
||||
or not version
|
||||
or any(
|
||||
c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._+-' for c in version
|
||||
)
|
||||
):
|
||||
raise ValueError('Invalid plugin version')
|
||||
_status, package = await _marketplace_get(
|
||||
client,
|
||||
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}',
|
||||
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
||||
task_context=task_context,
|
||||
)
|
||||
return package, version
|
||||
|
||||
mcp_status, mcp_body = await _marketplace_get(
|
||||
client,
|
||||
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}',
|
||||
@@ -1726,11 +1750,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('downloading plugin package')
|
||||
task_context.metadata['progress_percent'] = 15
|
||||
version_options = {'version': install_info['plugin_version']} if install_info.get('plugin_version') else {}
|
||||
file_bytes, version = await self._download_marketplace_package(
|
||||
execution_context,
|
||||
plugin_author,
|
||||
plugin_name,
|
||||
task_context,
|
||||
**version_options,
|
||||
)
|
||||
if file_bytes is None:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared plugin operation errors without runtime or application imports."""
|
||||
|
||||
|
||||
class PluginRuntimeNotConnectedError(RuntimeError):
|
||||
"""Raised when plugin runtime operations are requested before connection."""
|
||||
|
||||
|
||||
class PluginInstallationFailedError(RuntimeError):
|
||||
"""Stable Runtime desired-state failure for one plugin installation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
installation_uuid: str,
|
||||
error_code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
self.installation_uuid = installation_uuid
|
||||
self.error_code = error_code
|
||||
self.runtime_message = message
|
||||
super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
|
||||
|
||||
|
||||
class MarketplacePluginVersionNotFoundError(ValueError):
|
||||
"""The requested release is not available from the marketplace."""
|
||||
@@ -52,6 +52,7 @@ from ..entity.persistence import model as persistence_model
|
||||
from ..core import app
|
||||
from ..utils import constants
|
||||
from ..agent.runner.session_registry import get_session_registry
|
||||
from ..provider.modelmgr.reasoning import model_with_reasoning_level
|
||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||
from ..agent.runner import config_schema
|
||||
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call
|
||||
@@ -204,6 +205,7 @@ async def _validate_run_authorization(
|
||||
ap: app.Application,
|
||||
caller_plugin_identity: str | None = None,
|
||||
operation: str | None = None,
|
||||
workspace_uuid: str | None = None,
|
||||
) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]:
|
||||
"""Validate run_id authorization for a resource access.
|
||||
|
||||
@@ -250,6 +252,9 @@ async def _validate_run_authorization(
|
||||
message=f'Plugin identity mismatch: caller {caller_plugin_identity} is not authorized for run_id {run_id}',
|
||||
)
|
||||
|
||||
if workspace_uuid is not None and session['authorization'].get('workspace_id') not in (None, workspace_uuid):
|
||||
return None, handler.ActionResponse.error(message='Run session belongs to another Workspace')
|
||||
|
||||
if not session_registry.is_resource_allowed(session, resource_type, resource_id, operation):
|
||||
ap.logger.warning(
|
||||
f'{resource_type.upper()}: {resource_id} operation {operation or "*"} not allowed for run_id {run_id}'
|
||||
@@ -1124,6 +1129,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return handler.ActionResponse.success(
|
||||
data={
|
||||
'version': constants.semantic_version,
|
||||
'api_features': ['llm.reasoning_level'],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1183,7 +1189,18 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
output_lock = None
|
||||
output_lock_acquired = False
|
||||
try:
|
||||
if data.get('file_ids') is not None:
|
||||
from ..box.runner import exported_message, binding_for
|
||||
|
||||
query = _resolve_action_query(data, session, self.ap, action_context)
|
||||
output_lock = binding_for(query).lock
|
||||
await output_lock.acquire()
|
||||
output_lock_acquired = True
|
||||
files = exported_message(query, data['file_ids'])
|
||||
data = {**data, 'params': {**(data.get('params') or {}), 'message': files.model_dump(mode='json')}}
|
||||
tool_name, parameters, message = resolve_platform_api_call(
|
||||
session, data.get('bot_uuid'), data['action'], data.get('params') or {}, data.get('context_tool')
|
||||
)
|
||||
@@ -1203,9 +1220,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
parameters,
|
||||
message_chain=message,
|
||||
)
|
||||
if data.get('file_ids') is not None:
|
||||
exported_message(query, data['file_ids'], consume=True)
|
||||
return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)})
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
return handler.ActionResponse.error(message=str(exc))
|
||||
finally:
|
||||
if output_lock_acquired:
|
||||
output_lock.release()
|
||||
|
||||
@self.action(PluginToRuntimeAction.SEND_MESSAGE)
|
||||
async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
@@ -1341,8 +1363,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
|
||||
if run_id:
|
||||
_session, error = await _validate_run_authorization(
|
||||
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='count_tokens'
|
||||
_, error = await _validate_run_authorization(
|
||||
run_id,
|
||||
'model',
|
||||
llm_model_uuid,
|
||||
self.ap,
|
||||
caller_plugin_identity,
|
||||
operation='count_tokens',
|
||||
workspace_uuid=action_context.workspace_uuid,
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
@@ -1366,6 +1394,9 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
|
||||
)
|
||||
|
||||
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
|
||||
return handler.ActionResponse.error(message='LLM model belongs to another Workspace')
|
||||
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
|
||||
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
||||
|
||||
async def _placeholder_func(**kwargs):
|
||||
@@ -1407,7 +1438,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
# Permission validation for Runner calls
|
||||
if run_id:
|
||||
session, error = await _validate_run_authorization(
|
||||
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='invoke'
|
||||
run_id,
|
||||
'model',
|
||||
llm_model_uuid,
|
||||
self.ap,
|
||||
caller_plugin_identity,
|
||||
operation='invoke',
|
||||
workspace_uuid=action_context.workspace_uuid,
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
@@ -1441,6 +1478,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
|
||||
)
|
||||
|
||||
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
|
||||
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
||||
|
||||
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
|
||||
@@ -1500,7 +1538,13 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
# Permission validation for Runner calls
|
||||
if run_id:
|
||||
session, error = await _validate_run_authorization(
|
||||
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='stream'
|
||||
run_id,
|
||||
'model',
|
||||
llm_model_uuid,
|
||||
self.ap,
|
||||
caller_plugin_identity,
|
||||
operation='stream',
|
||||
workspace_uuid=action_context.workspace_uuid,
|
||||
)
|
||||
if error:
|
||||
yield error
|
||||
@@ -1528,6 +1572,10 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
return
|
||||
|
||||
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
|
||||
yield handler.ActionResponse.error(message='LLM model belongs to another Workspace')
|
||||
return
|
||||
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
|
||||
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
||||
|
||||
# The func field is excluded during model_dump() in plugin side
|
||||
@@ -2601,6 +2649,9 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
agent_pull_actions.register(self)
|
||||
runner_actions.register(self)
|
||||
from . import box_actions
|
||||
|
||||
box_actions.register(self)
|
||||
agent_state_actions.register(self)
|
||||
|
||||
@self.action(CommonAction.PING)
|
||||
@@ -2675,7 +2726,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return None
|
||||
|
||||
def require_outbound_installation_context(self) -> InstallationBinding:
|
||||
binding = self._outbound_installation_context.get()
|
||||
binding = self._outbound_installation_context.get(None)
|
||||
if not isinstance(binding, InstallationBinding):
|
||||
raise ValueError('Host plugin action requires an InstallationBinding scope')
|
||||
return binding
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
"""ChatGPT device auth with server-only credentials and cross-process refresh leases.
|
||||
|
||||
Network I/O never holds a DB transaction. A persisted CAS lease serializes refresh
|
||||
and poll; cancel fences device exchanges but waits for existing-token refreshes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
|
||||
from ...entity.persistence.model import CodexCredential, ModelProvider
|
||||
from ...api.http.context import PrincipalType, RequestContext
|
||||
from ...api.http.authz import Permission, has_permission
|
||||
from ...api.http.service.tenant import require_workspace_uuid
|
||||
from ...workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
REQUESTER = 'openai-codex'
|
||||
BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
||||
ISSUER = 'https://auth.openai.com'
|
||||
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
LOGIN_REQUIRED = 'ChatGPT sign-in required. Open this provider and sign in again.'
|
||||
LEASE_SECONDS = 90
|
||||
|
||||
|
||||
def validate_config(data: dict) -> None:
|
||||
if data.get('requester') != REQUESTER:
|
||||
return
|
||||
if data.get('base_url') not in (None, '', BASE_URL):
|
||||
raise ValueError('Codex uses the fixed ChatGPT endpoint; custom base URLs are not supported')
|
||||
if data.get('api_keys') not in (None, [], ''):
|
||||
raise ValueError('Codex uses ChatGPT sign-in, not API keys')
|
||||
data['base_url'] = BASE_URL
|
||||
data['api_keys'] = []
|
||||
|
||||
|
||||
def _claims(token: str) -> dict:
|
||||
"""Read routing metadata, NOT trusted LangBot identity, from issuer tokens."""
|
||||
try:
|
||||
part = token.split('.')[1]
|
||||
value = json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (ValueError, IndexError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _tokens(data: dict, previous: dict | None = None) -> dict:
|
||||
previous = previous or {}
|
||||
access = data.get('access_token')
|
||||
refresh = data.get('refresh_token') or previous.get('refresh_token')
|
||||
account = None
|
||||
for token in (access, data.get('id_token')):
|
||||
namespace = _claims(token or '').get('https://api.openai.com/auth', {})
|
||||
if isinstance(namespace, dict) and isinstance(namespace.get('chatgpt_account_id'), str):
|
||||
account = namespace['chatgpt_account_id']
|
||||
break
|
||||
account = account or previous.get('account_id')
|
||||
try:
|
||||
expires_at = (
|
||||
time.time() + float(data['expires_in'])
|
||||
if data.get('expires_in') is not None
|
||||
else float(_claims(access or '').get('exp', 0))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
expires_at = 0
|
||||
if (
|
||||
not all(isinstance(v, str) and v for v in (access, refresh, account))
|
||||
or not math.isfinite(expires_at)
|
||||
or expires_at <= time.time()
|
||||
):
|
||||
raise ValueError('ChatGPT returned an incomplete authorization. Please sign in again.')
|
||||
return {
|
||||
'access_token': access,
|
||||
'refresh_token': refresh,
|
||||
'account_id': account,
|
||||
'expires_at': expires_at,
|
||||
'connection_id': previous.get('connection_id') or secrets.token_urlsafe(24),
|
||||
}
|
||||
|
||||
|
||||
class CodexAuth:
|
||||
def __init__(self, ap):
|
||||
self.ap = ap
|
||||
|
||||
def _where(self, workspace: str, provider: str):
|
||||
return (CodexCredential.workspace_uuid == workspace, CodexCredential.provider_uuid == provider)
|
||||
|
||||
async def _execute(self, statement):
|
||||
# SQLAlchemy/driver/serialization errors may embed the entire secret payload.
|
||||
try:
|
||||
return await self.ap.persistence_mgr.execute_async(statement)
|
||||
except Exception:
|
||||
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
|
||||
|
||||
async def _read(self, workspace: str, provider: str) -> dict | None:
|
||||
result = await self._execute(sa.select(CodexCredential).where(*self._where(workspace, provider)))
|
||||
try:
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row is not None else None
|
||||
except Exception:
|
||||
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
|
||||
|
||||
async def _provider(self, context, provider: str, *, user: bool = False) -> str:
|
||||
workspace = require_workspace_uuid(context)
|
||||
if user and (
|
||||
not isinstance(context, RequestContext)
|
||||
or context.principal.principal_type != PrincipalType.ACCOUNT
|
||||
or not context.account_uuid
|
||||
or not has_permission(context, Permission.PROVIDER_SECRET_MANAGE)
|
||||
):
|
||||
raise ValueError('ChatGPT authorization requires an authorized workspace user')
|
||||
result = await self._execute(
|
||||
sa.select(ModelProvider.requester).where(
|
||||
ModelProvider.workspace_uuid == workspace, ModelProvider.uuid == provider
|
||||
)
|
||||
)
|
||||
kind = result.scalar()
|
||||
if kind is None:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
if kind != REQUESTER:
|
||||
raise ValueError('This provider does not use ChatGPT sign-in')
|
||||
return workspace
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lease(self, workspace: str, provider: str, *, refresh: bool = False):
|
||||
owner = ('refresh:' if refresh else 'device:') + secrets.token_urlsafe(32)
|
||||
deadline = time.monotonic() + 65
|
||||
while True:
|
||||
now = time.time()
|
||||
result = await self._execute(
|
||||
sa.update(CodexCredential)
|
||||
.where(
|
||||
*self._where(workspace, provider),
|
||||
sa.or_(CodexCredential.lease_owner.is_(None), CodexCredential.lease_until < now),
|
||||
)
|
||||
.values(lease_owner=owner, lease_until=now + LEASE_SECONDS)
|
||||
)
|
||||
if result.rowcount == 1:
|
||||
break
|
||||
if await self._read(workspace, provider) is None:
|
||||
raise ValueError(LOGIN_REQUIRED)
|
||||
if time.monotonic() >= deadline:
|
||||
raise ValueError('ChatGPT authorization is busy. Please retry shortly.')
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
yield owner
|
||||
finally:
|
||||
await self._execute(
|
||||
sa.update(CodexCredential)
|
||||
.where(*self._where(workspace, provider), CodexCredential.lease_owner == owner)
|
||||
.values(lease_owner=None, lease_until=0)
|
||||
)
|
||||
|
||||
async def _save(self, workspace: str, provider: str, owner: str, payload: dict) -> None:
|
||||
result = await self._execute(
|
||||
sa.update(CodexCredential)
|
||||
.where(
|
||||
*self._where(workspace, provider),
|
||||
CodexCredential.lease_owner == owner,
|
||||
CodexCredential.lease_until > time.time(),
|
||||
)
|
||||
.values(payload=payload, version=CodexCredential.version + 1)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise ValueError('ChatGPT authorization was cancelled or replaced. Please retry.')
|
||||
|
||||
async def _post(self, path: str, *, data=None, json_body=None) -> httpx.Response:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
|
||||
return await asyncio.wait_for(
|
||||
client.post(
|
||||
ISSUER + path,
|
||||
data=data,
|
||||
json=json_body,
|
||||
headers={'Accept': 'application/json', 'User-Agent': 'LangBot'},
|
||||
),
|
||||
25,
|
||||
)
|
||||
except (httpx.HTTPError, TimeoutError):
|
||||
raise ValueError('ChatGPT authorization network error. Please retry.') from None
|
||||
|
||||
@staticmethod
|
||||
def _json(response: httpx.Response) -> dict:
|
||||
try:
|
||||
value = response.json()
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError
|
||||
return value
|
||||
except ValueError:
|
||||
raise ValueError('ChatGPT returned an invalid authorization response') from None
|
||||
|
||||
async def status(self, context, provider: str) -> dict:
|
||||
workspace = await self._provider(context, provider, user=True)
|
||||
row = await self._read(workspace, provider)
|
||||
payload = row['payload'] if row else {}
|
||||
tokens = payload.get('tokens')
|
||||
connected = bool(tokens and not payload.get('invalid'))
|
||||
return {
|
||||
'status': 'connected' if connected else 'expired' if payload.get('invalid') else 'disconnected',
|
||||
'connected': connected,
|
||||
'expires_at': tokens.get('expires_at') if tokens else None,
|
||||
}
|
||||
|
||||
async def start(self, context, provider: str) -> dict:
|
||||
workspace = await self._provider(context, provider, user=True)
|
||||
async with self._lease(workspace, provider) as owner:
|
||||
response = await self._post('/api/accounts/deviceauth/usercode', json_body={'client_id': CLIENT_ID})
|
||||
if response.status_code != 200:
|
||||
raise ValueError('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.')
|
||||
data = self._json(response)
|
||||
try:
|
||||
code = data.get('user_code') or data['usercode']
|
||||
device = data['device_auth_id']
|
||||
interval = max(5, min(60, int(data.get('interval') or 5)))
|
||||
if not isinstance(code, str) or not isinstance(device, str) or not code or not device:
|
||||
raise ValueError
|
||||
except (KeyError, ValueError, TypeError):
|
||||
raise ValueError('ChatGPT returned an invalid device code') from None
|
||||
now = time.time()
|
||||
try:
|
||||
expiry = data.get('expires_at')
|
||||
if expiry is None:
|
||||
expiry = now + float(data.get('expires_in', 900))
|
||||
try:
|
||||
expires_at = float(expiry)
|
||||
except ValueError:
|
||||
parsed = datetime.fromisoformat(expiry.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
expires_at = parsed.timestamp()
|
||||
if not math.isfinite(expires_at) or expires_at <= now:
|
||||
raise ValueError
|
||||
expires_at = min(now + 900, expires_at)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError('ChatGPT returned an invalid device code expiry') from None
|
||||
pending = {
|
||||
'authorization_id': secrets.token_urlsafe(32),
|
||||
'user_code': code,
|
||||
'device_auth_id': device,
|
||||
'account_uuid': context.account_uuid,
|
||||
'interval': interval,
|
||||
'expires_at': expires_at,
|
||||
'next_poll_at': now + interval,
|
||||
}
|
||||
row = await self._read(workspace, provider)
|
||||
payload = dict(row['payload'])
|
||||
payload['pending'] = pending
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
return {k: pending[k] for k in ('authorization_id', 'user_code', 'interval', 'expires_at')} | {
|
||||
'verification_uri': ISSUER + '/codex/device'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _attempt(payload: dict, context, authorization_id: str) -> dict | None:
|
||||
pending = payload.get('pending')
|
||||
if not pending or pending.get('authorization_id') != authorization_id:
|
||||
return None
|
||||
if pending.get('account_uuid') != context.account_uuid:
|
||||
raise WorkspaceNotFoundError('Authorization not found')
|
||||
return pending
|
||||
|
||||
async def poll(self, context, provider: str, authorization_id: str) -> dict:
|
||||
workspace = await self._provider(context, provider, user=True)
|
||||
if not isinstance(authorization_id, str) or not authorization_id:
|
||||
raise ValueError('authorization_id is required')
|
||||
async with self._lease(workspace, provider) as owner:
|
||||
row = await self._read(workspace, provider)
|
||||
payload = dict(row['payload'])
|
||||
pending = self._attempt(payload, context, authorization_id)
|
||||
if pending is None:
|
||||
completed = payload.get('completed', {})
|
||||
if (
|
||||
completed.get('authorization_id') == authorization_id
|
||||
and completed.get('account_uuid') == context.account_uuid
|
||||
):
|
||||
return {'status': 'connected'}
|
||||
return {'status': 'expired'}
|
||||
now = time.time()
|
||||
if pending['expires_at'] <= now or pending.get('consumed'):
|
||||
payload.pop('pending', None)
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
return {'status': 'expired'}
|
||||
if pending['next_poll_at'] > now:
|
||||
return {'status': 'pending', 'interval': pending['interval']}
|
||||
pending['next_poll_at'] = now + pending['interval']
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
response = await self._post(
|
||||
'/api/accounts/deviceauth/token',
|
||||
json_body={'device_auth_id': pending['device_auth_id'], 'user_code': pending['user_code']},
|
||||
)
|
||||
if response.status_code in (403, 404, 429):
|
||||
if response.status_code == 429:
|
||||
pending['interval'] = min(60, pending['interval'] + 5)
|
||||
pending['next_poll_at'] = time.time() + pending['interval']
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
return {'status': 'pending', 'interval': pending['interval']}
|
||||
if response.status_code != 200:
|
||||
payload.pop('pending', None)
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
raise ValueError('ChatGPT device authorization failed. Please start again.')
|
||||
data = self._json(response)
|
||||
if not data.get('authorization_code') or not data.get('code_verifier'):
|
||||
payload.pop('pending', None)
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
raise ValueError('ChatGPT returned an incomplete device authorization')
|
||||
# Keep an attempt tombstone so cancel can preempt exchange, but never replay a code.
|
||||
pending['consumed'] = True
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
response = await self._post(
|
||||
'/oauth/token',
|
||||
data={
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': CLIENT_ID,
|
||||
'code': data['authorization_code'],
|
||||
'code_verifier': data['code_verifier'],
|
||||
'redirect_uri': ISSUER + '/deviceauth/callback',
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise ValueError('ChatGPT token exchange failed. Please start sign-in again.')
|
||||
tokens = _tokens(self._json(response))
|
||||
await self._save(
|
||||
workspace,
|
||||
provider,
|
||||
owner,
|
||||
{
|
||||
'tokens': tokens,
|
||||
'completed': {'authorization_id': authorization_id, 'account_uuid': context.account_uuid},
|
||||
},
|
||||
)
|
||||
return {'status': 'connected'}
|
||||
|
||||
async def disconnect(self, context, provider: str) -> None:
|
||||
workspace = await self._provider(context, provider, user=True)
|
||||
await self._execute(
|
||||
sa.update(CodexCredential)
|
||||
.where(*self._where(workspace, provider))
|
||||
.values(payload={}, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
|
||||
)
|
||||
|
||||
async def cancel(self, context, provider: str, authorization_id: str) -> None:
|
||||
workspace = await self._provider(context, provider, user=True)
|
||||
deadline = time.monotonic() + 65
|
||||
while time.monotonic() < deadline:
|
||||
row = await self._read(workspace, provider)
|
||||
if row is None:
|
||||
return
|
||||
old = row['payload']
|
||||
if self._attempt(old, context, authorization_id) is None:
|
||||
return
|
||||
lease_owner = row['lease_owner']
|
||||
if lease_owner and lease_owner.startswith('refresh:') and row['lease_until'] > time.time():
|
||||
# A rotated refresh token must be committed before removing the attempt.
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
payload = dict(old)
|
||||
payload.pop('pending', None)
|
||||
result = await self._execute(
|
||||
sa.update(CodexCredential)
|
||||
.where(
|
||||
*self._where(workspace, provider),
|
||||
CodexCredential.version == row['version'],
|
||||
# Lease acquisition does not change version; fence that race too.
|
||||
CodexCredential.lease_owner == lease_owner,
|
||||
)
|
||||
.values(payload=payload, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
|
||||
)
|
||||
if result.rowcount == 1:
|
||||
return
|
||||
raise ValueError('Authorization changed concurrently. Please retry cancellation.')
|
||||
|
||||
async def access(self, context, provider: str, *, rejected_token: str | None = None) -> dict:
|
||||
workspace = await self._provider(context, provider)
|
||||
row = await self._read(workspace, provider)
|
||||
payload = row['payload'] if row else {}
|
||||
tokens = payload.get('tokens')
|
||||
if not tokens or payload.get('invalid'):
|
||||
raise ValueError(LOGIN_REQUIRED)
|
||||
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
|
||||
return tokens
|
||||
async with self._lease(workspace, provider, refresh=True) as owner:
|
||||
row = await self._read(workspace, provider)
|
||||
payload = dict(row['payload'])
|
||||
tokens = payload.get('tokens')
|
||||
if not tokens or payload.get('invalid'):
|
||||
raise ValueError(LOGIN_REQUIRED)
|
||||
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
|
||||
return tokens
|
||||
response = await self._post(
|
||||
'/oauth/token',
|
||||
data={'grant_type': 'refresh_token', 'client_id': CLIENT_ID, 'refresh_token': tokens['refresh_token']},
|
||||
)
|
||||
error = self._json(response).get('error') if response.status_code in (400, 401, 403) else None
|
||||
error_code = error.get('code') if isinstance(error, dict) else error
|
||||
if error_code in (
|
||||
'invalid_grant',
|
||||
'refresh_token_reused',
|
||||
'refresh_token_expired',
|
||||
'refresh_token_revoked',
|
||||
):
|
||||
payload['invalid'] = True
|
||||
payload.pop('tokens', None)
|
||||
payload.pop('completed', None)
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
raise ValueError(LOGIN_REQUIRED)
|
||||
if response.status_code != 200:
|
||||
raise ValueError('ChatGPT token refresh temporarily failed. Please retry.')
|
||||
refreshed = _tokens(self._json(response), tokens)
|
||||
payload['tokens'] = refreshed
|
||||
await self._save(workspace, provider, owner, payload)
|
||||
return refreshed
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Explicitly safe Codex failures; never construct messages from upstream bodies."""
|
||||
|
||||
|
||||
class CodexProviderError(ValueError):
|
||||
"""A known provider failure safe to expose at the HTTP boundary."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
@@ -18,7 +18,7 @@ from ...discover import engine
|
||||
from ...entity.errors import provider as provider_errors
|
||||
from ...entity.persistence import model as persistence_model
|
||||
from ...workspace.entities import WorkspaceExecutionBinding
|
||||
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError
|
||||
from . import requester, token
|
||||
|
||||
|
||||
@@ -638,10 +638,32 @@ class ModelManager:
|
||||
) -> requester.RuntimeLLMModel:
|
||||
execution_context = await self.resolve_execution_context(context)
|
||||
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
||||
runtime_provider = await self._build_provider(
|
||||
execution_context,
|
||||
persistence_model.ModelProvider(**provider_info),
|
||||
)
|
||||
provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid')
|
||||
inline_codex = provider_info.get('requester') == 'openai-codex'
|
||||
provider_entity = persistence_model.ModelProvider(**provider_info)
|
||||
if provider_uuid:
|
||||
if provider_info.get('uuid') and provider_info['uuid'] != provider_uuid:
|
||||
raise ValueError('Conflicting provider identities')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
|
||||
persistence_model.ModelProvider.uuid == provider_uuid,
|
||||
)
|
||||
)
|
||||
saved_provider = result.first()
|
||||
if saved_provider is None:
|
||||
if inline_codex or model_info.get('provider_uuid'):
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
else:
|
||||
saved_provider = self._coerce_provider(saved_provider, execution_context)
|
||||
if saved_provider.requester == 'openai-codex':
|
||||
# OAuth identity and transport configuration are server-owned.
|
||||
provider_entity = saved_provider
|
||||
elif inline_codex:
|
||||
raise ValueError('This provider does not use ChatGPT sign-in')
|
||||
elif inline_codex:
|
||||
raise WorkspaceNotFoundError('Provider not found')
|
||||
runtime_provider = await self._build_provider(execution_context, provider_entity)
|
||||
model_entity = persistence_model.LLMModel(
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
uuid=model_info.get('uuid', ''),
|
||||
@@ -723,6 +745,10 @@ class ModelManager:
|
||||
'requester_name': provider_entity.requester,
|
||||
}
|
||||
|
||||
if provider_entity.requester == 'openai-codex':
|
||||
config['provider_uuid'] = provider_entity.uuid
|
||||
config['workspace_uuid'] = context.workspace_uuid
|
||||
|
||||
if litellm_provider:
|
||||
from .requesters import litellmchat
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import copy
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .requester import RuntimeLLMModel
|
||||
|
||||
|
||||
ReasoningLevel = typing.Literal[
|
||||
@@ -123,3 +127,30 @@ def default_reasoning_capabilities(
|
||||
'levels': ['provider_default'],
|
||||
'source': source,
|
||||
}
|
||||
|
||||
|
||||
def model_with_reasoning_level(
|
||||
model: RuntimeLLMModel,
|
||||
level: ReasoningLevel | None,
|
||||
) -> RuntimeLLMModel:
|
||||
"""Clone only the runtime wrapper, after the caller has authorized the model.
|
||||
|
||||
Do not mutate shared model entities or providers. Requesters retain ownership
|
||||
of ability/capability validation and provider-specific argument translation.
|
||||
"""
|
||||
if level is None:
|
||||
return model
|
||||
abilities = set(model.model_entity.abilities or [])
|
||||
# Managed Space models are read-only. Their detected capability is also what
|
||||
# the model API and UI expose, even when the catalog omits the ability flag.
|
||||
# Custom providers still require the user's explicit ability setting.
|
||||
if (
|
||||
'reasoning' not in abilities
|
||||
and model.provider.provider_entity.requester == 'space-chat-completions'
|
||||
and model.provider.requester.get_reasoning_capabilities(model).get('supported') is True
|
||||
):
|
||||
abilities.add('reasoning')
|
||||
config = validate_reasoning_config({'level': level}, abilities, model.model_entity.extra_args)
|
||||
scoped_model = copy.copy(model)
|
||||
scoped_model.reasoning_config_override = copy.deepcopy(config)
|
||||
return scoped_model
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Native ChatGPT Codex Responses/SSE requester (never Chat Completions)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import httpx
|
||||
import langbot
|
||||
import langbot_plugin.api.entities.builtin.provider.message as pm
|
||||
|
||||
from .. import requester, reasoning
|
||||
from ..codex_auth import BASE_URL, CodexAuth, LOGIN_REQUIRED
|
||||
from ..codex_errors import CodexProviderError
|
||||
|
||||
|
||||
async def sse_events(response):
|
||||
"""Decode SSE records, including CRLF, comments, and multiline data."""
|
||||
data = []
|
||||
size = 0
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
if data:
|
||||
text = '\n'.join(data)
|
||||
if text == '[DONE]':
|
||||
return
|
||||
try:
|
||||
event = json.loads(text)
|
||||
if not isinstance(event, dict):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError('Codex returned an invalid stream event') from None
|
||||
yield event
|
||||
data, size = [], 0
|
||||
elif line.startswith('data:'):
|
||||
value = line[5:]
|
||||
if value.startswith(' '):
|
||||
value = value[1:]
|
||||
size += len(value)
|
||||
if size > 4 * 1024 * 1024:
|
||||
raise ValueError('Codex stream event exceeds the size limit')
|
||||
data.append(value)
|
||||
# SSE requires the blank separator; unterminated records cannot prove completion.
|
||||
|
||||
|
||||
def _content(message):
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
return [{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': content}]
|
||||
result = []
|
||||
for part in content or []:
|
||||
if part.type == 'text':
|
||||
result.append(
|
||||
{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': part.text or ''}
|
||||
)
|
||||
elif part.type == 'image_url' and part.image_url is not None:
|
||||
result.append({'type': 'input_image', 'image_url': part.image_url.url})
|
||||
elif part.type == 'image_base64' and part.image_base64:
|
||||
value = part.image_base64
|
||||
result.append(
|
||||
{
|
||||
'type': 'input_image',
|
||||
'image_url': value if value.startswith('data:') else 'data:image/png;base64,' + value,
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise ValueError('Codex supports text and images only; this message contains unsupported content')
|
||||
return result
|
||||
|
||||
|
||||
def _tool(item):
|
||||
try:
|
||||
return pm.ToolCall(
|
||||
id=item['call_id'],
|
||||
type='function',
|
||||
function=pm.FunctionCall(name=item['name'], arguments=item.get('arguments') or ''),
|
||||
)
|
||||
except (KeyError, ValueError, TypeError):
|
||||
raise ValueError('Codex returned an invalid function call') from None
|
||||
|
||||
|
||||
def _usage(response):
|
||||
usage = response.get('usage') or {}
|
||||
return {
|
||||
'prompt_tokens': usage.get('input_tokens', 0),
|
||||
'completion_tokens': usage.get('output_tokens', 0),
|
||||
'total_tokens': usage.get('total_tokens', usage.get('input_tokens', 0) + usage.get('output_tokens', 0)),
|
||||
'prompt_tokens_details': usage.get('input_tokens_details', {}),
|
||||
'completion_tokens_details': usage.get('output_tokens_details', {}),
|
||||
}
|
||||
|
||||
|
||||
class CodexRequester(requester.ProviderAPIRequester):
|
||||
async def initialize(self):
|
||||
self.auth = CodexAuth(self.ap)
|
||||
self.workspace = self.requester_cfg['workspace_uuid']
|
||||
self.provider = self.requester_cfg['provider_uuid']
|
||||
# Opaque replay data stays server-side; handles are scoped to the same query,
|
||||
# model and OAuth connection. No token or encrypted reasoning enters messages.
|
||||
self._replay = OrderedDict()
|
||||
|
||||
async def aclose(self):
|
||||
self._replay.clear()
|
||||
|
||||
def get_reasoning_capabilities(self, model):
|
||||
return {
|
||||
'supported': True,
|
||||
'levels': ['provider_default', 'low', 'medium', 'high', 'xhigh'],
|
||||
'source': 'provider',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _headers(tokens, *, stream=False):
|
||||
return {
|
||||
'Authorization': 'Bearer ' + tokens['access_token'],
|
||||
'ChatGPT-Account-ID': tokens['account_id'],
|
||||
'User-Agent': 'LangBot/' + langbot.__version__,
|
||||
'originator': 'langbot',
|
||||
'OpenAI-Beta': 'responses=experimental',
|
||||
'Accept': 'text/event-stream' if stream else 'application/json',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _http_error(status):
|
||||
if status == 401:
|
||||
# Upstream authentication is not LangBot authentication: HTTP401 would
|
||||
# make the browser discard its own valid user session.
|
||||
return CodexProviderError(LOGIN_REQUIRED, 400, 'codex_reauthentication_required')
|
||||
if status == 429:
|
||||
return CodexProviderError(
|
||||
'ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.',
|
||||
429,
|
||||
'codex_rate_limited',
|
||||
)
|
||||
if status == 403:
|
||||
return CodexProviderError(
|
||||
'ChatGPT denied this request. Check subscription and workspace permissions.',
|
||||
403,
|
||||
'codex_access_denied',
|
||||
)
|
||||
if status == 400:
|
||||
return CodexProviderError(
|
||||
'ChatGPT rejected the model or request. Check the selected model and request settings.',
|
||||
400,
|
||||
'codex_invalid_request',
|
||||
)
|
||||
return CodexProviderError('ChatGPT Codex upstream request failed. Please retry later.')
|
||||
|
||||
async def _response_error(self, response):
|
||||
# Inspect only a bounded 429 error record and an allowlisted machine code.
|
||||
# Never expose upstream prose, reset metadata, headers or credentials.
|
||||
if response.status_code == 429:
|
||||
payload = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
if len(payload) + len(chunk) > 8192:
|
||||
return self._http_error(429)
|
||||
payload.extend(chunk)
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
error = data.get('error') if isinstance(data, dict) else None
|
||||
if isinstance(error, dict) and (
|
||||
error.get('type') == 'usage_limit_reached' or error.get('code') == 'usage_limit_reached'
|
||||
):
|
||||
return CodexProviderError(
|
||||
'ChatGPT subscription usage limit reached. Please retry later or check your plan.',
|
||||
429,
|
||||
'codex_usage_limit_reached',
|
||||
)
|
||||
except (ValueError, UnicodeError):
|
||||
pass
|
||||
return self._http_error(response.status_code)
|
||||
|
||||
def _scope(self, query, model, tokens):
|
||||
return (
|
||||
id(query),
|
||||
getattr(query, 'query_id', None),
|
||||
model.model_entity.name,
|
||||
tokens.get('connection_id'),
|
||||
tokens['account_id'],
|
||||
)
|
||||
|
||||
def _body(self, query, model, messages, funcs, extra_args, tokens):
|
||||
args = {**(model.model_entity.extra_args or {}), **(extra_args or {})}
|
||||
# Never permit credentials, transport overrides, store/history or arbitrary
|
||||
# SDK kwargs to be smuggled through model advanced parameters.
|
||||
allowed = {'reasoning', 'text', 'parallel_tool_calls', 'tool_choice'}
|
||||
unknown = set(args) - allowed
|
||||
if unknown:
|
||||
raise ValueError('Unsupported Codex advanced parameters: ' + ', '.join(sorted(unknown)))
|
||||
instructions = []
|
||||
items = []
|
||||
scope = self._scope(query, model, tokens)
|
||||
for message in messages:
|
||||
if message.role in ('system', 'developer'):
|
||||
instructions.append('\n'.join(p['text'] for p in _content(message) if 'text' in p))
|
||||
continue
|
||||
if message.role == 'tool':
|
||||
if not message.tool_call_id:
|
||||
raise ValueError('Codex tool results require a tool_call_id')
|
||||
output = (
|
||||
message.content
|
||||
if isinstance(message.content, str)
|
||||
else json.dumps([p.model_dump(exclude_none=True) for p in message.content or []])
|
||||
)
|
||||
items.append({'type': 'function_call_output', 'call_id': message.tool_call_id, 'output': output or ''})
|
||||
continue
|
||||
if message.role not in ('assistant', 'user'):
|
||||
raise ValueError('Unsupported Codex message role')
|
||||
handle = (message.provider_specific_fields or {}).get('codex_replay_id')
|
||||
cached = self._replay.get(handle) if isinstance(handle, str) else None
|
||||
if query is not None and cached and cached[0] == scope and cached[1] > time.time():
|
||||
items.extend(cached[2])
|
||||
continue
|
||||
content = _content(message)
|
||||
if content:
|
||||
items.append({'type': 'message', 'role': message.role, 'content': content})
|
||||
for call in message.tool_calls or []:
|
||||
items.append(
|
||||
{
|
||||
'type': 'function_call',
|
||||
'call_id': call.id,
|
||||
'name': call.function.name,
|
||||
'arguments': call.function.arguments,
|
||||
}
|
||||
)
|
||||
body = {
|
||||
**args,
|
||||
'model': model.model_entity.name,
|
||||
'instructions': '\n\n'.join(instructions),
|
||||
'input': items,
|
||||
'store': False,
|
||||
'stream': True,
|
||||
'include': ['reasoning.encrypted_content'],
|
||||
}
|
||||
reasoning_config = getattr(model, 'reasoning_config_override', None)
|
||||
if reasoning_config is None:
|
||||
reasoning_config = getattr(model.model_entity, 'reasoning_config', None)
|
||||
level = reasoning.normalize_reasoning_config(reasoning_config)['level']
|
||||
if level != 'provider_default':
|
||||
reasoning.validate_reasoning_capabilities(
|
||||
{'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name
|
||||
)
|
||||
body['reasoning'] = {'effort': level, 'summary': 'auto'}
|
||||
if funcs:
|
||||
body['tools'] = [
|
||||
{
|
||||
'type': 'function',
|
||||
'name': f.name,
|
||||
'description': f.description,
|
||||
'parameters': f.parameters,
|
||||
'strict': False,
|
||||
}
|
||||
for f in funcs
|
||||
]
|
||||
return body
|
||||
|
||||
async def _events(self, query, model, messages, funcs, extra_args):
|
||||
tokens = await self.auth.access(self.workspace, self.provider)
|
||||
try:
|
||||
async with asyncio.timeout(300), httpx.AsyncClient(timeout=120, follow_redirects=False) as client:
|
||||
for attempt in range(2):
|
||||
body = self._body(query, model, messages, funcs, extra_args, tokens)
|
||||
async with client.stream(
|
||||
'POST', BASE_URL + '/responses', json=body, headers=self._headers(tokens, stream=True)
|
||||
) as response:
|
||||
if response.status_code == 401 and attempt == 0:
|
||||
tokens = await self.auth.access(
|
||||
self.workspace, self.provider, rejected_token=tokens['access_token']
|
||||
)
|
||||
continue
|
||||
if response.status_code != 200:
|
||||
raise await self._response_error(response)
|
||||
async for event in sse_events(response):
|
||||
yield event, tokens
|
||||
return
|
||||
except (httpx.HTTPError, TimeoutError):
|
||||
raise ValueError('ChatGPT Codex network error or timeout. Please retry.') from None
|
||||
|
||||
async def _chunks(self, query, model, messages, funcs, extra_args, remove_think, usage_out):
|
||||
text = ''
|
||||
seen_calls = set()
|
||||
output_items = {}
|
||||
response_id = None
|
||||
async for event, tokens in self._events(query, model, messages, funcs, extra_args):
|
||||
kind = event.get('type')
|
||||
response = event.get('response') or {}
|
||||
response_id = response.get('id') or response_id
|
||||
if kind in ('error', 'response.failed', 'response.incomplete'):
|
||||
raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.')
|
||||
if kind == 'response.output_text.delta':
|
||||
delta = event.get('delta', '')
|
||||
text += delta
|
||||
yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id)
|
||||
elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'):
|
||||
if not remove_think:
|
||||
yield pm.MessageChunk(
|
||||
role='assistant',
|
||||
content='',
|
||||
provider_specific_fields={'reasoning_content': event.get('delta', '')},
|
||||
)
|
||||
elif kind == 'response.output_item.done':
|
||||
item = event.get('item') or {}
|
||||
output_items[event.get('output_index', len(output_items))] = item
|
||||
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
|
||||
seen_calls.add(item.get('call_id'))
|
||||
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
|
||||
elif kind in ('response.completed', 'response.done'):
|
||||
if response.get('status') not in (None, 'completed'):
|
||||
raise CodexProviderError('ChatGPT Codex response was not completed')
|
||||
output = response.get('output') or [output_items[k] for k in sorted(output_items)]
|
||||
for item in output:
|
||||
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
|
||||
seen_calls.add(item.get('call_id'))
|
||||
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
|
||||
# Some servers send only the terminal output, without text deltas.
|
||||
final_text = ''.join(
|
||||
p.get('text', '')
|
||||
for item in output
|
||||
if item.get('type') == 'message'
|
||||
for p in item.get('content', [])
|
||||
if p.get('type') == 'output_text'
|
||||
)
|
||||
if not text and final_text:
|
||||
text = final_text
|
||||
yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id)
|
||||
usage_out.update(_usage(response))
|
||||
if query is not None:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out)
|
||||
fields = None
|
||||
if query is not None and output:
|
||||
handle = secrets.token_urlsafe(24)
|
||||
self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output)
|
||||
while len(self._replay) > 64:
|
||||
self._replay.popitem(last=False)
|
||||
fields = {'codex_replay_id': handle}
|
||||
yield pm.MessageChunk(
|
||||
role='assistant',
|
||||
content='',
|
||||
all_content=text,
|
||||
is_final=True,
|
||||
resp_message_id=response_id,
|
||||
provider_specific_fields=fields,
|
||||
)
|
||||
return
|
||||
raise CodexProviderError('ChatGPT Codex stream ended before completion. Please retry.')
|
||||
|
||||
async def invoke_llm_stream(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
|
||||
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, {}):
|
||||
yield chunk
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
|
||||
usage = {}
|
||||
text = ''
|
||||
calls = []
|
||||
fields = {}
|
||||
response_id = None
|
||||
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, usage):
|
||||
text += chunk.content or ''
|
||||
calls.extend(chunk.tool_calls or [])
|
||||
response_id = chunk.resp_message_id or response_id
|
||||
for key, value in (chunk.provider_specific_fields or {}).items():
|
||||
fields[key] = fields.get(key, '') + value if key == 'reasoning_content' else value
|
||||
return pm.Message(
|
||||
role='assistant',
|
||||
content=text,
|
||||
tool_calls=calls or None,
|
||||
resp_message_id=response_id,
|
||||
provider_specific_fields=fields or None,
|
||||
), usage
|
||||
|
||||
async def scan_models(self, api_key=None):
|
||||
tokens = await self.auth.access(self.workspace, self.provider)
|
||||
try:
|
||||
async with asyncio.timeout(90), httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
|
||||
for attempt in range(2):
|
||||
response = await client.get(
|
||||
BASE_URL + '/models',
|
||||
params={'client_version': langbot.__version__},
|
||||
headers=self._headers(tokens),
|
||||
)
|
||||
if response.status_code == 401 and attempt == 0:
|
||||
tokens = await self.auth.access(
|
||||
self.workspace, self.provider, rejected_token=tokens['access_token']
|
||||
)
|
||||
continue
|
||||
if response.status_code != 200:
|
||||
raise await self._response_error(response)
|
||||
data = response.json()
|
||||
if not isinstance(data, dict) or not isinstance(data.get('models'), list):
|
||||
raise ValueError('ChatGPT returned an invalid model catalog')
|
||||
result = {}
|
||||
for item in data['models']:
|
||||
name = item.get('slug') or item.get('id')
|
||||
if not isinstance(name, str) or not name or item.get('visibility') == 'hide':
|
||||
continue
|
||||
modalities = item.get('input_modalities') or ['text']
|
||||
abilities = ['func_call']
|
||||
if 'image' in modalities:
|
||||
abilities.append('vision')
|
||||
if item.get('supported_reasoning_levels'):
|
||||
abilities.append('reasoning')
|
||||
result[name] = {
|
||||
'id': name,
|
||||
'name': name,
|
||||
'type': 'llm',
|
||||
'abilities': abilities,
|
||||
'display_name': item.get('display_name'),
|
||||
'description': item.get('description'),
|
||||
'context_length': item.get('context_window'),
|
||||
'input_modalities': modalities,
|
||||
'output_modalities': ['text'],
|
||||
'owned_by': 'openai',
|
||||
}
|
||||
return {'models': list(result.values()), 'debug': None}
|
||||
except (httpx.HTTPError, TimeoutError):
|
||||
raise ValueError('ChatGPT model discovery network error. Please retry.') from None
|
||||
except (ValueError, TypeError, KeyError, AttributeError) as exc:
|
||||
# Never echo upstream response bodies (which may contain credentials).
|
||||
if isinstance(exc, ValueError) and str(exc).startswith(('ChatGPT', 'Codex')):
|
||||
raise
|
||||
raise ValueError('ChatGPT returned an invalid model catalog') from None
|
||||
@@ -0,0 +1,27 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: openai-codex
|
||||
label:
|
||||
en_US: OpenAI Codex
|
||||
zh_Hans: OpenAI Codex
|
||||
ja_JP: OpenAI Codex
|
||||
icon: openai.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: ChatGPT endpoint
|
||||
zh_Hans: ChatGPT 服务地址
|
||||
ja_JP: ChatGPT エンドポイント
|
||||
type: string
|
||||
required: false
|
||||
default: https://chatgpt.com/backend-api/codex
|
||||
alias: "openai codex ChatGPT subscription OAuth 订阅"
|
||||
support_type:
|
||||
- llm
|
||||
provider_category: manufacturer
|
||||
execution:
|
||||
python:
|
||||
path: ./codex.py
|
||||
attr: CodexRequester
|
||||
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
levels = ['provider_default', 'disabled', 'enabled']
|
||||
elif family == 'doubao':
|
||||
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||
elif family == 'ollama':
|
||||
elif family in ('ollama', 'ollama_chat'):
|
||||
levels = ['provider_default']
|
||||
levels.append('disabled')
|
||||
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
||||
@@ -1379,7 +1379,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> tuple[list[list[float]], dict]:
|
||||
"""Invoke embedding and return vectors with usage info."""
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
# litellm's embedding routing has no "ollama_chat" branch (that provider
|
||||
# exists only for /api/chat completions) — embeddings still go through
|
||||
# the plain "ollama" provider. Requesters configured for ollama_chat
|
||||
# (to get native tool-calling on the chat path) must fall back to
|
||||
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
|
||||
# provider for this endpoint".
|
||||
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
@@ -1575,6 +1582,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||
) as client:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
|
||||
# Some OpenAI-compatible servers (notably a bare Ollama host,
|
||||
# e.g. http://host:11434) expose the model list under /v1/models
|
||||
# rather than /models. Providers whose configured base_url
|
||||
# already ends in /v1 keep their original (working) URL.
|
||||
response = await client.get(f'{base_url}/v1/models', headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = await httpclient.parse_json_response(response)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ metadata:
|
||||
zh_Hans: Ollama
|
||||
icon: ollama.svg
|
||||
spec:
|
||||
litellm_provider: ollama
|
||||
litellm_provider: ollama_chat
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
|
||||
<rect width="64" height="64" rx="14" fill="#111827"/>
|
||||
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
|
||||
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="tokenlab-symbol-title tokenlab-symbol-desc">
|
||||
<title id="tokenlab-symbol-title">TokenLab</title>
|
||||
<desc id="tokenlab-symbol-desc">Specimen Split symbol, positive master for sizes from 32 to 96 pixels.</desc>
|
||||
<g fill="#151714">
|
||||
<path d="M5 13h14.25L35 43H5V13Z"/>
|
||||
<path d="M20.7 5H43v30H35.6L20.7 5Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 265 B After Width: | Height: | Size: 416 B |
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
import json
|
||||
import math
|
||||
@@ -208,6 +209,13 @@ class MCPSessionStatus(enum.Enum):
|
||||
ERROR = 'error'
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MCPOAuthChallenge:
|
||||
"""Bearer challenge metadata returned by an OAuth-protected MCP server."""
|
||||
|
||||
resource_metadata_url: str | None
|
||||
|
||||
|
||||
class _TransportReconnect(Exception):
|
||||
"""Internal signal: the Box stdio WS transport dropped but the managed
|
||||
process is still alive. Triggers a lightweight transport reconnect that
|
||||
@@ -267,6 +275,7 @@ class RuntimeMCPSession:
|
||||
_ready_event: asyncio.Event
|
||||
|
||||
error_message: str | None = None
|
||||
_public_error_code: str = 'runtime_error'
|
||||
|
||||
error_phase: MCPSessionErrorPhase | None = None
|
||||
|
||||
@@ -513,6 +522,13 @@ class RuntimeMCPSession:
|
||||
await self._init_streamable_http_server()
|
||||
return
|
||||
except Exception as e:
|
||||
if self._extract_oauth_challenge(e) is not None:
|
||||
self.error_phase = MCPSessionErrorPhase.OAUTH_REQUIRED
|
||||
self.ap.logger.info(
|
||||
f'MCP server {self.server_name}: remote server requires OAuth authorization; '
|
||||
'not falling back to SSE'
|
||||
)
|
||||
raise
|
||||
if not self._should_fallback_to_sse(e):
|
||||
self.ap.logger.info(
|
||||
f'MCP server {self.server_name}: Streamable HTTP transport failed '
|
||||
@@ -636,6 +652,7 @@ class RuntimeMCPSession:
|
||||
except Exception as e:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = str(e)
|
||||
self._public_error_code = self._classify_public_error(e)
|
||||
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
|
||||
# Do NOT set _ready_event here — let _lifecycle_loop_with_retry
|
||||
# handle retries first. It will set the event when all retries
|
||||
@@ -759,6 +776,11 @@ class RuntimeMCPSession:
|
||||
self.retry_count += 1
|
||||
if self._shutdown_event.is_set():
|
||||
return # Shutdown requested, don't retry
|
||||
if self.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED:
|
||||
self.retry_count = attempt + 1
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self._ready_event.set()
|
||||
return
|
||||
# A lifecycle that reached CONNECTED proved its preceding
|
||||
# startup attempt was healthy. Give a later runtime failure a
|
||||
# fresh consecutive-startup retry budget instead of exhausting
|
||||
@@ -897,6 +919,39 @@ class RuntimeMCPSession:
|
||||
else:
|
||||
yield exc
|
||||
|
||||
@staticmethod
|
||||
def _classify_public_error(exc: BaseException) -> str:
|
||||
"""Expose a safe category without transport URLs, headers, or arguments."""
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
|
||||
if isinstance(leaf, httpx.HTTPStatusError):
|
||||
return f'http_{leaf.response.status_code}'
|
||||
if isinstance(leaf, (httpx.TimeoutException, TimeoutError)):
|
||||
return 'connection_timeout'
|
||||
if isinstance(leaf, httpx.ConnectError):
|
||||
return 'connection_unreachable'
|
||||
return 'runtime_error'
|
||||
|
||||
@staticmethod
|
||||
def _extract_oauth_challenge(exc: BaseException) -> MCPOAuthChallenge | None:
|
||||
"""Extract an OAuth Bearer challenge from a remote MCP connection failure."""
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
|
||||
if not isinstance(leaf, httpx.HTTPStatusError) or leaf.response.status_code != 401:
|
||||
continue
|
||||
for header in leaf.response.headers.get_list('www-authenticate'):
|
||||
bearer_match = re.search(r'(?:^|,)\s*Bearer(?:\s|,|$)', header, flags=re.IGNORECASE)
|
||||
if bearer_match is None:
|
||||
continue
|
||||
metadata_match = re.search(
|
||||
r'(?:^|,)\s*resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))',
|
||||
header[bearer_match.end() :],
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if metadata_match is None:
|
||||
continue
|
||||
resource_metadata_url = metadata_match.group(1) or metadata_match.group(2)
|
||||
return MCPOAuthChallenge(resource_metadata_url=resource_metadata_url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _should_fallback_to_sse(exc: BaseException) -> bool:
|
||||
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
|
||||
@@ -1439,7 +1494,7 @@ class RuntimeMCPSession:
|
||||
# environment values. Detailed diagnostics belong in AUDIT_VIEW
|
||||
# logs; resource-list responses expose only a stable status.
|
||||
'error_message': 'MCP runtime failed' if self.error_message else None,
|
||||
'error_code': 'runtime_error' if self.error_message else None,
|
||||
'error_code': self._public_error_code if self.error_message else None,
|
||||
'error_phase': self.error_phase.value if self.error_phase else None,
|
||||
'retry_count': self.retry_count,
|
||||
'tool_count': len(self.get_tools()),
|
||||
|
||||
@@ -52,6 +52,7 @@ class MCPSessionErrorPhase(enum.Enum):
|
||||
MCP_INIT = 'mcp_init'
|
||||
RUNTIME = 'runtime'
|
||||
TOOL_CALL = 'tool_call'
|
||||
OAUTH_REQUIRED = 'oauth_required'
|
||||
# Stdio MCP refused because Box is disabled in config or currently
|
||||
# unavailable. Not transient — retries would be pointless. The frontend
|
||||
# uses this phase to render a localized actionable message instead of
|
||||
|
||||
@@ -223,6 +223,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
self.ap.logger.warning(
|
||||
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host. '
|
||||
'The LLM will not have access to code execution or file operation tools.'
|
||||
)
|
||||
|
||||
@@ -263,6 +264,9 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
|
||||
from ....box.runner import binding_for
|
||||
|
||||
binding_for(query)
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
|
||||
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
else:
|
||||
self.ap.logger.info(
|
||||
'Skill tools (activate/register_skill) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready.'
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host.'
|
||||
)
|
||||
|
||||
async def _check_sandbox_available(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import datetime
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import xxhash
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core import app
|
||||
from . import mgr as storage_mgr
|
||||
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
DEFAULT_MAX_SIZE_MB = 0
|
||||
MEDIA_DIR = 'media_cache'
|
||||
SAFE_MEDIA_FILENAME = re.compile(r'^[a-f0-9]{32,64}(\.[a-zA-Z0-9]{1,10})?$')
|
||||
|
||||
|
||||
class MediaCache:
|
||||
"""Content-addressable storage cache for images and media attachments.
|
||||
|
||||
Deduplicates media files using xxHash3-128,
|
||||
offloads payloads from SQLite to StorageProvider, and implements LRU
|
||||
and age-based retention cleanup.
|
||||
"""
|
||||
|
||||
def __init__(self, ap: app.Application, storage_mgr: storage_mgr.StorageMgr):
|
||||
self.ap = ap
|
||||
self.storage_mgr = storage_mgr
|
||||
|
||||
@staticmethod
|
||||
def hash_bytes(data: bytes) -> str:
|
||||
"""Compute content-addressable hash for binary data."""
|
||||
return xxhash.xxh3_128_hexdigest(data)
|
||||
|
||||
@staticmethod
|
||||
def parse_data_url(data_url: str) -> tuple[bytes, str] | None:
|
||||
"""Parse a data URL or raw base64 string into bytes and mime type."""
|
||||
if not data_url or not isinstance(data_url, str):
|
||||
return None
|
||||
try:
|
||||
if data_url.startswith('data:'):
|
||||
split_index = data_url.find(';base64,')
|
||||
if split_index != -1:
|
||||
mime_type = data_url[5:split_index]
|
||||
b64_data = data_url[split_index + 8 :]
|
||||
return base64.b64decode(b64_data), mime_type
|
||||
# Try raw base64 if sufficiently long
|
||||
if len(data_url) > 20 and not data_url.startswith(('http://', 'https://', '/')):
|
||||
return base64.b64decode(data_url), 'application/octet-stream'
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def guess_extension(mime_type: str | None, default: str = '.jpg') -> str:
|
||||
"""Guess appropriate file extension from MIME type."""
|
||||
if not mime_type:
|
||||
return default
|
||||
mime_lower = mime_type.lower()
|
||||
if 'png' in mime_lower:
|
||||
return '.png'
|
||||
if 'webp' in mime_lower:
|
||||
return '.webp'
|
||||
if 'gif' in mime_lower:
|
||||
return '.gif'
|
||||
if 'jpeg' in mime_lower or 'jpg' in mime_lower:
|
||||
return '.jpg'
|
||||
ext = mimetypes.guess_extension(mime_type)
|
||||
if ext == '.jpe':
|
||||
return '.jpg'
|
||||
return ext or default
|
||||
|
||||
async def save_media(self, data: bytes, mime_type: str | None = None) -> tuple[str, str, int]:
|
||||
"""Save media bytes into content-addressable storage cache.
|
||||
|
||||
Returns:
|
||||
Tuple of (hash_str, storage_key, byte_size)
|
||||
"""
|
||||
hash_str = self.hash_bytes(data)
|
||||
ext = self.guess_extension(mime_type)
|
||||
storage_key = f'{MEDIA_DIR}/{hash_str}{ext}'
|
||||
provider = self.storage_mgr.storage_provider
|
||||
|
||||
if not await provider.exists(storage_key):
|
||||
await provider.save(storage_key, data)
|
||||
else:
|
||||
await self.touch(storage_key)
|
||||
|
||||
return hash_str, storage_key, len(data)
|
||||
|
||||
async def get_media(self, filename_or_key: str) -> tuple[bytes, str] | None:
|
||||
"""Retrieve media bytes and mime type by key or filename."""
|
||||
filename = os.path.basename(filename_or_key)
|
||||
if not SAFE_MEDIA_FILENAME.match(filename):
|
||||
return None
|
||||
storage_key = f'{MEDIA_DIR}/{filename}'
|
||||
provider = self.storage_mgr.storage_provider
|
||||
|
||||
if not await provider.exists(storage_key):
|
||||
return None
|
||||
|
||||
data = await self.storage_mgr._load_object_bounded(storage_key)
|
||||
mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
||||
await self.touch(storage_key)
|
||||
return data, mime_type
|
||||
|
||||
async def touch(self, storage_key: str) -> None:
|
||||
"""Update access/modified time of a media file for LRU tracking."""
|
||||
provider = getattr(self.storage_mgr, 'storage_provider', None)
|
||||
if provider is not None and provider.__class__.__name__ == 'LocalStorageProvider':
|
||||
full_path = os.path.join('data', 'storage', storage_key)
|
||||
if os.path.exists(full_path):
|
||||
now = datetime.datetime.now().timestamp()
|
||||
try:
|
||||
await asyncio.to_thread(os.utime, full_path, (now, now))
|
||||
except Exception:
|
||||
# Failures to update mtime are intentionally ignored because LRU touch is opportunistic.
|
||||
pass
|
||||
|
||||
async def cleanup(
|
||||
self,
|
||||
retention_days: int = DEFAULT_RETENTION_DAYS,
|
||||
max_size_mb: int = DEFAULT_MAX_SIZE_MB,
|
||||
) -> dict[str, int]:
|
||||
"""Perform age-based and LRU size-based cleanup on media cache.
|
||||
|
||||
Args:
|
||||
retention_days: Retain media accessed within this many days (default 30).
|
||||
max_size_mb: Maximum total size in MB (0 means unlimited).
|
||||
|
||||
Returns:
|
||||
Dictionary of cleanup metrics.
|
||||
"""
|
||||
provider = getattr(self.storage_mgr, 'storage_provider', None)
|
||||
if provider is None or provider.__class__.__name__ != 'LocalStorageProvider':
|
||||
return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0}
|
||||
|
||||
target_dir = Path('data/storage') / MEDIA_DIR
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
return {'expired_deleted': 0, 'size_deleted': 0, 'bytes_freed': 0}
|
||||
|
||||
now = datetime.datetime.now().timestamp()
|
||||
cutoff = (now - retention_days * 86400) if retention_days > 0 else 0
|
||||
|
||||
expired_deleted = 0
|
||||
size_deleted = 0
|
||||
bytes_freed = 0
|
||||
remaining: list[tuple[Path, int, float]] = []
|
||||
|
||||
for entry in target_dir.iterdir():
|
||||
if not entry.is_file():
|
||||
continue
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if cutoff > 0 and stat.st_mtime < cutoff:
|
||||
try:
|
||||
entry.unlink(missing_ok=True)
|
||||
expired_deleted += 1
|
||||
bytes_freed += stat.st_size
|
||||
except OSError:
|
||||
# Best-effort cleanup; file may already be gone or temporarily inaccessible.
|
||||
pass
|
||||
else:
|
||||
remaining.append((entry, stat.st_size, stat.st_mtime))
|
||||
|
||||
if max_size_mb > 0:
|
||||
max_bytes = max_size_mb * 1024 * 1024
|
||||
total_bytes = sum(item[1] for item in remaining)
|
||||
if total_bytes > max_bytes:
|
||||
remaining.sort(key=lambda item: item[2])
|
||||
for path, size, _ in remaining:
|
||||
if total_bytes <= max_bytes:
|
||||
break
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
size_deleted += 1
|
||||
bytes_freed += size
|
||||
total_bytes -= size
|
||||
except OSError:
|
||||
# Best-effort cleanup; file may already be gone or temporarily inaccessible.
|
||||
continue
|
||||
|
||||
return {
|
||||
'expired_deleted': expired_deleted,
|
||||
'size_deleted': size_deleted,
|
||||
'bytes_freed': bytes_freed,
|
||||
}
|
||||
|
||||
async def externalize_chain_dump(self, chain_dump: Any) -> Any:
|
||||
"""Recursively extract raw base64 media into cache and replace with references."""
|
||||
if isinstance(chain_dump, list):
|
||||
return [await self.externalize_chain_dump(item) for item in chain_dump]
|
||||
if isinstance(chain_dump, dict):
|
||||
node = copy.copy(chain_dump)
|
||||
node_type = node.get('type')
|
||||
if node_type == 'Image':
|
||||
b64 = node.get('base64')
|
||||
if b64 and isinstance(b64, str):
|
||||
try:
|
||||
parsed = self.parse_data_url(b64)
|
||||
if parsed is not None:
|
||||
raw_bytes, mime_type = parsed
|
||||
hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type)
|
||||
filename = os.path.basename(storage_key)
|
||||
current_url = node.get('url') or ''
|
||||
if current_url and not current_url.startswith('data:'):
|
||||
node['original_url'] = current_url
|
||||
node['url'] = f'/api/v1/files/media/{filename}'
|
||||
node['base64'] = None
|
||||
node['hash'] = hash_str
|
||||
node['storage_key'] = storage_key
|
||||
node['size'] = size
|
||||
node['mime_type'] = mime_type
|
||||
except Exception as e:
|
||||
if hasattr(self.ap, 'logger') and self.ap.logger:
|
||||
self.ap.logger.warning(f'Failed to externalize image to media cache: {e}')
|
||||
node['base64'] = None
|
||||
for k, v in list(node.items()):
|
||||
if isinstance(v, (list, dict)):
|
||||
node[k] = await self.externalize_chain_dump(v)
|
||||
return node
|
||||
return chain_dump
|
||||
@@ -34,6 +34,9 @@ class StorageMgr:
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
from . import media
|
||||
|
||||
self.media_cache = media.MediaCache(ap, self)
|
||||
|
||||
def _object_read_limit(self) -> int:
|
||||
config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
|
||||
|
||||
@@ -83,7 +83,7 @@ class VersionManager:
|
||||
try:
|
||||
if await self.is_new_version_available():
|
||||
return (
|
||||
'New version available. Update guide: https://link.langbot.app/en/docs/update',
|
||||
'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
|
||||
logging.INFO,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
self._collection_configs: Dict[str, HNSWConfiguration] = {}
|
||||
self._runtime_cache_limit = runtime_cache_limit(ap)
|
||||
|
||||
self._escape_table = str.maketrans(
|
||||
{
|
||||
'\x00': '',
|
||||
'\\': '\\\\',
|
||||
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
|
||||
'"': '\\"',
|
||||
'\n': '\\n',
|
||||
'\r': '\\r',
|
||||
'\t': '\\t',
|
||||
}
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._collections.clear()
|
||||
self._collection_configs.clear()
|
||||
@@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
return coll
|
||||
|
||||
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
|
||||
return {
|
||||
k: v.translate(self._escape_table)
|
||||
if isinstance(v, str)
|
||||
else v
|
||||
if v is None or isinstance(v, (int, float, bool))
|
||||
else str(v)
|
||||
for k, v in meta.items()
|
||||
if v is not None
|
||||
}
|
||||
"""Keep supported scalar metadata values without altering strings."""
|
||||
return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None}
|
||||
|
||||
@staticmethod
|
||||
def _relevance_scores_to_distances(results: Dict[str, Any]) -> None:
|
||||
"""Convert SeekDB hybrid relevance scores to lower-is-better distances."""
|
||||
distances = results.get('distances')
|
||||
if not isinstance(distances, list):
|
||||
return
|
||||
|
||||
results['distances'] = [
|
||||
[1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch]
|
||||
if isinstance(batch, list)
|
||||
else batch
|
||||
for batch in distances
|
||||
]
|
||||
|
||||
async def get_or_create_collection(self, collection: str):
|
||||
"""Get or create collection (without vector size - will use default)."""
|
||||
@@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
|
||||
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
|
||||
if documents is not None:
|
||||
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
|
||||
await asyncio.to_thread(coll.add, **kwargs)
|
||||
kwargs['documents'] = documents
|
||||
await asyncio.to_thread(coll.upsert, **kwargs)
|
||||
|
||||
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
|
||||
self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
# Route by search type.
|
||||
# pyseekdb's query() always requires embeddings, so full-text and
|
||||
# hybrid modes use hybrid_search() which supports text-only queries
|
||||
# and returns the same nested-list format with distances.
|
||||
# and returns relevance scores in the nested ``distances`` field.
|
||||
returns_relevance_scores = False
|
||||
if search_type == SearchType.FULL_TEXT:
|
||||
if not query_text:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||
@@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
returns_relevance_scores = True
|
||||
|
||||
elif search_type == SearchType.HYBRID:
|
||||
if not query_text:
|
||||
@@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
returns_relevance_scores = True
|
||||
self.ap.logger.info(
|
||||
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||
)
|
||||
@@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||
|
||||
results = self._json_safe(results)
|
||||
if returns_relevance_scores:
|
||||
self._relevance_scores_to_distances(results)
|
||||
self.ap.logger.info(
|
||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||
)
|
||||
|
||||
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 4px 20px;">
|
||||
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<img src="https://langbot.app/docs/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -106,12 +106,6 @@ system:
|
||||
max_pipelines: -1
|
||||
max_extensions: -1
|
||||
max_knowledge_bases: -1
|
||||
# When set to a non-empty string, every pipeline is forced to use this
|
||||
# Box sandbox-scope template regardless of its own configuration, and
|
||||
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
||||
# Used by SaaS deployments to confine a tenant to a single shared
|
||||
# sandbox (set to '{global}'). Empty string = no restriction.
|
||||
force_box_session_id_template: ''
|
||||
task_retention:
|
||||
# Keep at most this many completed async task records in memory
|
||||
completed_limit: 200
|
||||
@@ -227,6 +221,12 @@ storage:
|
||||
# Bound every object materialized into Core memory. Built-in Local/S3
|
||||
# providers enforce this while reading (hard cap: 64 MiB).
|
||||
max_object_read_bytes: 10485760
|
||||
# Media content cache (images & attachments externalized from monitoring and pipelines)
|
||||
media_cache:
|
||||
# Retention period in days for cached media (defaults to 30 days)
|
||||
retention_days: 30
|
||||
# Maximum disk storage for cached media in MB (0 means unlimited, defaults to 0)
|
||||
max_size_mb: 0
|
||||
cleanup:
|
||||
# Enable periodic cleanup of local/S3 uploaded files and old log files
|
||||
enabled: true
|
||||
@@ -344,7 +344,10 @@ box:
|
||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||
# be listed read-only and http/sse MCP servers continue to work.
|
||||
enabled: true
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||
# isolation. It is never auto-selected and is only for trusted local
|
||||
# development. Can be written via BOX__BACKEND.
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
|
||||
runtime:
|
||||
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
||||
# runtimes. To protect an exposed endpoint, set the same strong secret
|
||||
|
||||
@@ -645,9 +645,10 @@
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (
|
||||
prevContent === content ||
|
||||
prevContent.indexOf(content) >= 0 ||
|
||||
content.indexOf(prevContent) >= 0
|
||||
prevContent &&
|
||||
(prevContent === content ||
|
||||
prevContent.indexOf(content) >= 0 ||
|
||||
content.indexOf(prevContent) >= 0)
|
||||
)
|
||||
return;
|
||||
}
|
||||
@@ -1265,6 +1266,11 @@
|
||||
// Styles
|
||||
var style = document.createElement("style");
|
||||
style.textContent = STYLES;
|
||||
// Keep the wizard navigation accessible while the chat preview is open.
|
||||
if (scriptTestNotice) {
|
||||
style.textContent +=
|
||||
".lb-bubble { bottom: 84px; } .lb-panel { bottom: 152px; max-height: calc(100vh - 172px); } @media (max-width: 480px) { .lb-panel { bottom: 64px; height: calc(100vh - 64px); max-height: calc(100vh - 64px); } }";
|
||||
}
|
||||
shadow.appendChild(style);
|
||||
|
||||
// Chat bubble button
|
||||
|
||||
Reference in New Issue
Block a user